Step 10.2: draw ships on the map
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
29290de478
commit
ebbc97bea5
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue