diff --git a/DECISIONS.md b/DECISIONS.md index 923857a..1a0741f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -463,3 +463,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Camera state (zoom, pan) is a Zustand store**, per the tech stack; zoom is clamped to 0.5–40×, and "Reset view" restores it. - **The Map screen is lazy-loaded,** so PixiJS (~285 KB) downloads only when the map is opened. - **Seven tabs:** a Map tab is added after HQ. To fit 375 px, the Contracts tab is labelled "Jobs" and Market "Shop"; screen titles are unchanged. + +## Step 10.2 + +- **Route lines run from the origin's position at departure to the destination's position at arrival**, matching sim's straight-line flight model (Step 3.15). The marker is interpolated in map space by sim `dispatchProgress`; because the camera transform is affine, it stays exactly on the drawn line at any zoom or pan. +- **Idle ships are a count badge per location** rather than individual markers, so a busy bay stays readable on a phone. The idle tutorial loaner counts as one. diff --git a/apps/web/src/map/MapCanvas.tsx b/apps/web/src/map/MapCanvas.tsx index bd4f898..e6cd5ae 100644 --- a/apps/web/src/map/MapCanvas.tsx +++ b/apps/web/src/map/MapCanvas.tsx @@ -12,11 +12,13 @@ const COLORS = { discovered: 0xe2e8f0, undiscovered: 0x475569, bay: 0x34d399, + route: 0x38bdf8, + ship: 0xfbbf24, }; /** - * The system map canvas (Step 10.1). Draws every frame from `state` and the - * server clock; drag to pan, pinch or wheel to zoom. + * The system map canvas (Steps 10.1–10.2). Draws locations and ships every + * frame from `state` and the server clock; drag to pan, pinch or wheel to zoom. */ export function MapCanvas({ state }: { state: PlayerState }) { const containerRef = useRef(null); @@ -30,6 +32,7 @@ export function MapCanvas({ state }: { state: PlayerState }) { const app = new Application(); const graphics = new Graphics(); const labels = new Map(); + const badges = new Map(); const viewport = (): Viewport => ({ width: container.clientWidth, @@ -68,6 +71,33 @@ export function MapCanvas({ state }: { state: PlayerState }) { label.text = location.discovered ? location.name : `${location.name}?`; label.style.fill = color; label.position.set(p.x + 8, p.y - 7); + + // Idle ships here: a small badge with the count (Step 10.2). + let badge = badges.get(location.id); + if (!badge) { + badge = new Text({ text: "", style: { fontSize: 10, fill: COLORS.background } }); + badge.anchor.set(0.5); + badges.set(location.id, badge); + app.stage.addChild(badge); + } + badge.visible = location.idleShips > 0; + if (badge.visible) { + graphics.circle(p.x - 10, p.y + 10, 7).fill(COLORS.ship); + badge.text = String(location.idleShips); + badge.position.set(p.x - 10, p.y + 10); + } + } + + // In-flight ships: the route line and a marker moving along it (Step 10.2). + for (const flight of scene.flights) { + const from = toScreen(flight.from, view, camera); + const to = toScreen(flight.to, view, camera); + const marker = toScreen(flight.marker, view, camera); + graphics + .moveTo(from.x, from.y) + .lineTo(to.x, to.y) + .stroke({ width: 1, color: COLORS.route }); + graphics.circle(marker.x, marker.y, 4).fill(COLORS.ship); } }; diff --git a/apps/web/src/map/scene.ts b/apps/web/src/map/scene.ts index 93d676d..725b9ea 100644 --- a/apps/web/src/map/scene.ts +++ b/apps/web/src/map/scene.ts @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; -import type { PlayerState } from "@deadhead/schema"; -import { bodyPosition, realToGame } from "@deadhead/sim"; +import type { Dispatch, PlayerState } from "@deadhead/schema"; +import { bodyPosition, dispatchProgress, realToGame } from "@deadhead/sim"; import { locationMapPoint, radialScale, type MapPoint } from "./projection"; export interface SceneLocation { @@ -11,26 +11,80 @@ export interface SceneLocation { orbitRadius: number | null; discovered: boolean; hasBay: boolean; + /** Idle ships here (the tutorial loaner included), shown as a badge. */ + idleShips: number; +} + +export interface SceneFlight { + dispatchId: string; + /** The ship's id, or "loaner" for a tutorial run. */ + shipId: string; + /** Origin at departure and destination at arrival: the route line. */ + from: MapPoint; + to: MapPoint; + /** Where the ship is now, on the route line. */ + marker: MapPoint; + progress: number; } export interface Scene { locations: SceneLocation[]; + flights: SceneFlight[]; +} + +function location(id: string) { + const found = content.location(id); + if (!found) throw new Error(`Unknown location ${id}`); + return found; +} + +/** + * A flight's route and current marker (Step 10.2). The marker moves along the + * straight route line by the sim's progress, in map space, so it always stays + * on the line however the map is scaled. + */ +export function flightMarker(dispatch: Dispatch, now: number): SceneFlight { + const from = locationMapPoint( + location(dispatch.originId), + realToGame(dispatch.departureAt, content.config), + ); + const to = locationMapPoint( + location(dispatch.destinationId), + realToGame(dispatch.arrivalAt, content.config), + ); + const progress = dispatchProgress(dispatch, now); + return { + dispatchId: dispatch.id, + shipId: dispatch.shipId, + from, + to, + marker: { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress }, + progress, + }; } /** Everything the map draws at real time `now`, in map coordinates. */ export function buildScene(state: PlayerState, now: number): Scene { const gameTime = realToGame(now, content.config); + const idleAt = (locationId: string) => + state.ships.filter((s) => s.status === "idle" && s.locationId === locationId).length + + (state.tutorial && !state.tutorial.runInFlight && state.tutorial.loanerLocationId === locationId + ? 1 + : 0); + return { - locations: content.locations.map((location) => { - const sun = bodyPosition(location.body, gameTime); + locations: content.locations.map((loc) => { + const sun = bodyPosition(loc.body, gameTime); return { - id: location.id, - name: location.name, - point: locationMapPoint(location, gameTime), - orbitRadius: location.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)), - discovered: state.locations.some((l) => l.id === location.id && l.discovered), - hasBay: state.bays.some((b) => b.locationId === location.id), + id: loc.id, + name: loc.name, + point: locationMapPoint(loc, gameTime), + orbitRadius: loc.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)), + discovered: state.locations.some((l) => l.id === loc.id && l.discovered), + hasBay: state.bays.some((b) => b.locationId === loc.id), + idleShips: idleAt(loc.id), }; }), + flights: state.activeDispatches.map((d) => flightMarker(d, now)), }; } diff --git a/apps/web/test/map-scene.test.ts b/apps/web/test/map-scene.test.ts new file mode 100644 index 0000000..239d0a2 --- /dev/null +++ b/apps/web/test/map-scene.test.ts @@ -0,0 +1,96 @@ +import type { Dispatch } from "@deadhead/schema"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_CAMERA, toScreen, zoomAt } from "../src/map/camera"; +import { buildScene, flightMarker } from "../src/map/scene"; +import { samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const flight: Dispatch = { + id: "d1", + shipId: "s1", + pilotId: "p1", + contract: null, + originId: "earth", + destinationId: "mars", + tutorial: false, + departureAt: NOW - 3_600_000, + arrivalAt: NOW + 3_600_000, + distanceMillionKm: 150, + fuelCost: 0, + expectedPayout: 0, + seed: 1, + status: "in-flight", + resolvedAt: null, +}; + +describe("ship markers", () => { + it("puts a ship at 50% progress on the route line between the endpoints, on screen", () => { + const viewport = { width: 375, height: 690 }; + const camera = zoomAt(DEFAULT_CAMERA, 2.5, { x: 100, y: 200 }, viewport); + const scene = flightMarker(flight, NOW); + expect(scene.progress).toBe(0.5); + + const a = toScreen(scene.from, viewport, camera); + const b = toScreen(scene.to, viewport, camera); + const m = toScreen(scene.marker, viewport, camera); + + // On the line: the cross product of (b - a) and (m - a) is zero. + const cross = (b.x - a.x) * (m.y - a.y) - (b.y - a.y) * (m.x - a.x); + expect(Math.abs(cross)).toBeLessThan(1e-6); + // Between the endpoints, halfway along. + expect(Math.hypot(m.x - a.x, m.y - a.y)).toBeCloseTo(Math.hypot(b.x - a.x, b.y - a.y) / 2, 6); + }); + + it("starts at the origin and ends at the destination", () => { + expect(flightMarker(flight, flight.departureAt).marker).toEqual(flightMarker(flight, NOW).from); + const end = flightMarker(flight, flight.arrivalAt + 60_000); + expect(end.marker.x).toBeCloseTo(end.to.x, 12); + expect(end.marker.y).toBeCloseTo(end.to.y, 12); + }); + + it("builds one flight per active dispatch and badges idle ships at their location", () => { + const state = samplePlayerState({ + tutorial: null, + activeDispatches: [flight], + ships: [ + { + id: "s1", + modelId: "courier", + name: "A", + status: "in-flight", + locationId: null, + dispatchId: "d1", + pilotId: "p1", + }, + { + id: "s2", + modelId: "courier", + name: "B", + status: "idle", + locationId: "moon", + dispatchId: null, + pilotId: null, + }, + { + id: "s3", + modelId: "courier", + name: "C", + status: "idle", + locationId: "moon", + dispatchId: null, + pilotId: null, + }, + ], + }); + const scene = buildScene(state, NOW); + expect(scene.flights.map((f) => f.dispatchId)).toEqual(["d1"]); + expect(scene.locations.find((l) => l.id === "moon")!.idleShips).toBe(2); + expect(scene.locations.find((l) => l.id === "earth")!.idleShips).toBe(0); + }); + + it("counts the idle tutorial loaner at its location", () => { + const scene = buildScene(samplePlayerState(), NOW); + expect(scene.locations.find((l) => l.id === "earth")!.idleShips).toBe(1); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 3baf09d..77748ff 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -99,5 +99,5 @@ ## Phase 10 — Simple System Map - [x] **Step 10.1 — PixiJS map component.** Map tab (`/map`, lazy-loaded) with a PixiJS v8 canvas (`src/map/MapCanvas.tsx`): Sun, orbit rings, five locations (dimmed if undiscovered, ring if a bay), drag to pan, pinch or wheel to zoom, "Reset view". Square-root radial scale and screen projection in `src/map/projection.ts`, camera in `src/map/camera.ts`. Tests: scale monotonic, locations fit a 375 px map for 12 years, zoom keeps the anchor fixed. Manual phone check pending. -- [ ] Step 10.2 — Ships on the map +- [x] **Step 10.2 — Ships on the map.** `flightMarker` / `buildScene` (`src/map/scene.ts`): each in-flight dispatch draws a route line (origin at departure → destination at arrival) with a marker placed by sim progress every frame; idle ships (tutorial loaner included) show as a count badge at their location. Test: marker at 50% lies halfway along the route line on screen. Manual smoothness check pending. - [ ] Step 10.3 — Map interactions