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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
|
|
@ -30,6 +32,7 @@ export function MapCanvas({ state }: { state: PlayerState }) {
|
|||
const app = new Application();
|
||||
const graphics = new Graphics();
|
||||
const labels = new Map<string, Text>();
|
||||
const badges = new Map<string, Text>();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
- [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
|
||||
|
|
|
|||
Loading…
Reference in New Issue