Compare commits
3 Commits
ff136377f4
...
a28fe5c5cb
| Author | SHA1 | Date |
|---|---|---|
|
|
a28fe5c5cb | |
|
|
ebbc97bea5 | |
|
|
29290de478 |
21
DECISIONS.md
21
DECISIONS.md
|
|
@ -453,3 +453,24 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Two levels of network error:** if the first player-state load fails there is nothing to show, so the app shows an error with Retry. If a later refresh fails, the last state stays on screen under an "Offline — showing last update" banner with Retry, which serves every screen (Phase 11 builds on this for offline mode). Contract boards have their own error and Retry.
|
||||
- **Market and Locations have no empty state:** they list static content, which always exists. Their error handling is the app-level one.
|
||||
- **The placeholder screen is gone;** every route has a real screen.
|
||||
|
||||
## Step 10.1
|
||||
|
||||
- **PixiJS v8 used directly** (an `Application` created in an effect), not `@pixi/react`: the map is one imperative canvas redrawn each frame from player state and the server clock, which needs no React reconciliation. The effect copes with StrictMode double-mounting.
|
||||
- **Radial scale is a square root of the distance from the Sun,** normalised so 1,000M km is the map edge. Order is preserved, and Jupiter (~780M km) sits about 2.3× Earth's map radius instead of 5×. Directions from the Sun are unchanged.
|
||||
- **The Moon is drawn a fixed small offset from Earth** in its true direction; at map scale it would sit on top of Earth.
|
||||
- **All map logic is plain, testable functions** (projection, camera, scene; hit-testing in Step 10.3). jsdom has no WebGL, so component tests replace `MapCanvas` with a test double (`test/setup.ts`).
|
||||
- **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.
|
||||
|
||||
## Step 10.3
|
||||
|
||||
- **Taps are detected in the canvas's own pointer handling:** one pointer, moved less than 8 px, then released. Hit-testing is a pure function over the same scene the canvas draws, with a 22 px reach (about a fingertip); ship markers win close calls because they are drawn on top. Tapping empty space closes the panel.
|
||||
- **Panels are React components over the canvas**, so they work like the rest of the UI. The location panel's board link uses `/contracts?bay=<id>`; the Contracts screen now preselects the bay from that parameter.
|
||||
- **Component tests "tap" through the canvas test double**, which renders one button per tappable item from the real `buildScene` and calls the same `onSelect`. Hit-testing itself is unit-tested with screen coordinates.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@deadhead/schema": "workspace:*",
|
||||
"@deadhead/sim": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"pixi.js": "^8.22.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^8.4.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, type ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, type ReactNode } from "react";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { useApi } from "./api/context";
|
||||
import { startClockTicker, syncClock } from "./clock/clock";
|
||||
|
|
@ -15,8 +15,16 @@ import { Pilots } from "./screens/Pilots";
|
|||
import { StartScreen } from "./screens/StartScreen";
|
||||
import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||
|
||||
// The map pulls in PixiJS; load it only when the Map tab is opened.
|
||||
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
|
||||
|
||||
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||
"/": <HQ />,
|
||||
"/map": (
|
||||
<Suspense fallback={<p className="p-4 text-slate-400">Loading map…</p>}>
|
||||
<MapScreen />
|
||||
</Suspense>
|
||||
),
|
||||
"/contracts": <Contracts />,
|
||||
"/fleet": <Fleet />,
|
||||
"/pilots": <Pilots />,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function Layout({ credits = "—", clock = <GameClock />, banner }: Layou
|
|||
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="fixed inset-x-0 bottom-0 grid grid-cols-6 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
|
||||
className="fixed inset-x-0 bottom-0 grid grid-cols-7 border-t border-slate-800 bg-slate-950 pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
{SCREENS.map((screen) => (
|
||||
<NavLink
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { Application, Graphics, Text } from "pixi.js";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { serverNow } from "../clock/clock";
|
||||
import { pan, toScreen, useCamera, zoomAt, type Viewport } from "./camera";
|
||||
import { hitTest, type MapSelection } from "./hitTest";
|
||||
import { buildScene } from "./scene";
|
||||
|
||||
const COLORS = {
|
||||
background: 0x020617,
|
||||
orbit: 0x1e293b,
|
||||
sun: 0xfbbf24,
|
||||
discovered: 0xe2e8f0,
|
||||
undiscovered: 0x475569,
|
||||
bay: 0x34d399,
|
||||
route: 0x38bdf8,
|
||||
ship: 0xfbbf24,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
onSelect,
|
||||
}: {
|
||||
state: PlayerState;
|
||||
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
|
||||
onSelect?: (selection: MapSelection | null) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const onSelectRef = useRef(onSelect);
|
||||
onSelectRef.current = onSelect;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
let cancelled = false;
|
||||
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,
|
||||
height: container.clientHeight,
|
||||
});
|
||||
|
||||
const draw = () => {
|
||||
const scene = buildScene(stateRef.current, serverNow());
|
||||
const camera = useCamera.getState();
|
||||
const view = viewport();
|
||||
const sun = toScreen({ x: 0, y: 0 }, view, camera);
|
||||
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
|
||||
|
||||
graphics.clear();
|
||||
for (const location of scene.locations) {
|
||||
if (location.orbitRadius !== null) {
|
||||
graphics
|
||||
.circle(sun.x, sun.y, location.orbitRadius * unit)
|
||||
.stroke({ width: 1, color: COLORS.orbit });
|
||||
}
|
||||
}
|
||||
graphics.circle(sun.x, sun.y, 7).fill(COLORS.sun);
|
||||
|
||||
for (const location of scene.locations) {
|
||||
const p = toScreen(location.point, view, camera);
|
||||
const color = location.discovered ? COLORS.discovered : COLORS.undiscovered;
|
||||
graphics.circle(p.x, p.y, 5).fill(color);
|
||||
if (location.hasBay) graphics.circle(p.x, p.y, 9).stroke({ width: 2, color: COLORS.bay });
|
||||
|
||||
let label = labels.get(location.id);
|
||||
if (!label) {
|
||||
label = new Text({ text: location.name, style: { fontSize: 12, fill: color } });
|
||||
labels.set(location.id, label);
|
||||
app.stage.addChild(label);
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
void app
|
||||
.init({
|
||||
resizeTo: container,
|
||||
background: COLORS.background,
|
||||
antialias: true,
|
||||
autoDensity: true,
|
||||
resolution: window.devicePixelRatio,
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
app.destroy(true);
|
||||
return;
|
||||
}
|
||||
container.appendChild(app.canvas);
|
||||
app.stage.addChild(graphics);
|
||||
app.ticker.add(draw);
|
||||
})
|
||||
.catch(() => {
|
||||
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
||||
});
|
||||
|
||||
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
|
||||
// pointer that barely moves is a tap, which selects what is under it.
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
|
||||
const local = (e: PointerEvent | WheelEvent) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
const onDown = (e: PointerEvent) => {
|
||||
container.setPointerCapture(e.pointerId);
|
||||
pointers.set(e.pointerId, local(e));
|
||||
tap = pointers.size === 1 ? { id: e.pointerId, start: local(e), moved: 0 } : null;
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const previous = pointers.get(e.pointerId);
|
||||
if (!previous) return;
|
||||
const current = local(e);
|
||||
if (tap?.id === e.pointerId) {
|
||||
tap.moved = Math.max(
|
||||
tap.moved,
|
||||
Math.hypot(current.x - tap.start.x, current.y - tap.start.y),
|
||||
);
|
||||
}
|
||||
if (pointers.size === 1) {
|
||||
useCamera.setState((c) => pan(c, current.x - previous.x, current.y - previous.y));
|
||||
} else if (pointers.size === 2) {
|
||||
const other = [...pointers.entries()].find(([id]) => id !== e.pointerId)![1];
|
||||
const before = Math.hypot(previous.x - other.x, previous.y - other.y);
|
||||
const after = Math.hypot(current.x - other.x, current.y - other.y);
|
||||
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
|
||||
if (before > 0) useCamera.setState((c) => zoomAt(c, after / before, mid, viewport()));
|
||||
}
|
||||
pointers.set(e.pointerId, current);
|
||||
};
|
||||
const onUp = (e: PointerEvent) => {
|
||||
pointers.delete(e.pointerId);
|
||||
if (e.type === "pointerup" && tap?.id === e.pointerId && tap.moved < 8) {
|
||||
const scene = buildScene(stateRef.current, serverNow());
|
||||
onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState()));
|
||||
}
|
||||
if (tap?.id === e.pointerId) tap = null;
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));
|
||||
};
|
||||
container.addEventListener("pointerdown", onDown);
|
||||
container.addEventListener("pointermove", onMove);
|
||||
container.addEventListener("pointerup", onUp);
|
||||
container.addEventListener("pointercancel", onUp);
|
||||
container.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
container.removeEventListener("pointerdown", onDown);
|
||||
container.removeEventListener("pointermove", onMove);
|
||||
container.removeEventListener("pointerup", onUp);
|
||||
container.removeEventListener("pointercancel", onUp);
|
||||
container.removeEventListener("wheel", onWheel);
|
||||
if (app.renderer) app.destroy(true, { children: true });
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="map-canvas"
|
||||
className="relative h-full w-full touch-none overflow-hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { create } from "zustand";
|
||||
import type { MapPoint } from "./projection";
|
||||
|
||||
/** Pan (pixels) and zoom (1 = whole system fits) of the map view. */
|
||||
export interface Camera {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const MIN_ZOOM = 0.5;
|
||||
export const MAX_ZOOM = 40;
|
||||
/** Leave a little room around the map edge at zoom 1. */
|
||||
const FIT = 0.92;
|
||||
|
||||
export const DEFAULT_CAMERA: Camera = { zoom: 1, panX: 0, panY: 0 };
|
||||
|
||||
export const useCamera = create<Camera>(() => DEFAULT_CAMERA);
|
||||
|
||||
/** Pixels per map unit at this zoom. */
|
||||
function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
|
||||
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;
|
||||
}
|
||||
|
||||
export function toScreen(point: MapPoint, viewport: Viewport, camera: Camera): MapPoint {
|
||||
const k = pixelsPerUnit(viewport, camera);
|
||||
return {
|
||||
x: viewport.width / 2 + camera.panX + point.x * k,
|
||||
y: viewport.height / 2 + camera.panY + point.y * k,
|
||||
};
|
||||
}
|
||||
|
||||
export function pan(camera: Camera, dx: number, dy: number): Camera {
|
||||
return { ...camera, panX: camera.panX + dx, panY: camera.panY + dy };
|
||||
}
|
||||
|
||||
/** Zooms by `factor`, keeping the map point under `anchor` (screen pixels) in place. */
|
||||
export function zoomAt(
|
||||
camera: Camera,
|
||||
factor: number,
|
||||
anchor: MapPoint,
|
||||
viewport: Viewport,
|
||||
): Camera {
|
||||
const zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.zoom * factor));
|
||||
const ratio = zoom / camera.zoom;
|
||||
const cx = viewport.width / 2;
|
||||
const cy = viewport.height / 2;
|
||||
return {
|
||||
zoom,
|
||||
panX: anchor.x - cx - (anchor.x - cx - camera.panX) * ratio,
|
||||
panY: anchor.y - cy - (anchor.y - cy - camera.panY) * ratio,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { toScreen, type Camera, type Viewport } from "./camera";
|
||||
import type { MapPoint } from "./projection";
|
||||
import type { Scene } from "./scene";
|
||||
|
||||
export type MapSelection =
|
||||
{ kind: "location"; id: string } | { kind: "flight"; dispatchId: string };
|
||||
|
||||
/** How close (screen pixels) a tap must be to a location or ship: about a fingertip. */
|
||||
export const TAP_RADIUS = 22;
|
||||
|
||||
/**
|
||||
* What a tap at `tap` (screen pixels) selects: the nearest ship marker or
|
||||
* location within reach. Ships win ties, since they are drawn on top.
|
||||
*/
|
||||
export function hitTest(
|
||||
scene: Scene,
|
||||
tap: MapPoint,
|
||||
viewport: Viewport,
|
||||
camera: Camera,
|
||||
): MapSelection | null {
|
||||
let best: { selection: MapSelection; distance: number } | null = null;
|
||||
const consider = (point: MapPoint, selection: MapSelection, bias: number) => {
|
||||
const p = toScreen(point, viewport, camera);
|
||||
const distance = Math.hypot(p.x - tap.x, p.y - tap.y) - bias;
|
||||
if (distance <= TAP_RADIUS && (!best || distance < best.distance)) {
|
||||
best = { selection, distance };
|
||||
}
|
||||
};
|
||||
for (const location of scene.locations) {
|
||||
consider(location.point, { kind: "location", id: location.id }, 0);
|
||||
}
|
||||
for (const flight of scene.flights) {
|
||||
consider(flight.marker, { kind: "flight", dispatchId: flight.dispatchId }, 4);
|
||||
}
|
||||
return (best as { selection: MapSelection } | null)?.selection ?? null;
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { Location } from "@deadhead/content";
|
||||
import { bodyPosition, type GameTime, type Position } from "@deadhead/sim";
|
||||
|
||||
/**
|
||||
* Map coordinates: the Sun at (0, 0), the edge of the map at radius 1, y down.
|
||||
* Real distances are compressed so the inner planets and Jupiter fit together.
|
||||
*/
|
||||
export interface MapPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Heliocentric radius (million km) shown at the edge of the map. */
|
||||
export const MAP_EDGE_MILLION_KM = 1000;
|
||||
|
||||
/** Moons are drawn this far from their planet (map units), whatever the real distance. */
|
||||
export const MOON_MAP_OFFSET = 0.04;
|
||||
|
||||
/**
|
||||
* Compressed radial scale: real distance from the Sun (million km) to map
|
||||
* radius. Square root keeps the order (farther stays farther) while bringing
|
||||
* Jupiter (~780M km) within a few times Earth's (~150M km) distance.
|
||||
*/
|
||||
export function radialScale(millionKm: number): number {
|
||||
return Math.sqrt(Math.max(0, millionKm) / MAP_EDGE_MILLION_KM);
|
||||
}
|
||||
|
||||
/** A heliocentric position on the map: same angle, compressed radius. */
|
||||
export function toMapPoint(position: Position): MapPoint {
|
||||
const r = Math.hypot(position.x, position.y);
|
||||
if (r === 0) return { x: 0, y: 0 };
|
||||
const k = radialScale(r) / r;
|
||||
// Screens grow downwards; flip y so orbits run counter-clockwise as seen from above.
|
||||
return { x: position.x * k, y: -position.y * k };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a location is drawn. The Moon sits a fixed small distance from Earth
|
||||
* in its true direction, since at map scale it would overlap Earth.
|
||||
*/
|
||||
export function locationMapPoint(location: Location, gameTime: GameTime): MapPoint {
|
||||
if (location.body !== "moon") return toMapPoint(bodyPosition(location.body, gameTime));
|
||||
const earth = bodyPosition("earth", gameTime);
|
||||
const moon = bodyPosition("moon", gameTime);
|
||||
const dx = moon.x - earth.x;
|
||||
const dy = -(moon.y - earth.y);
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const base = toMapPoint(earth);
|
||||
return {
|
||||
x: base.x + (dx / length) * MOON_MAP_OFFSET,
|
||||
y: base.y + (dy / length) * MOON_MAP_OFFSET,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
||||
import { bodyPosition, dispatchProgress, realToGame } from "@deadhead/sim";
|
||||
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
|
||||
|
||||
export interface SceneLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
point: MapPoint;
|
||||
/** Map radius of its orbit around the Sun (the faint ring); null for moons. */
|
||||
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((loc) => {
|
||||
const sun = bodyPosition(loc.body, gameTime);
|
||||
return {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
/** The app's top-level screens, in bottom-navigation order. */
|
||||
/** The app's top-level screens, in bottom-navigation order. Labels are short to fit seven tabs. */
|
||||
export const SCREENS = [
|
||||
{ path: "/", title: "HQ", label: "HQ" },
|
||||
{ path: "/contracts", title: "Contracts", label: "Contracts" },
|
||||
{ path: "/map", title: "Map", label: "Map" },
|
||||
{ path: "/contracts", title: "Contracts", label: "Jobs" },
|
||||
{ path: "/fleet", title: "Fleet", label: "Fleet" },
|
||||
{ path: "/pilots", title: "Pilots", label: "Pilots" },
|
||||
{ path: "/market", title: "Market", label: "Market" },
|
||||
{ path: "/market", title: "Market", label: "Shop" },
|
||||
{ path: "/locations", title: "Locations", label: "Places" },
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Contract } from "@deadhead/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useApi } from "../api/context";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
|
|
@ -24,7 +25,9 @@ export function requirementsText(contract: Contract): string | null {
|
|||
/** Contract boards for the player's bays (Step 9.4). */
|
||||
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const [selected, setSelected] = useState(state.bays[0]?.locationId);
|
||||
// `?bay=moon` (e.g. from the map) preselects that bay.
|
||||
const [params] = useSearchParams();
|
||||
const [selected, setSelected] = useState(params.get("bay") ?? state.bays[0]?.locationId);
|
||||
const bayId = state.bays.some((b) => b.locationId === selected)
|
||||
? selected
|
||||
: state.bays[0]?.locationId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import { content, type Location } from "@deadhead/content";
|
||||
import { realToGame } from "@deadhead/sim";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { cargoName, formatMillionKm, locationName } from "../format";
|
||||
import { distanceAt } from "../game";
|
||||
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
||||
import type { MapSelection } from "../map/hitTest";
|
||||
import { MapCanvas } from "../map/MapCanvas";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
|
||||
const TYPE_LABELS: Record<Location["type"], string> = {
|
||||
"atmospheric-world": "Atmospheric world",
|
||||
"airless-body": "Airless body",
|
||||
"cloud-platform": "Cloud platform",
|
||||
"belt-station": "Belt station",
|
||||
};
|
||||
|
||||
/** The solar system map (Phase 10). Tap a location or ship for details. */
|
||||
export function MapScreen() {
|
||||
const state = useLoadedPlayer();
|
||||
const [selection, setSelection] = useState<MapSelection | null>(null);
|
||||
|
||||
return (
|
||||
<section className="relative h-[calc(100dvh-7.5rem)]">
|
||||
<h1 className="sr-only">Map</h1>
|
||||
<MapCanvas state={state} onSelect={setSelection} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
||||
className="absolute right-3 top-3 rounded-md bg-slate-900/80 px-2 py-1 text-xs text-slate-300"
|
||||
>
|
||||
Reset view
|
||||
</button>
|
||||
{selection?.kind === "location" && (
|
||||
<LocationPanel locationId={selection.id} onClose={() => setSelection(null)} />
|
||||
)}
|
||||
{selection?.kind === "flight" && (
|
||||
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
className="absolute inset-x-2 bottom-2 rounded-xl border border-slate-700 bg-slate-900/95 p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Close" className="px-2 text-slate-400">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tapped location: details if discovered, and a link to its board if there is a bay. */
|
||||
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const location = content.location(locationId)!;
|
||||
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
|
||||
const hasBay = state.bays.some((b) => b.locationId === locationId);
|
||||
|
||||
return (
|
||||
<Panel title={location.name} onClose={onClose}>
|
||||
{discovered ? (
|
||||
<>
|
||||
<p className="text-slate-300">
|
||||
{TYPE_LABELS[location.type]} ·{" "}
|
||||
{location.id === "earth"
|
||||
? "Home"
|
||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||
</p>
|
||||
<p className="text-slate-400">
|
||||
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
||||
{location.demands.map(cargoName).join(", ") || "nothing"}
|
||||
</p>
|
||||
{hasBay ? (
|
||||
<Link
|
||||
to={`/contracts?bay=${location.id}`}
|
||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||
>
|
||||
View contract board
|
||||
</Link>
|
||||
) : (
|
||||
<p className="mt-2 text-slate-400">No bay here yet.</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tapped ship in flight: its route, pilot and ETA. */
|
||||
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
|
||||
if (!dispatch) return null;
|
||||
const shipName = dispatch.tutorial
|
||||
? "Company loaner"
|
||||
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
|
||||
const pilotName = state.pilots.find((p) => p.id === dispatch.pilotId)?.name ?? "Unknown pilot";
|
||||
|
||||
return (
|
||||
<Panel title={shipName} onClose={onClose}>
|
||||
<p className="text-slate-300">
|
||||
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
|
||||
</p>
|
||||
<p className="text-slate-400">Pilot: {pilotName}</p>
|
||||
<p className="text-slate-400">
|
||||
Arrives in <Countdown to={dispatch.arrivalAt} /> (
|
||||
{formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
||||
</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { PlayerState } from "@deadhead/schema";
|
||||
import type { MapSelection } from "../src/map/hitTest";
|
||||
import { buildScene } from "../src/map/scene";
|
||||
|
||||
/**
|
||||
* Test stand-in for the PixiJS map canvas: one button per tappable thing in
|
||||
* the real scene, calling `onSelect` with the same selection a tap would.
|
||||
*/
|
||||
export function MapCanvas({
|
||||
state,
|
||||
onSelect,
|
||||
}: {
|
||||
state: PlayerState;
|
||||
onSelect?: (selection: MapSelection | null) => void;
|
||||
}) {
|
||||
const scene = buildScene(state, Date.now());
|
||||
return (
|
||||
<div data-testid="map-canvas">
|
||||
{scene.locations.map((l) => (
|
||||
<button key={l.id} type="button" onClick={() => onSelect?.({ kind: "location", id: l.id })}>
|
||||
Tap {l.name}
|
||||
</button>
|
||||
))}
|
||||
{scene.flights.map((f) => (
|
||||
<button
|
||||
key={f.dispatchId}
|
||||
type="button"
|
||||
onClick={() => onSelect?.({ kind: "flight", dispatchId: f.dispatchId })}
|
||||
>
|
||||
Tap ship {f.shipId}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => onSelect?.(null)}>
|
||||
Tap empty space
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { DEFAULT_CAMERA, toScreen } from "../src/map/camera";
|
||||
import { hitTest } from "../src/map/hitTest";
|
||||
import { buildScene } from "../src/map/scene";
|
||||
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const flight: Dispatch = {
|
||||
id: "d1",
|
||||
shipId: "s1",
|
||||
pilotId: "p-rae",
|
||||
contract: null,
|
||||
originId: "earth",
|
||||
destinationId: "mars",
|
||||
tutorial: false,
|
||||
departureAt: NOW - 3_600_000,
|
||||
arrivalAt: NOW + 5 * 60_000,
|
||||
distanceMillionKm: 100,
|
||||
fuelCost: 0,
|
||||
expectedPayout: 0,
|
||||
seed: 1,
|
||||
status: "in-flight",
|
||||
resolvedAt: null,
|
||||
};
|
||||
|
||||
function mapState(): PlayerState {
|
||||
const base = samplePlayerState({ tutorial: null });
|
||||
return {
|
||||
...base,
|
||||
bays: [{ id: "b1", locationId: "moon" }],
|
||||
ships: [
|
||||
{
|
||||
id: "s1",
|
||||
modelId: "freighter",
|
||||
name: "Slow Ox",
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
dispatchId: "d1",
|
||||
pilotId: "p-rae",
|
||||
},
|
||||
],
|
||||
pilots: [
|
||||
...base.pilots,
|
||||
{
|
||||
...base.pilots[0]!,
|
||||
id: "p-rae",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
assignedShipId: "s1",
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
},
|
||||
],
|
||||
activeDispatches: [flight],
|
||||
};
|
||||
}
|
||||
|
||||
describe("map hit-testing", () => {
|
||||
const viewport = { width: 375, height: 690 };
|
||||
|
||||
it("selects the location under a tap, and nothing on empty space", () => {
|
||||
const scene = buildScene(mapState(), NOW);
|
||||
// Ceres: nothing else is near it (the test flight is almost at Mars).
|
||||
const ceres = scene.locations.find((l) => l.id === "ceres-station")!;
|
||||
const p = toScreen(ceres.point, viewport, DEFAULT_CAMERA);
|
||||
expect(hitTest(scene, { x: p.x + 5, y: p.y - 5 }, viewport, DEFAULT_CAMERA)).toEqual({
|
||||
kind: "location",
|
||||
id: "ceres-station",
|
||||
});
|
||||
expect(hitTest(scene, { x: 2, y: 2 }, viewport, DEFAULT_CAMERA)).toBeNull();
|
||||
});
|
||||
|
||||
it("selects a ship marker under a tap", () => {
|
||||
const scene = buildScene(mapState(), NOW);
|
||||
const p = toScreen(scene.flights[0]!.marker, viewport, DEFAULT_CAMERA);
|
||||
expect(hitTest(scene, p, viewport, DEFAULT_CAMERA)).toEqual({
|
||||
kind: "flight",
|
||||
dispatchId: "d1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("map panels", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const openMap = async () => {
|
||||
renderApp(fakeApi({ getPlayerState: mapState }), "/map");
|
||||
return screen.findByTestId("map-canvas");
|
||||
};
|
||||
|
||||
it("tapping a location with a bay shows its details and a link to its contract board", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||
const panel = screen.getByRole("dialog", { name: "Moon" });
|
||||
expect(panel).toHaveTextContent("Airless body");
|
||||
expect(panel).toHaveTextContent(/from Earth/);
|
||||
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
|
||||
"href",
|
||||
"/contracts?bay=moon",
|
||||
);
|
||||
});
|
||||
|
||||
it("tapping a discovered location without a bay shows no board link", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
|
||||
const panel = screen.getByRole("dialog", { name: "Earth" });
|
||||
expect(panel).toHaveTextContent("No bay here yet.");
|
||||
expect(within(panel).queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tapping an undiscovered location hides its details", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars" }));
|
||||
expect(screen.getByRole("dialog", { name: "Mars" })).toHaveTextContent(
|
||||
"Undiscovered — deliver here to unlock.",
|
||||
);
|
||||
});
|
||||
|
||||
it("tapping a ship shows its route, pilot and ETA", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap ship s1" }));
|
||||
const panel = screen.getByRole("dialog", { name: "Slow Ox" });
|
||||
expect(panel).toHaveTextContent("Earth → Mars");
|
||||
expect(panel).toHaveTextContent("Pilot: Rae Okafor");
|
||||
expect(panel).toHaveTextContent("Arrives in 5m 00s");
|
||||
});
|
||||
|
||||
it("tapping empty space or Close dismisses the panel", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap empty space" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("the board link opens Contracts with that bay selected", async () => {
|
||||
const canvas = await openMap();
|
||||
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||
await userEvent.click(screen.getByRole("link", { name: "View contract board" }));
|
||||
expect(await screen.findByLabelText("Bay")).toHaveValue("moon");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CAMERA, pan, toScreen, zoomAt } from "../src/map/camera";
|
||||
import { locationMapPoint, radialScale, toMapPoint } from "../src/map/projection";
|
||||
|
||||
describe("radial scale", () => {
|
||||
it("is monotonic: farther in reality is farther on screen", () => {
|
||||
let previous = radialScale(0);
|
||||
for (let km = 0.5; km <= 1200; km += 0.5) {
|
||||
const next = radialScale(km);
|
||||
expect(next).toBeGreaterThan(previous);
|
||||
previous = next;
|
||||
}
|
||||
});
|
||||
|
||||
it("is compressed: Jupiter is much less than 5× Earth's distance on screen", () => {
|
||||
expect(radialScale(780) / radialScale(150)).toBeLessThan(2.5);
|
||||
expect(radialScale(1000)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a position's direction from the Sun", () => {
|
||||
const p = toMapPoint({ x: 300, y: 400 });
|
||||
expect(Math.atan2(-p.y, p.x)).toBeCloseTo(Math.atan2(400, 300), 10);
|
||||
expect(Math.hypot(p.x, p.y)).toBeCloseTo(radialScale(500), 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("map fits a phone", () => {
|
||||
// A 375 px wide phone, minus header and tab bar.
|
||||
const viewport = { width: 375, height: 690 };
|
||||
const start = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
it("shows all five locations on screen at default zoom, across 12 years", () => {
|
||||
for (let month = 0; month < 12 * 12; month++) {
|
||||
const gameTime = start + month * 30 * 86_400_000;
|
||||
for (const location of content.locations) {
|
||||
const p = toScreen(locationMapPoint(location, gameTime), viewport, DEFAULT_CAMERA);
|
||||
expect(p.x).toBeGreaterThanOrEqual(0);
|
||||
expect(p.x).toBeLessThanOrEqual(viewport.width);
|
||||
expect(p.y).toBeGreaterThanOrEqual(0);
|
||||
expect(p.y).toBeLessThanOrEqual(viewport.height);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("draws the Moon apart from Earth", () => {
|
||||
const earth = locationMapPoint(content.location("earth")!, start);
|
||||
const moon = locationMapPoint(content.location("moon")!, start);
|
||||
const a = toScreen(earth, viewport, DEFAULT_CAMERA);
|
||||
const b = toScreen(moon, viewport, DEFAULT_CAMERA);
|
||||
expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("camera", () => {
|
||||
const viewport = { width: 400, height: 600 };
|
||||
|
||||
it("keeps the map point under the fingers fixed while zooming", () => {
|
||||
const anchor = { x: 300, y: 150 };
|
||||
const camera = pan(DEFAULT_CAMERA, 20, -40);
|
||||
const point = { x: 0.37, y: -0.21 };
|
||||
const before = toScreen(point, viewport, camera);
|
||||
const zoomed = zoomAt(camera, 3, anchor, viewport);
|
||||
const after = toScreen(point, viewport, zoomed);
|
||||
// Distance from the anchor scales by the zoom factor; the anchor itself stays put.
|
||||
expect(after.x - anchor.x).toBeCloseTo((before.x - anchor.x) * 3, 6);
|
||||
expect(after.y - anchor.y).toBeCloseTo((before.y - anchor.y) * 3, 6);
|
||||
});
|
||||
|
||||
it("clamps zoom", () => {
|
||||
expect(zoomAt(DEFAULT_CAMERA, 1000, { x: 0, y: 0 }, viewport).zoom).toBe(40);
|
||||
expect(zoomAt(DEFAULT_CAMERA, 0.0001, { x: 0, y: 0 }, viewport).zoom).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
import { afterEach, vi } from "vitest";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
// PixiJS needs WebGL, which jsdom lacks. Component tests use a stand-in canvas;
|
||||
// the map's logic (projection, camera, scene, hit-testing) is unit-tested directly.
|
||||
vi.mock("../src/map/MapCanvas", () => import("./map-canvas-double"));
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
|||
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
|
||||
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
|
||||
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
|
||||
|
||||
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.
|
||||
|
|
|
|||
|
|
@ -95,3 +95,9 @@
|
|||
- [x] **Step 9.8 — Market screen.** `src/screens/Market.tsx`: ship models with speed, capacity, range, landing class and price; Buy disabled when unaffordable; a bought ship shows a confirmation and appears in Fleet after the state refresh.
|
||||
- [x] **Step 9.9 — Locations screen.** `src/screens/Locations.tsx`: discovered locations show type, landing need, current distance from Earth, produces/demands and "Lease bay (price)" or "Bay owned"; undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
|
||||
- [x] **Step 9.10 — Empty and error states.** First-load failure shows "Could not reach the server" with Retry; a failed refresh keeps the last state with an "Offline — showing last update" banner and Retry; boards have their own Retry. Empty states on HQ, Fleet, Contracts (no bays, empty bay/tutorial board) and Pilots. Tests cover every screen.
|
||||
|
||||
## 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.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.
|
||||
- [x] **Step 10.3 — Map interactions.** Taps (single pointer, under 8 px of movement) are hit-tested (`src/map/hitTest.ts`, 22 px reach, ships win ties). A location panel shows details (or "Undiscovered") and, with a bay, a link to `/contracts?bay=…` which preselects that board; a ship panel shows route, pilot and ETA. Component tests tap through a canvas test double that uses the real scene.
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ importers:
|
|||
'@tanstack/react-query':
|
||||
specifier: ^5.104.1
|
||||
version: 5.104.1(react@19.3.0)
|
||||
pixi.js:
|
||||
specifier: ^8.22.0
|
||||
version: 8.22.0
|
||||
react:
|
||||
specifier: ^19.3.0
|
||||
version: 19.3.0
|
||||
|
|
@ -919,6 +922,9 @@ packages:
|
|||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@pixi/colord@2.9.6':
|
||||
resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==}
|
||||
|
||||
'@remix-run/route-pattern@0.22.1':
|
||||
resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==}
|
||||
|
||||
|
|
@ -1170,6 +1176,9 @@ packages:
|
|||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/earcut@3.0.0':
|
||||
resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==}
|
||||
|
||||
'@types/esrecurse@4.3.1':
|
||||
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
|
||||
|
||||
|
|
@ -1299,6 +1308,13 @@ packages:
|
|||
'@vitest/spy@5.0.3':
|
||||
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
|
||||
|
||||
'@webgpu/types@0.1.74':
|
||||
resolution: {integrity: sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==}
|
||||
|
||||
'@xmldom/xmldom@0.8.15':
|
||||
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
|
|
@ -1606,6 +1622,9 @@ packages:
|
|||
sqlite3:
|
||||
optional: true
|
||||
|
||||
earcut@3.2.4:
|
||||
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
|
||||
|
||||
enhanced-resolve@5.26.0:
|
||||
resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
|
@ -1687,6 +1706,9 @@ packages:
|
|||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
|
@ -1755,6 +1777,9 @@ packages:
|
|||
get-tsconfig@4.14.3:
|
||||
resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
|
||||
|
||||
gifuct-js@2.1.2:
|
||||
resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
|
@ -1814,6 +1839,9 @@ packages:
|
|||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
ismobilejs@1.1.1:
|
||||
resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
|
@ -1821,6 +1849,9 @@ packages:
|
|||
jose@6.2.12:
|
||||
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
|
||||
|
||||
js-binary-schema-parser@2.0.3:
|
||||
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==}
|
||||
|
||||
js-tokens@10.0.0:
|
||||
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||
|
||||
|
|
@ -2085,6 +2116,9 @@ packages:
|
|||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
parse-svg-path@0.2.0:
|
||||
resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==}
|
||||
|
||||
parse5@8.0.1:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
|
||||
|
|
@ -2154,6 +2188,9 @@ packages:
|
|||
resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==}
|
||||
hasBin: true
|
||||
|
||||
pixi.js@8.22.0:
|
||||
resolution: {integrity: sha512-QbTANHMZ751MIjLiLSUSM8a7LNMWwMIf0KbRCqlW+SPS9hNLfSVS+cl1O2SEp9g1K/wyLzj/OJHv+1TUfiPr/w==}
|
||||
|
||||
postcss@8.5.28:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
|
@ -2346,6 +2383,10 @@ packages:
|
|||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tiny-lru@11.4.7:
|
||||
resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tinybench@6.2.0:
|
||||
resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
|
@ -3067,6 +3108,8 @@ snapshots:
|
|||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@pixi/colord@2.9.6': {}
|
||||
|
||||
'@remix-run/route-pattern@0.22.1': {}
|
||||
|
||||
'@rolldown/binding-android-arm-eabi@1.2.12':
|
||||
|
|
@ -3239,6 +3282,8 @@ snapshots:
|
|||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/earcut@3.0.0': {}
|
||||
|
||||
'@types/esrecurse@4.3.1': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
|
@ -3388,6 +3433,10 @@ snapshots:
|
|||
|
||||
'@vitest/spy@5.0.3': {}
|
||||
|
||||
'@webgpu/types@0.1.74': {}
|
||||
|
||||
'@xmldom/xmldom@0.8.15': {}
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.18.0):
|
||||
|
|
@ -3563,6 +3612,8 @@ snapshots:
|
|||
kysely: 0.29.6
|
||||
pg: 8.23.1
|
||||
|
||||
earcut@3.2.4: {}
|
||||
|
||||
enhanced-resolve@5.26.0:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
|
@ -3731,6 +3782,8 @@ snapshots:
|
|||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
|
@ -3814,6 +3867,10 @@ snapshots:
|
|||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
gifuct-js@2.1.2:
|
||||
dependencies:
|
||||
js-binary-schema-parser: 2.0.3
|
||||
|
||||
glob-parent@6.0.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
|
@ -3856,10 +3913,14 @@ snapshots:
|
|||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
ismobilejs@1.1.1: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.12: {}
|
||||
|
||||
js-binary-schema-parser@2.0.3: {}
|
||||
|
||||
js-tokens@10.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
|
@ -4077,6 +4138,8 @@ snapshots:
|
|||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
parse-svg-path@0.2.0: {}
|
||||
|
||||
parse5@8.0.1:
|
||||
dependencies:
|
||||
entities: 8.1.0
|
||||
|
|
@ -4154,6 +4217,19 @@ snapshots:
|
|||
sonic-boom: 4.2.1
|
||||
thread-stream: 4.2.0
|
||||
|
||||
pixi.js@8.22.0:
|
||||
dependencies:
|
||||
'@pixi/colord': 2.9.6
|
||||
'@types/earcut': 3.0.0
|
||||
'@webgpu/types': 0.1.74
|
||||
'@xmldom/xmldom': 0.8.15
|
||||
earcut: 3.2.4
|
||||
eventemitter3: 5.0.4
|
||||
gifuct-js: 2.1.2
|
||||
ismobilejs: 1.1.1
|
||||
parse-svg-path: 0.2.0
|
||||
tiny-lru: 11.4.7
|
||||
|
||||
postcss@8.5.28:
|
||||
dependencies:
|
||||
nanoid: 3.3.19
|
||||
|
|
@ -4317,6 +4393,8 @@ snapshots:
|
|||
dependencies:
|
||||
real-require: 1.0.0
|
||||
|
||||
tiny-lru@11.4.7: {}
|
||||
|
||||
tinybench@6.2.0: {}
|
||||
|
||||
tinyexec@1.3.1: {}
|
||||
|
|
|
|||
Loading…
Reference in New Issue