import { describe, expect, it } from "vitest"; import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera"; import { backgroundRect, decorate, layoutScene, polar, spriteScale, type SceneLayout, } from "../src/map/layout"; import { buildScene } from "../src/map/scene"; import { mapStyle } from "../src/map/style"; import { samplePlayerState } from "./render"; const viewports = [ { width: 390, height: 700 }, { width: 1280, height: 720 }, { width: 2560, height: 1440 }, { width: 320, height: 2000 }, ]; const pans = [-50_000, -800, -1, 0, 3, 640, 50_000]; const zooms = [MIN_ZOOM, 0.75, 1, 3, 12, MAX_ZOOM]; const images = [ { width: 1254, height: 1254 }, { width: 2048, height: 1024 }, ]; describe("background placement (decision 0.5.40)", () => { it("always fully contains the viewport", () => { for (const view of viewports) { for (const image of images) { for (const panX of pans) { for (const panY of pans) { for (const zoom of zooms) { const r = backgroundRect(view, { zoom, panX, panY }, image); const where = JSON.stringify({ view, image, panX, panY, zoom, r }); expect(r.x, where).toBeLessThanOrEqual(0); expect(r.y, where).toBeLessThanOrEqual(0); expect(r.x + r.width, where).toBeGreaterThanOrEqual(view.width - 1e-9); expect(r.y + r.height, where).toBeGreaterThanOrEqual(view.height - 1e-9); } } } } } }); it("moves a fraction of the pan (parallax) and keeps its aspect ratio", () => { const view = { width: 1280, height: 720 }; const image = { width: 1254, height: 1254 }; const still = backgroundRect(view, { zoom: 1, panX: 0, panY: 0 }, image); const panned = backgroundRect(view, { zoom: 1, panX: 100, panY: -60 }, image); expect(panned.x - still.x).toBeCloseTo(15); expect(panned.y - still.y).toBeCloseTo(-9); expect(still.width / still.height).toBeCloseTo(1); }); it("zooms only slightly with the map", () => { const view = { width: 1280, height: 720 }; const image = { width: 1254, height: 1254 }; const one = backgroundRect(view, { zoom: 1, panX: 0, panY: 0 }, image); const far = backgroundRect(view, { zoom: MAX_ZOOM, panX: 0, panY: 0 }, image); expect(far.width / one.width).toBeGreaterThan(1); expect(far.width / one.width).toBeLessThan(1.3); }); }); const dates = [ Date.parse("2100-01-01T00:00:00Z"), Date.parse("2100-03-17T08:00:00Z"), Date.parse("2101-11-02T19:30:00Z"), ]; const view = { width: 390, height: 700 }; const cameraAt = (zoom: number) => ({ zoom, panX: 13, panY: -27 }); describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => { it("keeps the sprite scale within the configured limits at extreme zooms", () => { const { min, max } = mapStyle.spriteScale; for (const zoom of [1e-6, MIN_ZOOM, 0.75, 1, 2, MAX_ZOOM, 1e6]) { expect(spriteScale(zoom)).toBeGreaterThanOrEqual(min); expect(spriteScale(zoom)).toBeLessThanOrEqual(max); } expect(spriteScale(1e-6)).toBe(min); expect(spriteScale(1e6)).toBe(max); expect(spriteScale(1)).toBe(1); }); it("puts the Moon exactly the configured offset from Earth, at any zoom and date", () => { const { distancePx, angleDeg } = mapStyle.childOffsets.moon!; for (const now of dates) { const scene = buildScene(samplePlayerState(), now); for (const zoom of [MIN_ZOOM, 1, 7, MAX_ZOOM]) { const layout = layoutScene(scene, view, cameraAt(zoom)); const earth = layout.locations.find((l) => l.id === "earth")!; const moon = layout.locations.find((l) => l.id === "moon")!; // The angle is measured from the outward direction, Sun → Earth. const outward = (Math.atan2(earth.center.y - layout.sun.center.y, earth.center.x - layout.sun.center.x) * 180) / Math.PI; const offset = polar(distancePx, outward + angleDeg); expect(moon.center.x - earth.center.x).toBeCloseTo(offset.x, 9); expect(moon.center.y - earth.center.y).toBeCloseTo(offset.y, 9); // And the two sprites never overlap. expect(Math.hypot(offset.x, offset.y)).toBeGreaterThanOrEqual((earth.size + moon.size) / 2); } } }); it("starts and ends Moon routes where the Moon is drawn", () => { const now = dates[0]!; const state = samplePlayerState({ activeDispatches: [ { id: "d", shipId: "loaner", pilotId: "avatar", contract: null, originId: "earth", destinationId: "moon", tutorial: true, departureAt: now, arrivalAt: now + 60_000, distanceMillionKm: 0.4, fuelCost: 0, expectedPayout: 0, seed: 1, status: "in-flight", resolvedAt: null, }, ], }); const layout = layoutScene(buildScene(state, now + 60_000), view, cameraAt(1)); const moon = layout.locations.find((l) => l.id === "moon")!; expect(layout.flights[0]!.to.x).toBeCloseTo(moon.center.x, 6); expect(layout.flights[0]!.to.y).toBeCloseTo(moon.center.y, 6); }); it("never makes a tap target smaller than the minimum", () => { const scene = buildScene(samplePlayerState(), dates[0]!); for (const zoom of [1e-6, MIN_ZOOM, 1, MAX_ZOOM]) { const layout = layoutScene(scene, view, cameraAt(zoom)); for (const item of [layout.sun, ...layout.locations, ...layout.flights]) { expect(item.tap).toBeGreaterThanOrEqual(mapStyle.minTapPx); expect(item.tap).toBeGreaterThanOrEqual(item.size); } } }); }); describe("labels and badges (Step 10C.6)", () => { const body = (id: string, x: number, y: number, size = 32) => ({ id, center: { x, y }, size, tap: 44, }); const layout = (locations: ReturnType[]): SceneLayout => ({ scale: 1, sun: { center: { x: -1000, y: -1000 }, size: 64, tap: 64 }, locations, flights: [], }); const size = () => ({ width: 60, height: 14 }); it("puts a label below its body when that is clear", () => { const placed = decorate(layout([body("a", 100, 100)]), size); expect(placed.get("a")!.label).toEqual({ x: 100, y: 120, anchorY: 0 }); }); it("moves a label above its body when below would cover another body", () => { const placed = decorate(layout([body("a", 100, 100), body("b", 110, 140)]), size); expect(placed.get("a")!.label).toEqual({ x: 100, y: 80, anchorY: 1 }); }); it("puts the idle badge on a diagonal clear of other bodies", () => { // The default spot (up-left) is taken by another body. const placed = decorate(layout([body("a", 100, 100), body("b", 70, 70)]), size); const badge = placed.get("a")!.badge; expect(Math.hypot(badge.x - 70, badge.y - 70)).toBeGreaterThan(24); }); });