Step 10.2: draw ships on the map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 07:08:14 -04:00
parent 29290de478
commit ebbc97bea5
5 changed files with 198 additions and 13 deletions

View File

@ -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. - **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. - **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. - **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.

View File

@ -12,11 +12,13 @@ const COLORS = {
discovered: 0xe2e8f0, discovered: 0xe2e8f0,
undiscovered: 0x475569, undiscovered: 0x475569,
bay: 0x34d399, bay: 0x34d399,
route: 0x38bdf8,
ship: 0xfbbf24,
}; };
/** /**
* The system map canvas (Step 10.1). Draws every frame from `state` and the * The system map canvas (Steps 10.1–10.2). Draws locations and ships every
* server clock; drag to pan, pinch or wheel to zoom. * frame from `state` and the server clock; drag to pan, pinch or wheel to zoom.
*/ */
export function MapCanvas({ state }: { state: PlayerState }) { export function MapCanvas({ state }: { state: PlayerState }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -30,6 +32,7 @@ export function MapCanvas({ state }: { state: PlayerState }) {
const app = new Application(); const app = new Application();
const graphics = new Graphics(); const graphics = new Graphics();
const labels = new Map<string, Text>(); const labels = new Map<string, Text>();
const badges = new Map<string, Text>();
const viewport = (): Viewport => ({ const viewport = (): Viewport => ({
width: container.clientWidth, width: container.clientWidth,
@ -68,6 +71,33 @@ export function MapCanvas({ state }: { state: PlayerState }) {
label.text = location.discovered ? location.name : `${location.name}?`; label.text = location.discovered ? location.name : `${location.name}?`;
label.style.fill = color; label.style.fill = color;
label.position.set(p.x + 8, p.y - 7); 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);
} }
}; };

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { PlayerState } from "@deadhead/schema"; import type { Dispatch, PlayerState } from "@deadhead/schema";
import { bodyPosition, realToGame } from "@deadhead/sim"; import { bodyPosition, dispatchProgress, realToGame } from "@deadhead/sim";
import { locationMapPoint, radialScale, type MapPoint } from "./projection"; import { locationMapPoint, radialScale, type MapPoint } from "./projection";
export interface SceneLocation { export interface SceneLocation {
@ -11,26 +11,80 @@ export interface SceneLocation {
orbitRadius: number | null; orbitRadius: number | null;
discovered: boolean; discovered: boolean;
hasBay: 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 { export interface Scene {
locations: SceneLocation[]; 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. */ /** Everything the map draws at real time `now`, in map coordinates. */
export function buildScene(state: PlayerState, now: number): Scene { export function buildScene(state: PlayerState, now: number): Scene {
const gameTime = realToGame(now, content.config); 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 { return {
locations: content.locations.map((location) => { locations: content.locations.map((loc) => {
const sun = bodyPosition(location.body, gameTime); const sun = bodyPosition(loc.body, gameTime);
return { return {
id: location.id, id: loc.id,
name: location.name, name: loc.name,
point: locationMapPoint(location, gameTime), point: locationMapPoint(loc, gameTime),
orbitRadius: location.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)), orbitRadius: loc.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)),
discovered: state.locations.some((l) => l.id === location.id && l.discovered), discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
hasBay: state.bays.some((b) => b.locationId === location.id), hasBay: state.bays.some((b) => b.locationId === loc.id),
idleShips: idleAt(loc.id),
}; };
}), }),
flights: state.activeDispatches.map((d) => flightMarker(d, now)),
}; };
} }

View File

@ -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);
});
});

View File

@ -99,5 +99,5 @@
## Phase 10 — Simple System Map ## 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. - [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 - [ ] Step 10.3 — Map interactions