Compare commits
No commits in common. "99aebe1dcb97926ddf2af5fd0acb642995ea8af6" and "f239f452e7575a1c8200d70303a062de0d3bcf2f" have entirely different histories.
99aebe1dcb
...
f239f452e7
59
DECISIONS.md
|
|
@ -695,62 +695,3 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **The committed-output test compares decoded pixels, not bytes**, because PNG compression can differ between Windows and Linux (CI). The "twice is identical" test compares bytes within one machine. The source-transparency test reads all four corners of each source sprite.
|
||||
- **Sizes:** the 2x body sprites are about 0.5–0.6 MB each (lossless PNG of detailed art). That is acceptable for now. If the PWA precache (Step 11.1) is too heavy, palette quantization is the lever.
|
||||
- **sharp's install script is not approved** in pnpm; its prebuilt platform binaries load without it.
|
||||
|
||||
## Step 10C.3
|
||||
|
||||
- **Angles are degrees clockwise from screen right** (screen y grows down), so −45° is up and to the right. The Moon sits 30 px up-right of Earth; the idle-ship badge sits 20 px down-left (135°) of a body's center; Jupiter's platform badge sits on its edge at −45°.
|
||||
- **Child offsets are keyed by location id with a `parentId`**, so a future moon only needs a config entry (decision 0.5.39).
|
||||
- **The background follows map zoom by 5%** (`backgroundZoomFollow`), the "zooms only slightly" option in Step 10C.5.
|
||||
- **Placeholder colors are in the style too**, so the fallback (decision 0.5.41) draws with the same palette as before.
|
||||
|
||||
## Step 10C.4
|
||||
|
||||
- **One bundle, loaded asset by asset:** the bundle is registered with `Assets.addBundle` once per page, then each alias is loaded with `Assets.load` under `Promise.allSettled`. `Assets.loadBundle` would reject on the first failure and lose every texture.
|
||||
- **Warnings are only for load failures.** An asset with no source file (`badge-cloud-platform`) is reported by the art script, not at runtime, so a normal page load logs nothing and the "exactly one warning" test holds.
|
||||
- **Textures are cached by PixiJS across visits to the Map**, so leaving and returning does not reload or recreate them (Step 10C.9 relies on this).
|
||||
- **Drawing moved to `MapRenderer`**, which keeps one Sprite or Text per item and redraws only Graphics layers each frame. Sprites of finished flights are destroyed; their textures are shared and kept.
|
||||
- **The loaner's flights use the Company Courier sprite**; an owned ship's flight uses its model's sprite.
|
||||
|
||||
## Step 10C.5
|
||||
|
||||
- **Background zoom follows `zoom ^ 0.05`, floored at 1**, so at the maximum zoom (40×) the background is about 20% larger and it never shrinks below cover size. With the clamp, that guarantees the viewport is always covered (property-tested over viewports, pans, zooms and two image shapes).
|
||||
- **No background art means no background sprite**: the canvas's solid background color is the placeholder.
|
||||
- **Map height fixed:** the Map section used `100dvh − 7.5rem` and stopped 42 px above the nav, which showed as a dark band under the new art. It now uses the real header and nav heights (4.875rem plus the safe-area inset) and cancels the layout's 5rem scroll padding, so the page does not scroll.
|
||||
- **Screenshots** come from a scratchpad Playwright harness driving local Chrome against a separate dev stack (API 3125, web 5199). The Vite proxy target is now configurable (`API_PROXY_TARGET`, default `http://127.0.0.1:3000`) for that.
|
||||
|
||||
## Step 10C.6
|
||||
|
||||
- **The child offset angle is measured from the outward direction (Sun → parent on screen)**, not from screen right. With a fixed screen angle the Moon landed on the Sun whenever Earth was on the Sun's opposite side at phone width (seen in the first screenshot). The distance is still a fixed screen offset (decision 0.5.39), and the tests check the exact offset at several zooms and dates.
|
||||
- **Moon distance 46 px** (the style config draft said 30): at the largest sprite scale Earth and the Moon need (32 + 18) / 2 × 1.75 = 43.75 px to clear each other.
|
||||
- **Sprite scale = zoom ^ 0.35, clamped to 0.75–1.75** (new `spriteScale.zoomExponent`): sprites grow a little when zooming in and are about the same size at the default zoom.
|
||||
- **Scene anchors:** a child body's scene `point` is its parent's map point, and flights carry origin and destination ids. `layoutScene` adds the screen offset, so routes to and from the Moon end exactly at the drawn Moon. `locationMapPoint` (map-space Moon offset) is kept but no longer drawn.
|
||||
- **Labels and badges avoid collisions** with a small greedy pass: a label goes below its body, else above it if below hits another body or label; an idle badge takes the first clear diagonal. This handles Earth and the Moon and long names (Jupiter Cloud Platform next to Mars) without special cases.
|
||||
- **Tap targets** come from the same layout as drawing: each is a circle of the larger of the sprite size and 44 px. Ships still win ties.
|
||||
- **The idle badge sits just off the body's edge** (2 px, up-left by default) instead of a fixed distance from the centre, so it clears big bodies like Jupiter.
|
||||
|
||||
## Step 10C.7
|
||||
|
||||
- **Overlays are decided by a pure function** (`locationOverlays`) that the renderer applies. PixiJS `Text` cannot be measured under jsdom, so the scene test checks this plan (filter, lock, HQ, badge) rather than live display objects. The manual screenshots cover the drawing.
|
||||
- **The padlock is drawn over the body's centre** and the label loses its 🔒 emoji. Undiscovered labels keep the muted color.
|
||||
- **The HQ marker is a small green house** placed beyond the label (below a label that is below the body, above one that is above), so it never sits between label and body. The old green ring around bay bodies is gone.
|
||||
- **Lock arc fix:** a bare `Graphics.arc` in PixiJS continues the current path, which drew long stray lines. The lock now starts its shackle with `moveTo`.
|
||||
|
||||
## Step 10C.8
|
||||
|
||||
- **Clustering threshold and spacing are both 44 px** (`shipSeparationPx` = the minimum tap size), so after nudging each ship has its own full tap target. Ships are grouped greedily by distance and spread along the normal of the first ship's route, centred on the group's average position.
|
||||
- **Ships keep the sim position on the route line** except when nudged; the route line itself is not moved.
|
||||
- **Highlight rings** (amber, 5 px outside the sprite) go on the selected ship, and on a selected location too, since "selection highlights" are in the CR's code-drawn list.
|
||||
- **Earth–Moon flights stay close to both bodies**: with the Moon at a fixed 46 px from Earth (decision 0.5.39), those routes are always short on screen. That is the intended trade-off.
|
||||
|
||||
## Step 10C.9
|
||||
|
||||
- **The ticker lifecycle is a small helper (`runWhileVisible`)** tested with a fake ticker and jsdom visibility events. The real `MapCanvas` cannot start PixiJS under jsdom and is mocked in component tests.
|
||||
- **Canvas resolution is capped at 2×.** Measuring in headless Chrome (software WebGL) showed frame time is set by pixel count, not our JavaScript: 58.5 fps at DPR 1 with the CPU throttled 4×, versus 11 fps at DPR 3. The art only has 2× variants, so a 3× canvas cost nine times the pixels of 1× for no visible gain; the cap doubled the rate to 22 fps there. A real phone rasterizes on its GPU, so the user's device check is the real verdict.
|
||||
- **Crowds of more than three ships use a hexagonal grid**, still 44 px apart, after the first busy screenshot showed ten simultaneous departures strung across 400 px of empty space. Up to three ships keep the perpendicular spread from Step 10C.8.
|
||||
- **Per-frame work stays allocation-light:** textures and Text objects are created once, Graphics layers are cleared and redrawn, and labels restyle only when their colour changes (restyling re-renders a text texture).
|
||||
|
||||
## Step 10C.10
|
||||
|
||||
- **Step 11.1's amendment names the generated folder explicitly** (`public/assets/map-gen/`), since the source art is dropped from the build and must not be precached.
|
||||
- **The "map with undiscovered locations" review shot is the tutorial map**, which shows Mars, Ceres and Jupiter locked; the mid-game shot has Mars discovered and Ceres and Jupiter still locked.
|
||||
- **Screenshot size:** phone shots are full-resolution (DPR 3) PNGs, about 2 MB each; `docs/screenshots/` is about 24 MB. They are kept as-is for the review; trimming or moving them out of the repository is a later cleanup.
|
||||
|
|
|
|||
|
|
@ -1,89 +1,136 @@
|
|||
import type { PlayerState } from "@deadhead/schema";
|
||||
import { Application, Assets, type Texture } from "pixi.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Application, Graphics, Text } from "pixi.js";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { serverNow } from "../clock/clock";
|
||||
import { loadArt } from "./art/load";
|
||||
import { pan, useCamera, zoomAt, type Viewport } from "./camera";
|
||||
import { pan, toScreen, useCamera, zoomAt, type Viewport } from "./camera";
|
||||
import { hitTest, type MapSelection } from "./hitTest";
|
||||
import { MapRenderer } from "./renderer";
|
||||
import { buildScene } from "./scene";
|
||||
import { mapStyle } from "./style";
|
||||
import { runWhileVisible } from "./ticker";
|
||||
|
||||
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, CR-003). Loads the map art, then
|
||||
* draws locations and ships 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,
|
||||
onSelect,
|
||||
selected = null,
|
||||
}: {
|
||||
state: PlayerState;
|
||||
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
|
||||
onSelect?: (selection: MapSelection | null) => void;
|
||||
/** Highlighted with a ring (Step 10C.8). */
|
||||
selected?: MapSelection | null;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const onSelectRef = useRef(onSelect);
|
||||
onSelectRef.current = onSelect;
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
let cancelled = false;
|
||||
let stopTicker = () => {};
|
||||
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,
|
||||
});
|
||||
|
||||
// Art and renderer load together; missing art falls back to placeholders (0.5.41).
|
||||
const art = loadArt<Texture>(Assets, window.devicePixelRatio);
|
||||
void Promise.all([
|
||||
app.init({
|
||||
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);
|
||||
}
|
||||
// Undiscovered locations are locked until a delivery reaches them (Step 10A.16).
|
||||
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: mapStyle.colors.background,
|
||||
background: COLORS.background,
|
||||
antialias: true,
|
||||
autoDensity: true,
|
||||
// Capped at 2: the art has 2x variants at most, and 3x screens would
|
||||
// triple fill cost for no visible gain (Step 10C.9).
|
||||
resolution: Math.min(window.devicePixelRatio, 2),
|
||||
}),
|
||||
art,
|
||||
])
|
||||
.then(([, textures]) => {
|
||||
resolution: window.devicePixelRatio,
|
||||
})
|
||||
.then(() => {
|
||||
if (cancelled) {
|
||||
app.destroy(true);
|
||||
return;
|
||||
}
|
||||
container.appendChild(app.canvas);
|
||||
const renderer = new MapRenderer(app.stage, textures);
|
||||
app.ticker.add(() =>
|
||||
renderer.draw(
|
||||
buildScene(stateRef.current, serverNow()),
|
||||
viewport(),
|
||||
useCamera.getState(),
|
||||
selectedRef.current,
|
||||
),
|
||||
);
|
||||
// Only draw while the page is visible (Step 10C.9).
|
||||
stopTicker = runWhileVisible(app.ticker);
|
||||
app.stage.addChild(graphics);
|
||||
app.ticker.add(draw);
|
||||
})
|
||||
.catch(() => {
|
||||
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
|
||||
|
|
@ -140,7 +187,6 @@ export function MapCanvas({
|
|||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopTicker();
|
||||
container.removeEventListener("pointerdown", onDown);
|
||||
container.removeEventListener("pointermove", onMove);
|
||||
container.removeEventListener("pointerup", onUp);
|
||||
|
|
@ -155,12 +201,6 @@ export function MapCanvas({
|
|||
ref={containerRef}
|
||||
data-testid="map-canvas"
|
||||
className="relative h-full w-full touch-none overflow-hidden"
|
||||
>
|
||||
{loading && (
|
||||
<p role="status" className="absolute inset-x-0 top-4 text-center text-sm text-slate-400">
|
||||
Loading map…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
import { artManifest } from "./manifest";
|
||||
|
||||
/** Where `scripts/map-art.mjs` output is served from (`public/assets/map-gen`). */
|
||||
export const ART_BASE_URL = "/assets/map-gen";
|
||||
export const ART_BUNDLE = "map-art";
|
||||
|
||||
/** The generated files to load: 2x sprites on high-density screens (Step 10C.4). */
|
||||
export function artBundle(devicePixelRatio: number): Record<string, string> {
|
||||
const density = devicePixelRatio >= 2 ? "2x" : "1x";
|
||||
const bundle: Record<string, string> = {};
|
||||
for (const [id, asset] of Object.entries(artManifest.assets)) {
|
||||
if (asset.file === null) continue;
|
||||
bundle[id] =
|
||||
asset.kind === "background"
|
||||
? `${ART_BASE_URL}/${id}.webp`
|
||||
: `${ART_BASE_URL}/${id}@${density}.png`;
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** The part of PixiJS's `Assets` the loader uses (a fake in tests). */
|
||||
export interface AssetLoader<T> {
|
||||
addBundle(name: string, assets: Record<string, string>): void;
|
||||
load(alias: string): Promise<T>;
|
||||
}
|
||||
|
||||
/** Loaded textures by asset id. An id that is absent draws its placeholder (0.5.41). */
|
||||
export type ArtTextures<T> = ReadonlyMap<string, T>;
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Registers the map art as one bundle and loads each asset on its own, so one
|
||||
* failure only costs that asset: it is left out (placeholder) and one warning
|
||||
* names it. Never rejects.
|
||||
*/
|
||||
export async function loadArt<T>(
|
||||
loader: AssetLoader<T>,
|
||||
devicePixelRatio: number,
|
||||
warn: (message: string) => void = console.warn,
|
||||
): Promise<ArtTextures<T>> {
|
||||
const bundle = artBundle(devicePixelRatio);
|
||||
if (!registered) {
|
||||
loader.addBundle(ART_BUNDLE, bundle);
|
||||
registered = true;
|
||||
}
|
||||
const ids = Object.keys(bundle);
|
||||
const results = await Promise.allSettled(ids.map((id) => loader.load(id)));
|
||||
const textures = new Map<string, T>();
|
||||
results.forEach((result, i) => {
|
||||
const id = ids[i]!;
|
||||
if (result.status === "fulfilled" && result.value) textures.set(id, result.value);
|
||||
else warn(`Map art "${id}" failed to load; drawing its placeholder.`);
|
||||
});
|
||||
return textures;
|
||||
}
|
||||
|
||||
/** Test hook: forget that the bundle was registered. */
|
||||
export function resetArtRegistration(): void {
|
||||
registered = false;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import type { Scene } from "../scene";
|
||||
import type { ArtTextures } from "./load";
|
||||
|
||||
/** How one map item is drawn: its texture, or the old placeholder shape (0.5.41). */
|
||||
export type Visual<T> = { kind: "sprite"; texture: T } | { kind: "placeholder" };
|
||||
|
||||
export function visualFor<T>(assetId: string | null, textures: ArtTextures<T>): Visual<T> {
|
||||
const texture = assetId === null ? undefined : textures.get(assetId);
|
||||
return texture === undefined ? { kind: "placeholder" } : { kind: "sprite", texture };
|
||||
}
|
||||
|
||||
export interface SceneVisuals<T> {
|
||||
background: Visual<T>;
|
||||
sun: Visual<T>;
|
||||
/** By location id. */
|
||||
locations: Map<string, Visual<T>>;
|
||||
/** Location badge art (e.g. Jupiter's platform), by location id; absent if none. */
|
||||
badges: Map<string, Visual<T>>;
|
||||
/** By dispatch id. */
|
||||
flights: Map<string, Visual<T>>;
|
||||
}
|
||||
|
||||
/** Picks a texture or placeholder for everything in the scene. */
|
||||
export function sceneVisuals<T>(scene: Scene, textures: ArtTextures<T>): SceneVisuals<T> {
|
||||
return {
|
||||
background: visualFor("bg", textures),
|
||||
sun: visualFor("sun", textures),
|
||||
locations: new Map(scene.locations.map((l) => [l.id, visualFor(l.sprite, textures)])),
|
||||
badges: new Map(
|
||||
scene.locations
|
||||
.filter((l) => l.badge !== null)
|
||||
.map((l) => [l.id, visualFor(l.badge, textures)]),
|
||||
),
|
||||
flights: new Map(scene.flights.map((f) => [f.dispatchId, visualFor(f.sprite, textures)])),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import type { Camera, Viewport } from "./camera";
|
||||
import { layoutScene } from "./layout";
|
||||
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 or location
|
||||
* whose tap target (at least `minTapPx`, Step 10C.6) contains it, as laid out
|
||||
* on screen. Ships win ties, since they are drawn on top.
|
||||
* 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,
|
||||
|
|
@ -17,19 +18,19 @@ export function hitTest(
|
|||
viewport: Viewport,
|
||||
camera: Camera,
|
||||
): MapSelection | null {
|
||||
const layout = layoutScene(scene, viewport, camera);
|
||||
let best: { selection: MapSelection; distance: number } | null = null;
|
||||
const consider = (center: MapPoint, size: number, selection: MapSelection, bias: number) => {
|
||||
const distance = Math.hypot(center.x - tap.x, center.y - tap.y);
|
||||
if (distance <= size / 2 && (!best || distance - bias < best.distance)) {
|
||||
best = { selection, distance: distance - bias };
|
||||
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 layout.locations) {
|
||||
consider(location.center, location.tap, { kind: "location", id: location.id }, 0);
|
||||
for (const location of scene.locations) {
|
||||
consider(location.point, { kind: "location", id: location.id }, 0);
|
||||
}
|
||||
for (const flight of layout.flights) {
|
||||
consider(flight.center, flight.tap, { kind: "flight", dispatchId: flight.dispatchId }, 4);
|
||||
for (const flight of scene.flights) {
|
||||
consider(flight.marker, { kind: "flight", dispatchId: flight.dispatchId }, 4);
|
||||
}
|
||||
return (best as { selection: MapSelection } | null)?.selection ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,312 +0,0 @@
|
|||
import { toScreen, type Camera, type Viewport } from "./camera";
|
||||
import type { MapPoint } from "./projection";
|
||||
import type { Scene } from "./scene";
|
||||
import { mapStyle, type MapStyle } from "./style";
|
||||
|
||||
/**
|
||||
* Screen-space layout rules for the map art (CR-003). Pure functions of the
|
||||
* viewport, camera and style, so they are unit-tested without PixiJS.
|
||||
*/
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the background image goes (decision 0.5.40): scaled to cover the
|
||||
* viewport plus a margin on every side, zoomed only slightly with the map
|
||||
* (never below cover size), shifted by pan × parallax, then clamped so its
|
||||
* edges never show.
|
||||
*/
|
||||
export function backgroundRect(
|
||||
view: Viewport,
|
||||
camera: Camera,
|
||||
image: { width: number; height: number },
|
||||
style: MapStyle = mapStyle,
|
||||
): Rect {
|
||||
const margin = 1 + 2 * style.backgroundMargin;
|
||||
const cover = Math.max(
|
||||
(view.width * margin) / image.width,
|
||||
(view.height * margin) / image.height,
|
||||
);
|
||||
const scale = cover * Math.max(1, camera.zoom ** style.backgroundZoomFollow);
|
||||
const width = image.width * scale;
|
||||
const height = image.height * scale;
|
||||
const clamp = (value: number, min: number) => Math.min(0, Math.max(min, value));
|
||||
return {
|
||||
x: clamp((view.width - width) / 2 + camera.panX * style.parallax, view.width - width),
|
||||
y: clamp((view.height - height) / 2 + camera.panY * style.parallax, view.height - height),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprite size factor at a zoom (decision 0.5.38): sprites grow a little as the
|
||||
* player zooms in and shrink a little as they zoom out, within the limits.
|
||||
*/
|
||||
export function spriteScale(zoom: number, style: MapStyle = mapStyle): number {
|
||||
const { min, max, zoomExponent } = style.spriteScale;
|
||||
return Math.min(max, Math.max(min, zoom ** zoomExponent));
|
||||
}
|
||||
|
||||
/** A screen-space offset of `distance` px at `angleDeg` (clockwise from screen right). */
|
||||
export function polar(distance: number, angleDeg: number): MapPoint {
|
||||
const a = (angleDeg * Math.PI) / 180;
|
||||
return { x: Math.cos(a) * distance, y: Math.sin(a) * distance };
|
||||
}
|
||||
|
||||
/**
|
||||
* A child body's screen offset from its parent (decision 0.5.39): the
|
||||
* configured distance, at the configured angle from the outward direction
|
||||
* (Sun → parent on screen); zero for other locations.
|
||||
*/
|
||||
export function childOffset(
|
||||
locationId: string,
|
||||
parent: MapPoint,
|
||||
sun: MapPoint,
|
||||
style: MapStyle = mapStyle,
|
||||
): MapPoint {
|
||||
const child = style.childOffsets[locationId];
|
||||
if (!child) return { x: 0, y: 0 };
|
||||
const dx = parent.x - sun.x;
|
||||
const dy = parent.y - sun.y;
|
||||
const outward = dx === 0 && dy === 0 ? 0 : (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
return polar(child.distancePx, outward + child.angleDeg);
|
||||
}
|
||||
|
||||
/** Base on-screen size (long side, px) of a sprite asset. */
|
||||
export function baseSpriteSize(assetId: string, style: MapStyle = mapStyle): number {
|
||||
return style.spriteSizePx[assetId] ?? style.shipSizePx;
|
||||
}
|
||||
|
||||
/** A tap target is the sprite's size, but never under the minimum (Step 10C.6). */
|
||||
export function tapSize(spriteSize: number, style: MapStyle = mapStyle): number {
|
||||
return Math.max(style.minTapPx, spriteSize);
|
||||
}
|
||||
|
||||
export interface BodyLayout {
|
||||
center: MapPoint;
|
||||
/** Drawn size, long side (px). */
|
||||
size: number;
|
||||
/** Tap target, square side (px). */
|
||||
tap: number;
|
||||
}
|
||||
|
||||
export interface LocationLayout extends BodyLayout {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface FlightLayout {
|
||||
dispatchId: string;
|
||||
/** Route ends on screen, matching where the locations are drawn. */
|
||||
from: MapPoint;
|
||||
to: MapPoint;
|
||||
/** Where the ship is drawn: on the route, nudged apart from nearby ships. */
|
||||
center: MapPoint;
|
||||
/** Radians; the art points up (Step 10C.8). */
|
||||
rotation: number;
|
||||
size: number;
|
||||
tap: number;
|
||||
}
|
||||
|
||||
export interface SceneLayout {
|
||||
scale: number;
|
||||
sun: BodyLayout;
|
||||
locations: LocationLayout[];
|
||||
flights: FlightLayout[];
|
||||
}
|
||||
|
||||
const add = (a: MapPoint, b: MapPoint): MapPoint => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
|
||||
/** Where and how big everything in the scene is drawn on screen (CR-003). */
|
||||
export function layoutScene(
|
||||
scene: Scene,
|
||||
view: Viewport,
|
||||
camera: Camera,
|
||||
style: MapStyle = mapStyle,
|
||||
): SceneLayout {
|
||||
const scale = spriteScale(camera.zoom, style);
|
||||
const sunCenter = toScreen({ x: 0, y: 0 }, view, camera);
|
||||
/** Screen point of a (possibly child) location whose anchor is at map point `point`. */
|
||||
const drawn = (point: MapPoint, locationId: string | null): MapPoint => {
|
||||
const anchor = toScreen(point, view, camera);
|
||||
return locationId ? add(anchor, childOffset(locationId, anchor, sunCenter, style)) : anchor;
|
||||
};
|
||||
const body = (point: MapPoint, assetId: string, locationId: string | null): BodyLayout => {
|
||||
const size = baseSpriteSize(assetId, style) * scale;
|
||||
return { center: drawn(point, locationId), size, tap: tapSize(size, style) };
|
||||
};
|
||||
const shipSize = style.shipSizePx * scale;
|
||||
return {
|
||||
scale,
|
||||
sun: body({ x: 0, y: 0 }, "sun", null),
|
||||
locations: scene.locations.map((l) => ({ id: l.id, ...body(l.point, l.sprite, l.id) })),
|
||||
flights: separateShips(
|
||||
scene.flights.map((f) => {
|
||||
const from = drawn(f.from, f.originId);
|
||||
const to = drawn(f.to, f.destinationId);
|
||||
return {
|
||||
dispatchId: f.dispatchId,
|
||||
from,
|
||||
to,
|
||||
center: {
|
||||
x: from.x + (to.x - from.x) * f.progress,
|
||||
y: from.y + (to.y - from.y) * f.progress,
|
||||
},
|
||||
rotation: shipRotation(from, to),
|
||||
size: shipSize,
|
||||
tap: tapSize(shipSize, style),
|
||||
};
|
||||
}),
|
||||
style,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A ship sprite's rotation for travel from `from` to `to` (screen space): the
|
||||
* art points up, so moving right is +90° and moving down is 180°.
|
||||
*/
|
||||
export function shipRotation(from: MapPoint, to: MapPoint): number {
|
||||
return Math.atan2(to.y - from.y, to.x - from.x) + Math.PI / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* `count` points at least `gap` apart around (0, 0) on a hexagonal grid,
|
||||
* filled ring by ring: the most compact way to spread a crowd.
|
||||
*/
|
||||
export function hexSpiral(count: number, gap: number): MapPoint[] {
|
||||
const points: MapPoint[] = [{ x: 0, y: 0 }];
|
||||
const dirs = [0, 1, 2, 3, 4, 5].map((i) => polar(gap, 60 * i));
|
||||
for (let ring = 1; points.length < count; ring++) {
|
||||
// Start at the ring's corner, then walk its six sides.
|
||||
let p = { x: dirs[4]!.x * ring, y: dirs[4]!.y * ring };
|
||||
for (let side = 0; side < 6; side++) {
|
||||
for (let step = 0; step < ring; step++) {
|
||||
points.push(p);
|
||||
p = add(p, dirs[side]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return points.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudges ships that would be drawn within `shipSeparationPx` of each other
|
||||
* apart so each stays visible and tappable (Step 10C.8): up to three spread
|
||||
* perpendicular to the first one's route, more on a compact hexagonal grid,
|
||||
* always `shipSeparationPx` apart.
|
||||
*/
|
||||
export function separateShips(flights: FlightLayout[], style: MapStyle = mapStyle): FlightLayout[] {
|
||||
const gap = style.shipSeparationPx;
|
||||
const clusters: FlightLayout[][] = [];
|
||||
for (const flight of flights) {
|
||||
const near = clusters.find((c) =>
|
||||
c.some((o) => Math.hypot(o.center.x - flight.center.x, o.center.y - flight.center.y) < gap),
|
||||
);
|
||||
if (near) near.push(flight);
|
||||
else clusters.push([flight]);
|
||||
}
|
||||
return clusters.flatMap((cluster) => {
|
||||
if (cluster.length === 1) return cluster;
|
||||
const first = cluster[0]!;
|
||||
const dx = first.to.x - first.from.x;
|
||||
const dy = first.to.y - first.from.y;
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
const normal = { x: -dy / length, y: dx / length };
|
||||
const middle = {
|
||||
x: cluster.reduce((sum, f) => sum + f.center.x, 0) / cluster.length,
|
||||
y: cluster.reduce((sum, f) => sum + f.center.y, 0) / cluster.length,
|
||||
};
|
||||
if (cluster.length > 3) {
|
||||
const spots = hexSpiral(cluster.length, gap);
|
||||
return cluster.map((flight, i) => ({ ...flight, center: add(middle, spots[i]!) }));
|
||||
}
|
||||
return cluster.map((flight, i) => {
|
||||
const k = (i - (cluster.length - 1) / 2) * gap;
|
||||
return { ...flight, center: { x: middle.x + normal.x * k, y: middle.y + normal.y * k } };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface Box {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
const overlaps = (a: Box, b: Box) =>
|
||||
a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||
|
||||
const square = (center: MapPoint, size: number): Box => ({
|
||||
left: center.x - size / 2,
|
||||
top: center.y - size / 2,
|
||||
right: center.x + size / 2,
|
||||
bottom: center.y + size / 2,
|
||||
});
|
||||
|
||||
export interface Decoration {
|
||||
/** Label anchor: top-centre when below the body (anchorY 0), bottom-centre when above (1). */
|
||||
label: { x: number; y: number; anchorY: 0 | 1 };
|
||||
/** Idle-ship badge centre. */
|
||||
badge: MapPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places each location's label below its body, or above it when below would
|
||||
* cover another body or an earlier label; then its idle-ship badge on the
|
||||
* first diagonal clear of other bodies and the labels. `labelSize` measures a
|
||||
* label.
|
||||
*/
|
||||
export function decorate(
|
||||
layout: SceneLayout,
|
||||
labelSize: (id: string) => { width: number; height: number },
|
||||
style: MapStyle = mapStyle,
|
||||
): Map<string, Decoration> {
|
||||
const bodies = [
|
||||
{ id: "sun", box: square(layout.sun.center, layout.sun.size) },
|
||||
...layout.locations.map((l) => ({ id: l.id, box: square(l.center, l.size) })),
|
||||
];
|
||||
const labels: Box[] = [];
|
||||
const clearOfBodies = (box: Box, ownId: string) =>
|
||||
!bodies.some((b) => b.id !== ownId && overlaps(box, b.box));
|
||||
const clearOfLabels = (box: Box) => !labels.some((l) => overlaps(box, l));
|
||||
|
||||
const placed = layout.locations.map((location) => {
|
||||
const { width, height } = labelSize(location.id);
|
||||
const { center, size } = location;
|
||||
const gap = style.label.gapPx;
|
||||
const below = { x: center.x, y: center.y + size / 2 + gap, anchorY: 0 as const };
|
||||
const above = { x: center.x, y: center.y - size / 2 - gap, anchorY: 1 as const };
|
||||
const boxOf = (l: { x: number; y: number; anchorY: 0 | 1 }): Box => ({
|
||||
left: l.x - width / 2,
|
||||
right: l.x + width / 2,
|
||||
top: l.anchorY === 0 ? l.y : l.y - height,
|
||||
bottom: l.anchorY === 0 ? l.y + height : l.y,
|
||||
});
|
||||
const label =
|
||||
[below, above].find((l) => clearOfBodies(boxOf(l), location.id) && clearOfLabels(boxOf(l))) ??
|
||||
below;
|
||||
labels.push(boxOf(label));
|
||||
return { location, label };
|
||||
});
|
||||
|
||||
const result = new Map<string, Decoration>();
|
||||
for (const { location, label } of placed) {
|
||||
const { distancePx, angleDeg, radiusPx } = style.idleBadge;
|
||||
const angles = [angleDeg, -180 - angleDeg, -angleDeg, 180 + angleDeg];
|
||||
const spots = angles.map((a) => add(location.center, polar(location.size / 2 + distancePx, a)));
|
||||
const badge =
|
||||
spots.find((p) => {
|
||||
const box = square(p, radiusPx * 2);
|
||||
return clearOfBodies(box, location.id) && clearOfLabels(box);
|
||||
}) ?? spots[0]!;
|
||||
result.set(location.id, { label, badge });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import type { Scene } from "./scene";
|
||||
import { mapStyle, type MapStyle } from "./style";
|
||||
|
||||
/** How a location's state is shown on the map (Step 10C.7). */
|
||||
export interface LocationOverlay {
|
||||
id: string;
|
||||
/** Undiscovered: a code-drawn lock over the body. */
|
||||
locked: boolean;
|
||||
/** Undiscovered: the sprite is desaturated (color matrix filter) and dimmed. */
|
||||
desaturate: boolean;
|
||||
alpha: number;
|
||||
/** The player has a bay (HQ) here: a small marker by the label. */
|
||||
hq: boolean;
|
||||
/** Idle ships here, as a count badge; null for none. Idle ships are never drawn singly. */
|
||||
idleBadge: number | null;
|
||||
}
|
||||
|
||||
export function locationOverlays(scene: Scene, style: MapStyle = mapStyle): LocationOverlay[] {
|
||||
return scene.locations.map((l) => ({
|
||||
id: l.id,
|
||||
locked: !l.discovered,
|
||||
desaturate: !l.discovered,
|
||||
alpha: l.discovered ? 1 : style.undiscoveredAlpha,
|
||||
hq: l.hasBay,
|
||||
idleBadge: l.idleShips > 0 ? l.idleShips : null,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
import { ColorMatrixFilter, Container, Graphics, Sprite, Text, type Texture } from "pixi.js";
|
||||
import type { ArtTextures } from "./art/load";
|
||||
import { sceneVisuals, type Visual } from "./art/visuals";
|
||||
import { toScreen, type Camera, type Viewport } from "./camera";
|
||||
import { backgroundRect, decorate, layoutScene, polar } from "./layout";
|
||||
import type { MapSelection } from "./hitTest";
|
||||
import { locationOverlays } from "./overlays";
|
||||
import type { MapPoint } from "./projection";
|
||||
import type { Scene } from "./scene";
|
||||
import { mapStyle } from "./style";
|
||||
|
||||
const { colors } = mapStyle;
|
||||
|
||||
/** Fits a sprite's long side to `size` px, keeping its aspect ratio. */
|
||||
function fit(sprite: Sprite, size: number) {
|
||||
const { width, height } = sprite.texture;
|
||||
sprite.scale.set(size / Math.max(width, height, 1));
|
||||
}
|
||||
|
||||
/** A padlock (undiscovered location), `size` px tall, centred on `at`. */
|
||||
function drawLock(g: Graphics, at: MapPoint, size: number) {
|
||||
const w = size * 0.75;
|
||||
const h = size * 0.55;
|
||||
const top = at.y - size / 2 + size * 0.45;
|
||||
// moveTo first: a bare arc would join on from the previous path point.
|
||||
g.moveTo(at.x - w * 0.3, top)
|
||||
.arc(at.x, top, w * 0.3, Math.PI, 0)
|
||||
.stroke({ width: size * 0.14, color: colors.lock });
|
||||
g.roundRect(at.x - w / 2, top, w, h, size * 0.12)
|
||||
.fill(colors.lock)
|
||||
.stroke({ width: 1.5, color: colors.labelHalo });
|
||||
g.circle(at.x, top + h * 0.45, size * 0.08).fill(colors.labelHalo);
|
||||
}
|
||||
|
||||
/** The HQ (bay) marker: a small house, `size` px, centred on `at`. */
|
||||
function drawHq(g: Graphics, at: MapPoint, size: number) {
|
||||
const s = size / 2;
|
||||
g.poly([
|
||||
at.x - s,
|
||||
at.y,
|
||||
at.x,
|
||||
at.y - s,
|
||||
at.x + s,
|
||||
at.y,
|
||||
at.x + s * 0.7,
|
||||
at.y,
|
||||
at.x + s * 0.7,
|
||||
at.y + s,
|
||||
at.x - s * 0.7,
|
||||
at.y + s,
|
||||
at.x - s * 0.7,
|
||||
at.y,
|
||||
])
|
||||
.fill(colors.hq)
|
||||
.stroke({ width: 1, color: colors.labelHalo });
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the map with PixiJS (Steps 10.1–10.2, CR-003). Positions and sizes come
|
||||
* from `layoutScene`; sprites and text are created once per item and updated
|
||||
* every frame, and code-drawn shapes go in Graphics layers redrawn each frame.
|
||||
*/
|
||||
export class MapRenderer {
|
||||
/** Non-tiled, parallax background (decision 0.5.40); absent if its art is missing. */
|
||||
private readonly background: Sprite | null;
|
||||
private readonly orbits = new Graphics();
|
||||
private readonly routes = new Graphics();
|
||||
private readonly bodies = new Container();
|
||||
private readonly ships = new Container();
|
||||
private readonly overlay = new Graphics();
|
||||
private readonly texts = new Container();
|
||||
private readonly sprites = new Map<string, Sprite>();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
private readonly badges = new Map<string, Text>();
|
||||
/** One shared filter for every undiscovered location's sprite. */
|
||||
private readonly desaturate = new ColorMatrixFilter();
|
||||
|
||||
constructor(
|
||||
stage: Container,
|
||||
private readonly textures: ArtTextures<Texture>,
|
||||
) {
|
||||
this.desaturate.desaturate();
|
||||
const bg = textures.get("bg");
|
||||
this.background = bg ? new Sprite({ texture: bg }) : null;
|
||||
if (this.background) stage.addChild(this.background);
|
||||
stage.addChild(this.orbits, this.routes, this.bodies, this.ships, this.overlay, this.texts);
|
||||
}
|
||||
|
||||
/** A sprite for `key`, created on first use; hidden until positioned this frame. */
|
||||
private sprite(key: string, texture: Texture, layer: Container): Sprite {
|
||||
let sprite = this.sprites.get(key);
|
||||
if (!sprite) {
|
||||
sprite = new Sprite({ texture, anchor: 0.5 });
|
||||
this.sprites.set(key, sprite);
|
||||
layer.addChild(sprite);
|
||||
}
|
||||
sprite.visible = true;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/** Draws `visual` as a sprite at `at`, or calls `placeholder` (decision 0.5.41). */
|
||||
private place(
|
||||
key: string,
|
||||
visual: Visual<Texture>,
|
||||
layer: Container,
|
||||
at: MapPoint,
|
||||
size: number,
|
||||
placeholder: () => void,
|
||||
): Sprite | null {
|
||||
if (visual.kind === "placeholder") {
|
||||
placeholder();
|
||||
return null;
|
||||
}
|
||||
const sprite = this.sprite(key, visual.texture, layer);
|
||||
fit(sprite, size);
|
||||
sprite.position.set(at.x, at.y);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/** A text for `key` in the text layer, created on first use. */
|
||||
private text(map: Map<string, Text>, key: string, create: () => Text): Text {
|
||||
let text = map.get(key);
|
||||
if (!text) {
|
||||
text = create();
|
||||
map.set(key, text);
|
||||
this.texts.addChild(text);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
draw(scene: Scene, view: Viewport, camera: Camera, selected: MapSelection | null = null) {
|
||||
const visuals = sceneVisuals(scene, this.textures);
|
||||
const layout = layoutScene(scene, view, camera);
|
||||
const sun = layout.sun.center;
|
||||
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
|
||||
for (const sprite of this.sprites.values()) sprite.visible = false;
|
||||
|
||||
if (this.background) {
|
||||
const r = backgroundRect(view, camera, this.background.texture);
|
||||
this.background.position.set(r.x, r.y);
|
||||
this.background.setSize(r.width, r.height);
|
||||
}
|
||||
|
||||
this.orbits.clear();
|
||||
this.routes.clear();
|
||||
this.overlay.clear();
|
||||
|
||||
const placed = new Map(layout.locations.map((l) => [l.id, l]));
|
||||
// Faint orbit rings: around the Sun at each body's current compressed
|
||||
// radius, and a small one around a parent for its child (the Moon).
|
||||
for (const location of scene.locations) {
|
||||
if (location.orbitRadius !== null) {
|
||||
this.orbits
|
||||
.circle(sun.x, sun.y, location.orbitRadius * unit)
|
||||
.stroke({ width: 1, color: colors.orbit, alpha: colors.orbitAlpha });
|
||||
}
|
||||
const child = mapStyle.childOffsets[location.id];
|
||||
const parent = location.parentId ? placed.get(location.parentId) : undefined;
|
||||
if (child && parent) {
|
||||
this.orbits
|
||||
.circle(parent.center.x, parent.center.y, child.distancePx)
|
||||
.stroke({ width: 1, color: colors.orbit, alpha: colors.orbitAlpha });
|
||||
}
|
||||
}
|
||||
|
||||
this.place("sun", visuals.sun, this.bodies, sun, layout.sun.size, () =>
|
||||
this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun),
|
||||
);
|
||||
|
||||
const overlays = new Map(locationOverlays(scene).map((o) => [o.id, o]));
|
||||
for (const location of scene.locations) {
|
||||
const { center: p, size } = placed.get(location.id)!;
|
||||
const state = overlays.get(location.id)!;
|
||||
const placeholderColor = state.locked ? colors.labelUndiscovered : colors.placeholderBody;
|
||||
const sprite = this.place(
|
||||
`loc:${location.id}`,
|
||||
visuals.locations.get(location.id)!,
|
||||
this.bodies,
|
||||
p,
|
||||
size,
|
||||
() =>
|
||||
this.overlay.circle(p.x, p.y, 5).fill({ color: placeholderColor, alpha: state.alpha }),
|
||||
);
|
||||
// Undiscovered: desaturated and dimmed (Step 10C.7).
|
||||
if (sprite) {
|
||||
sprite.filters = state.desaturate ? [this.desaturate] : [];
|
||||
sprite.alpha = state.alpha;
|
||||
}
|
||||
|
||||
// Location badge art (Jupiter's cloud platform) on the body's edge, if provided.
|
||||
const badgeArt = visuals.badges.get(location.id);
|
||||
if (badgeArt) {
|
||||
const edge = polar(size / 2, mapStyle.locationBadge.angleDeg);
|
||||
const at = { x: p.x + edge.x, y: p.y + edge.y };
|
||||
const badgeSize = mapStyle.locationBadge.sizePx * layout.scale;
|
||||
const art = this.place(
|
||||
`badge:${location.id}`,
|
||||
badgeArt,
|
||||
this.bodies,
|
||||
at,
|
||||
badgeSize,
|
||||
() => {},
|
||||
);
|
||||
if (art) art.alpha = state.alpha;
|
||||
}
|
||||
|
||||
if (state.locked) drawLock(this.overlay, p, mapStyle.lockSizePx);
|
||||
|
||||
// The name under the body, with a dark halo for contrast on the background.
|
||||
const label = this.text(this.labels, location.id, () => {
|
||||
const text = new Text({
|
||||
text: location.name,
|
||||
style: {
|
||||
fontSize: mapStyle.label.fontSizePx,
|
||||
fontWeight: "600",
|
||||
fill: colors.label,
|
||||
stroke: { color: colors.labelHalo, width: mapStyle.label.haloPx * 2, join: "round" },
|
||||
},
|
||||
});
|
||||
text.anchor.set(0.5, 0);
|
||||
return text;
|
||||
});
|
||||
// Only on change: restyling a Text re-renders its texture (Step 10C.9).
|
||||
const fill = state.locked ? colors.labelUndiscovered : colors.label;
|
||||
if (label.style.fill !== fill) label.style.fill = fill;
|
||||
|
||||
// Idle ships here: a count badge just off the body's edge; never single sprites.
|
||||
const badge = this.text(this.badges, location.id, () => {
|
||||
const text = new Text({
|
||||
text: "",
|
||||
style: {
|
||||
fontSize: mapStyle.idleBadge.fontSizePx,
|
||||
fontWeight: "700",
|
||||
fill: colors.badgeText,
|
||||
},
|
||||
});
|
||||
text.anchor.set(0.5);
|
||||
return text;
|
||||
});
|
||||
badge.visible = state.idleBadge !== null;
|
||||
badge.text = String(state.idleBadge ?? "");
|
||||
}
|
||||
|
||||
// Labels below (or above) bodies, badges on a clear diagonal (Step 10C.6), and
|
||||
// the HQ marker on the far side of the label from its body (Step 10C.7).
|
||||
const decorations = decorate(layout, (id) => this.labels.get(id)!);
|
||||
for (const location of scene.locations) {
|
||||
const { label, badge } = decorations.get(location.id)!;
|
||||
const text = this.labels.get(location.id)!;
|
||||
text.anchor.set(0.5, label.anchorY);
|
||||
text.position.set(label.x, label.y);
|
||||
if (overlays.get(location.id)!.hq) {
|
||||
const gap = 3 + mapStyle.hqSizePx / 2;
|
||||
const y = label.anchorY === 0 ? label.y + text.height + gap : label.y - text.height - gap;
|
||||
drawHq(this.overlay, { x: label.x, y }, mapStyle.hqSizePx);
|
||||
}
|
||||
const count = this.badges.get(location.id)!;
|
||||
if (count.visible) {
|
||||
this.overlay.circle(badge.x, badge.y, mapStyle.idleBadge.radiusPx).fill(colors.badgeFill);
|
||||
count.position.set(badge.x, badge.y);
|
||||
}
|
||||
}
|
||||
|
||||
// In-flight ships (Steps 10.2, 10C.8): a faint route, brighter ahead of the
|
||||
// ship, and the ship's sprite facing its direction of travel.
|
||||
for (const flight of layout.flights) {
|
||||
const scened = scene.flights.find((f) => f.dispatchId === flight.dispatchId)!;
|
||||
const onRoute = {
|
||||
x: flight.from.x + (flight.to.x - flight.from.x) * scened.progress,
|
||||
y: flight.from.y + (flight.to.y - flight.from.y) * scened.progress,
|
||||
};
|
||||
this.routes
|
||||
.moveTo(flight.from.x, flight.from.y)
|
||||
.lineTo(flight.to.x, flight.to.y)
|
||||
.stroke({ width: 1, color: colors.routeBehind, alpha: 0.35 });
|
||||
this.routes
|
||||
.moveTo(onRoute.x, onRoute.y)
|
||||
.lineTo(flight.to.x, flight.to.y)
|
||||
.stroke({ width: 1.5, color: colors.routeAhead, alpha: 0.9 });
|
||||
const { center } = flight;
|
||||
const ship = this.place(
|
||||
`ship:${flight.dispatchId}`,
|
||||
visuals.flights.get(flight.dispatchId)!,
|
||||
this.ships,
|
||||
center,
|
||||
flight.size,
|
||||
() => this.overlay.circle(center.x, center.y, 4).fill(colors.placeholderShip),
|
||||
);
|
||||
if (ship) ship.rotation = flight.rotation;
|
||||
if (selected?.kind === "flight" && selected.dispatchId === flight.dispatchId) {
|
||||
this.overlay
|
||||
.circle(center.x, center.y, flight.size / 2 + 5)
|
||||
.stroke({ width: 2, color: colors.selection });
|
||||
}
|
||||
}
|
||||
|
||||
// A selected location gets the same highlight ring.
|
||||
if (selected?.kind === "location") {
|
||||
const location = placed.get(selected.id);
|
||||
if (location) {
|
||||
this.overlay
|
||||
.circle(location.center.x, location.center.y, location.size / 2 + 5)
|
||||
.stroke({ width: 2, color: colors.selection });
|
||||
}
|
||||
}
|
||||
|
||||
// Ships whose flight ended: drop their sprites (the texture is shared and kept).
|
||||
for (const [key, sprite] of this.sprites) {
|
||||
if (key.startsWith("ship:") && !sprite.visible) {
|
||||
sprite.destroy();
|
||||
this.sprites.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +1,30 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
||||
import { bodyPosition, dispatchProgress, loanerModel, realToGame } from "@deadhead/sim";
|
||||
import { bodyPosition, dispatchProgress, realToGame } from "@deadhead/sim";
|
||||
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
|
||||
import { mapStyle } from "./style";
|
||||
|
||||
export interface SceneLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
/**
|
||||
* Map position; for a child body (the Moon) its parent's position, since
|
||||
* children are drawn at a fixed screen offset from it (decision 0.5.39).
|
||||
*/
|
||||
point: MapPoint;
|
||||
/** The parent location of a child body, else null. */
|
||||
parentId: string | null;
|
||||
/** 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;
|
||||
/** Map art asset ids (CR-003). */
|
||||
sprite: string;
|
||||
badge: string | null;
|
||||
}
|
||||
|
||||
export interface SceneFlight {
|
||||
dispatchId: string;
|
||||
/** The ship's id, or "loaner" for a tutorial run. */
|
||||
shipId: string;
|
||||
originId: string;
|
||||
destinationId: string;
|
||||
/** Origin at departure and destination at arrival (anchors; see `point`): the route line. */
|
||||
/** 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;
|
||||
/** The flying ship's model and its map art asset id (CR-003). */
|
||||
modelId: string;
|
||||
sprite: string;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
|
|
@ -53,37 +38,28 @@ function location(id: string) {
|
|||
return found;
|
||||
}
|
||||
|
||||
/** The location whose map position a location is drawn from: its parent, if it has one. */
|
||||
export function anchorLocation(id: string) {
|
||||
return location(mapStyle.childOffsets[id]?.parentId ?? id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, modelId: string): SceneFlight {
|
||||
export function flightMarker(dispatch: Dispatch, now: number): SceneFlight {
|
||||
const from = locationMapPoint(
|
||||
anchorLocation(dispatch.originId),
|
||||
location(dispatch.originId),
|
||||
realToGame(dispatch.departureAt, content.config),
|
||||
);
|
||||
const to = locationMapPoint(
|
||||
anchorLocation(dispatch.destinationId),
|
||||
location(dispatch.destinationId),
|
||||
realToGame(dispatch.arrivalAt, content.config),
|
||||
);
|
||||
const progress = dispatchProgress(dispatch, now);
|
||||
return {
|
||||
dispatchId: dispatch.id,
|
||||
shipId: dispatch.shipId,
|
||||
originId: dispatch.originId,
|
||||
destinationId: dispatch.destinationId,
|
||||
from,
|
||||
to,
|
||||
marker: { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress },
|
||||
progress,
|
||||
modelId,
|
||||
sprite: content.shipModel(modelId)?.sprite ?? `ship-${modelId}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -102,24 +78,13 @@ export function buildScene(state: PlayerState, now: number): Scene {
|
|||
return {
|
||||
id: loc.id,
|
||||
name: loc.name,
|
||||
point: locationMapPoint(anchorLocation(loc.id), gameTime),
|
||||
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
|
||||
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),
|
||||
sprite: loc.sprite,
|
||||
badge: loc.badge ?? null,
|
||||
};
|
||||
}),
|
||||
flights: state.activeDispatches.map((d) =>
|
||||
flightMarker(
|
||||
d,
|
||||
now,
|
||||
d.tutorial
|
||||
? loanerModel(content).id
|
||||
: (state.ships.find((s) => s.id === d.shipId)?.modelId ?? "courier"),
|
||||
),
|
||||
),
|
||||
flights: state.activeDispatches.map((d) => flightMarker(d, now)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Map presentation settings (CR-003): sizes in screen pixels, offsets, and
|
||||
* colors. These are not game numbers, so they live here, not in
|
||||
* `@deadhead/content`. Angles are degrees clockwise from screen right.
|
||||
*/
|
||||
const px = z.number().positive();
|
||||
const color = z.number().int().min(0).max(0xffffff);
|
||||
const offset = z.object({ distancePx: px, angleDeg: z.number() });
|
||||
|
||||
export const mapStyleSchema = z
|
||||
.object({
|
||||
/** On-screen base size (long side, px) per sprite asset id (decision 0.5.38). */
|
||||
spriteSizePx: z.record(z.string(), px),
|
||||
/** Every ship sprite's base size (long side, px). */
|
||||
shipSizePx: px,
|
||||
/**
|
||||
* Sprite size factor as the player zooms (decision 0.5.38): zoom ^ exponent,
|
||||
* clamped to [min, max], so sprites stay roughly the same size on screen.
|
||||
*/
|
||||
spriteScale: z.object({ min: px, max: px, zoomExponent: z.number().min(0).max(1) }),
|
||||
/**
|
||||
* Child bodies drawn at a fixed screen distance from their parent (decision
|
||||
* 0.5.39). The angle is measured from the outward direction (Sun → parent),
|
||||
* so a child never lands on the Sun.
|
||||
*/
|
||||
childOffsets: z.record(z.string(), offset.extend({ parentId: z.string() })),
|
||||
/**
|
||||
* Idle-ship count badge: `distancePx` beyond the body's edge, at `angleDeg`,
|
||||
* or the first of the other diagonals that is clear of bodies and labels.
|
||||
*/
|
||||
idleBadge: offset.extend({ radiusPx: px, fontSizePx: px }),
|
||||
/** Location badge art (e.g. Jupiter's cloud platform), on the body's edge. */
|
||||
locationBadge: z.object({ sizePx: px, angleDeg: z.number() }),
|
||||
/** Opacity of an undiscovered location's (desaturated) sprite. */
|
||||
undiscoveredAlpha: z.number().min(0).max(1),
|
||||
/** Code-drawn lock (undiscovered) and HQ marker sizes, px. */
|
||||
lockSizePx: px,
|
||||
hqSizePx: px,
|
||||
/** No tap target is smaller than this (px, square). */
|
||||
minTapPx: px,
|
||||
/** Background moves this fraction of the camera pan (decision 0.5.40). */
|
||||
parallax: z.number().min(0).max(1),
|
||||
/** Background covers the viewport plus this fraction of it on every side. */
|
||||
backgroundMargin: z.number().min(0).max(1),
|
||||
/** Background zoom follows map zoom by this fraction (0 = never zooms). */
|
||||
backgroundZoomFollow: z.number().min(0).max(1),
|
||||
label: z.object({ fontSizePx: px, gapPx: z.number().min(0), haloPx: z.number().min(0) }),
|
||||
/** Gap kept between in-flight ships that would otherwise overlap (Step 10C.8). */
|
||||
shipSeparationPx: px,
|
||||
colors: z.object({
|
||||
background: color,
|
||||
orbit: color,
|
||||
orbitAlpha: z.number().min(0).max(1),
|
||||
routeBehind: color,
|
||||
routeAhead: color,
|
||||
label: color,
|
||||
labelUndiscovered: color,
|
||||
labelHalo: color,
|
||||
hq: color,
|
||||
badgeFill: color,
|
||||
badgeText: color,
|
||||
selection: color,
|
||||
lock: color,
|
||||
placeholderBody: color,
|
||||
placeholderShip: color,
|
||||
sun: color,
|
||||
}),
|
||||
})
|
||||
.refine((s) => s.spriteScale.min <= s.spriteScale.max, {
|
||||
message: "spriteScale.min must not exceed spriteScale.max",
|
||||
path: ["spriteScale"],
|
||||
});
|
||||
export type MapStyle = z.infer<typeof mapStyleSchema>;
|
||||
|
||||
export const mapStyle: MapStyle = mapStyleSchema.parse({
|
||||
spriteSizePx: {
|
||||
sun: 64,
|
||||
"body-jupiter": 48,
|
||||
"body-earth": 32,
|
||||
"body-mars": 32,
|
||||
"body-ceres": 22,
|
||||
"body-moon": 18,
|
||||
},
|
||||
shipSizePx: 24,
|
||||
spriteScale: { min: 0.75, max: 1.75, zoomExponent: 0.35 },
|
||||
// 46 px clears Earth and the Moon at the largest sprite scale: (32 + 18) / 2 × 1.75 = 43.75.
|
||||
childOffsets: { moon: { parentId: "earth", distancePx: 46, angleDeg: -30 } },
|
||||
idleBadge: { distancePx: 2, angleDeg: -135, radiusPx: 8, fontSizePx: 10 },
|
||||
locationBadge: { sizePx: 16, angleDeg: -45 },
|
||||
undiscoveredAlpha: 0.55,
|
||||
lockSizePx: 12,
|
||||
hqSizePx: 9,
|
||||
minTapPx: 44,
|
||||
parallax: 0.15,
|
||||
backgroundMargin: 0.1,
|
||||
backgroundZoomFollow: 0.05,
|
||||
label: { fontSizePx: 11, gapPx: 4, haloPx: 3 },
|
||||
shipSeparationPx: 44,
|
||||
colors: {
|
||||
background: 0x020617,
|
||||
orbit: 0x94a3b8,
|
||||
orbitAlpha: 0.18,
|
||||
routeBehind: 0x38bdf8,
|
||||
routeAhead: 0x7dd3fc,
|
||||
label: 0xe2e8f0,
|
||||
labelUndiscovered: 0x94a3b8,
|
||||
labelHalo: 0x020617,
|
||||
hq: 0x34d399,
|
||||
badgeFill: 0xfbbf24,
|
||||
badgeText: 0x020617,
|
||||
selection: 0xfbbf24,
|
||||
lock: 0xcbd5e1,
|
||||
placeholderBody: 0xe2e8f0,
|
||||
placeholderShip: 0xfbbf24,
|
||||
sun: 0xfbbf24,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/** The part of a PixiJS `Ticker` the map's lifecycle needs (a fake in tests). */
|
||||
export interface Startable {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `ticker` only while the page is visible (Step 10C.9): stops it when the
|
||||
* page is hidden and starts it again when it returns. The returned cleanup (for
|
||||
* unmounting the Map) stops it and removes the listener.
|
||||
*/
|
||||
export function runWhileVisible(ticker: Startable, doc: Document = document): () => void {
|
||||
const sync = () => (doc.visibilityState === "hidden" ? ticker.stop() : ticker.start());
|
||||
doc.addEventListener("visibilitychange", sync);
|
||||
sync();
|
||||
return () => {
|
||||
doc.removeEventListener("visibilitychange", sync);
|
||||
ticker.stop();
|
||||
};
|
||||
}
|
||||
|
|
@ -26,11 +26,9 @@ export function MapScreen() {
|
|||
const [selection, setSelection] = useState<MapSelection | null>(null);
|
||||
|
||||
return (
|
||||
// Fill the space between the header (2.3rem) and the bottom nav (2.6rem + safe
|
||||
// area); -mb-20 cancels the layout's scroll padding so the page never scrolls.
|
||||
<section className="relative -mb-20 h-[calc(100dvh-4.875rem-env(safe-area-inset-bottom))]">
|
||||
<section className="relative h-[calc(100dvh-7.5rem)]">
|
||||
<h1 className="sr-only">Map</h1>
|
||||
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
|
||||
<MapCanvas state={state} onSelect={setSelection} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
import type { Dispatch, Ship } from "@deadhead/schema";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadArt, resetArtRegistration, type AssetLoader } from "../src/map/art/load";
|
||||
import { artManifest } from "../src/map/art/manifest";
|
||||
import { sceneVisuals } from "../src/map/art/visuals";
|
||||
import { buildScene } from "../src/map/scene";
|
||||
import { samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const flight = (id: string, shipId: string, tutorial = false): Dispatch => ({
|
||||
id,
|
||||
shipId,
|
||||
pilotId: "p",
|
||||
contract: null,
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
tutorial,
|
||||
departureAt: NOW - 60_000,
|
||||
arrivalAt: NOW + 60_000,
|
||||
distanceMillionKm: 0.4,
|
||||
fuelCost: 0,
|
||||
expectedPayout: 0,
|
||||
seed: 1,
|
||||
status: "in-flight",
|
||||
resolvedAt: null,
|
||||
});
|
||||
|
||||
const flying = (id: string, modelId: string, dispatchId: string): Ship => ({
|
||||
id,
|
||||
modelId,
|
||||
name: id,
|
||||
status: "in-flight",
|
||||
locationId: null,
|
||||
dispatchId,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
});
|
||||
|
||||
/** Ships of every model in flight, plus the tutorial loaner. */
|
||||
const scene = () =>
|
||||
buildScene(
|
||||
samplePlayerState({
|
||||
ships: [
|
||||
flying("s1", "courier", "d1"),
|
||||
flying("s2", "freighter", "d2"),
|
||||
flying("s3", "bulk-hauler", "d3"),
|
||||
],
|
||||
activeDispatches: [
|
||||
flight("d1", "s1"),
|
||||
flight("d2", "s2"),
|
||||
flight("d3", "s3"),
|
||||
flight("d4", "loaner", true),
|
||||
],
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
|
||||
/** A fake PixiJS `Assets`: every alias loads as "tex:<alias>" unless it is in `failing`. */
|
||||
function fakeLoader(failing: string[] = []) {
|
||||
const loader = {
|
||||
bundles: [] as Record<string, string>[],
|
||||
addBundle: vi.fn((_name: string, assets: Record<string, string>) => {
|
||||
loader.bundles.push(assets);
|
||||
}),
|
||||
load: vi.fn(async (alias: string) => {
|
||||
if (failing.includes(alias)) throw new Error("404");
|
||||
return `tex:${alias}`;
|
||||
}),
|
||||
};
|
||||
return loader satisfies AssetLoader<string>;
|
||||
}
|
||||
|
||||
describe("map art loading (CR-003)", () => {
|
||||
beforeEach(() => resetArtRegistration());
|
||||
|
||||
it("uses a texture for every location, the Sun, the background and every ship", async () => {
|
||||
const warn = vi.fn();
|
||||
const textures = await loadArt(fakeLoader(), 1, warn);
|
||||
const visuals = sceneVisuals(scene(), textures);
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(visuals.background).toEqual({ kind: "sprite", texture: "tex:bg" });
|
||||
expect(visuals.sun).toEqual({ kind: "sprite", texture: "tex:sun" });
|
||||
for (const [id, visual] of visuals.locations) {
|
||||
expect(visual, id).toMatchObject({ kind: "sprite" });
|
||||
}
|
||||
expect(Object.fromEntries(visuals.flights)).toEqual({
|
||||
d1: { kind: "sprite", texture: "tex:ship-courier" },
|
||||
d2: { kind: "sprite", texture: "tex:ship-freighter" },
|
||||
d3: { kind: "sprite", texture: "tex:ship-bulk-hauler" },
|
||||
d4: { kind: "sprite", texture: "tex:ship-company-courier" },
|
||||
});
|
||||
});
|
||||
|
||||
it("draws a placeholder for one failed asset, textures for the rest, and warns once", async () => {
|
||||
const warn = vi.fn();
|
||||
const textures = await loadArt(fakeLoader(["body-mars"]), 1, warn);
|
||||
const visuals = sceneVisuals(scene(), textures);
|
||||
|
||||
expect(visuals.locations.get("mars")).toEqual({ kind: "placeholder" });
|
||||
for (const [id, visual] of visuals.locations) {
|
||||
if (id !== "mars") expect(visual, id).toMatchObject({ kind: "sprite" });
|
||||
}
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]![0]).toContain("body-mars");
|
||||
});
|
||||
|
||||
it("draws Jupiter's badge as a placeholder when its art was not provided", async () => {
|
||||
const visuals = sceneVisuals(scene(), await loadArt(fakeLoader(), 1, vi.fn()));
|
||||
expect(artManifest.assets["badge-cloud-platform"]!.file).toBeNull();
|
||||
expect(visuals.badges.get("jupiter-cloud-platform")).toEqual({ kind: "placeholder" });
|
||||
});
|
||||
|
||||
it("requests the 2x variants at device pixel ratio 2, and 1x below it", async () => {
|
||||
const hiDpi = fakeLoader();
|
||||
await loadArt(hiDpi, 2, vi.fn());
|
||||
expect(hiDpi.addBundle).toHaveBeenCalledTimes(1);
|
||||
expect(hiDpi.bundles[0]!["body-earth"]).toBe("/assets/map-gen/body-earth@2x.png");
|
||||
expect(hiDpi.bundles[0]!["ship-courier"]).toBe("/assets/map-gen/ship-courier@2x.png");
|
||||
expect(hiDpi.bundles[0]!.bg).toBe("/assets/map-gen/bg.webp");
|
||||
expect(hiDpi.load).toHaveBeenCalledWith("body-earth");
|
||||
|
||||
resetArtRegistration();
|
||||
const normal = fakeLoader();
|
||||
await loadArt(normal, 1.5, vi.fn());
|
||||
expect(normal.bundles[0]!["body-earth"]).toBe("/assets/map-gen/body-earth@1x.png");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera";
|
||||
import {
|
||||
backgroundRect,
|
||||
decorate,
|
||||
layoutScene,
|
||||
polar,
|
||||
spriteScale,
|
||||
type SceneLayout,
|
||||
} from "../src/map/layout";
|
||||
import { buildScene } from "../src/map/scene";
|
||||
import { mapStyle } from "../src/map/style";
|
||||
import { samplePlayerState } from "./render";
|
||||
|
||||
const viewports = [
|
||||
{ width: 390, height: 700 },
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 2560, height: 1440 },
|
||||
{ width: 320, height: 2000 },
|
||||
];
|
||||
const pans = [-50_000, -800, -1, 0, 3, 640, 50_000];
|
||||
const zooms = [MIN_ZOOM, 0.75, 1, 3, 12, MAX_ZOOM];
|
||||
const images = [
|
||||
{ width: 1254, height: 1254 },
|
||||
{ width: 2048, height: 1024 },
|
||||
];
|
||||
|
||||
describe("background placement (decision 0.5.40)", () => {
|
||||
it("always fully contains the viewport", () => {
|
||||
for (const view of viewports) {
|
||||
for (const image of images) {
|
||||
for (const panX of pans) {
|
||||
for (const panY of pans) {
|
||||
for (const zoom of zooms) {
|
||||
const r = backgroundRect(view, { zoom, panX, panY }, image);
|
||||
const where = JSON.stringify({ view, image, panX, panY, zoom, r });
|
||||
expect(r.x, where).toBeLessThanOrEqual(0);
|
||||
expect(r.y, where).toBeLessThanOrEqual(0);
|
||||
expect(r.x + r.width, where).toBeGreaterThanOrEqual(view.width - 1e-9);
|
||||
expect(r.y + r.height, where).toBeGreaterThanOrEqual(view.height - 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("moves a fraction of the pan (parallax) and keeps its aspect ratio", () => {
|
||||
const view = { width: 1280, height: 720 };
|
||||
const image = { width: 1254, height: 1254 };
|
||||
const still = backgroundRect(view, { zoom: 1, panX: 0, panY: 0 }, image);
|
||||
const panned = backgroundRect(view, { zoom: 1, panX: 100, panY: -60 }, image);
|
||||
expect(panned.x - still.x).toBeCloseTo(15);
|
||||
expect(panned.y - still.y).toBeCloseTo(-9);
|
||||
expect(still.width / still.height).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it("zooms only slightly with the map", () => {
|
||||
const view = { width: 1280, height: 720 };
|
||||
const image = { width: 1254, height: 1254 };
|
||||
const one = backgroundRect(view, { zoom: 1, panX: 0, panY: 0 }, image);
|
||||
const far = backgroundRect(view, { zoom: MAX_ZOOM, panX: 0, panY: 0 }, image);
|
||||
expect(far.width / one.width).toBeGreaterThan(1);
|
||||
expect(far.width / one.width).toBeLessThan(1.3);
|
||||
});
|
||||
});
|
||||
|
||||
const dates = [
|
||||
Date.parse("2100-01-01T00:00:00Z"),
|
||||
Date.parse("2100-03-17T08:00:00Z"),
|
||||
Date.parse("2101-11-02T19:30:00Z"),
|
||||
];
|
||||
const view = { width: 390, height: 700 };
|
||||
const cameraAt = (zoom: number) => ({ zoom, panX: 13, panY: -27 });
|
||||
|
||||
describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => {
|
||||
it("keeps the sprite scale within the configured limits at extreme zooms", () => {
|
||||
const { min, max } = mapStyle.spriteScale;
|
||||
for (const zoom of [1e-6, MIN_ZOOM, 0.75, 1, 2, MAX_ZOOM, 1e6]) {
|
||||
expect(spriteScale(zoom)).toBeGreaterThanOrEqual(min);
|
||||
expect(spriteScale(zoom)).toBeLessThanOrEqual(max);
|
||||
}
|
||||
expect(spriteScale(1e-6)).toBe(min);
|
||||
expect(spriteScale(1e6)).toBe(max);
|
||||
expect(spriteScale(1)).toBe(1);
|
||||
});
|
||||
|
||||
it("puts the Moon exactly the configured offset from Earth, at any zoom and date", () => {
|
||||
const { distancePx, angleDeg } = mapStyle.childOffsets.moon!;
|
||||
for (const now of dates) {
|
||||
const scene = buildScene(samplePlayerState(), now);
|
||||
for (const zoom of [MIN_ZOOM, 1, 7, MAX_ZOOM]) {
|
||||
const layout = layoutScene(scene, view, cameraAt(zoom));
|
||||
const earth = layout.locations.find((l) => l.id === "earth")!;
|
||||
const moon = layout.locations.find((l) => l.id === "moon")!;
|
||||
// The angle is measured from the outward direction, Sun → Earth.
|
||||
const outward =
|
||||
(Math.atan2(earth.center.y - layout.sun.center.y, earth.center.x - layout.sun.center.x) *
|
||||
180) /
|
||||
Math.PI;
|
||||
const offset = polar(distancePx, outward + angleDeg);
|
||||
expect(moon.center.x - earth.center.x).toBeCloseTo(offset.x, 9);
|
||||
expect(moon.center.y - earth.center.y).toBeCloseTo(offset.y, 9);
|
||||
// And the two sprites never overlap.
|
||||
expect(Math.hypot(offset.x, offset.y)).toBeGreaterThanOrEqual((earth.size + moon.size) / 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("starts and ends Moon routes where the Moon is drawn", () => {
|
||||
const now = dates[0]!;
|
||||
const state = samplePlayerState({
|
||||
activeDispatches: [
|
||||
{
|
||||
id: "d",
|
||||
shipId: "loaner",
|
||||
pilotId: "avatar",
|
||||
contract: null,
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
tutorial: true,
|
||||
departureAt: now,
|
||||
arrivalAt: now + 60_000,
|
||||
distanceMillionKm: 0.4,
|
||||
fuelCost: 0,
|
||||
expectedPayout: 0,
|
||||
seed: 1,
|
||||
status: "in-flight",
|
||||
resolvedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const layout = layoutScene(buildScene(state, now + 60_000), view, cameraAt(1));
|
||||
const moon = layout.locations.find((l) => l.id === "moon")!;
|
||||
expect(layout.flights[0]!.to.x).toBeCloseTo(moon.center.x, 6);
|
||||
expect(layout.flights[0]!.to.y).toBeCloseTo(moon.center.y, 6);
|
||||
});
|
||||
|
||||
it("never makes a tap target smaller than the minimum", () => {
|
||||
const scene = buildScene(samplePlayerState(), dates[0]!);
|
||||
for (const zoom of [1e-6, MIN_ZOOM, 1, MAX_ZOOM]) {
|
||||
const layout = layoutScene(scene, view, cameraAt(zoom));
|
||||
for (const item of [layout.sun, ...layout.locations, ...layout.flights]) {
|
||||
expect(item.tap).toBeGreaterThanOrEqual(mapStyle.minTapPx);
|
||||
expect(item.tap).toBeGreaterThanOrEqual(item.size);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("labels and badges (Step 10C.6)", () => {
|
||||
const body = (id: string, x: number, y: number, size = 32) => ({
|
||||
id,
|
||||
center: { x, y },
|
||||
size,
|
||||
tap: 44,
|
||||
});
|
||||
const layout = (locations: ReturnType<typeof body>[]): SceneLayout => ({
|
||||
scale: 1,
|
||||
sun: { center: { x: -1000, y: -1000 }, size: 64, tap: 64 },
|
||||
locations,
|
||||
flights: [],
|
||||
});
|
||||
const size = () => ({ width: 60, height: 14 });
|
||||
|
||||
it("puts a label below its body when that is clear", () => {
|
||||
const placed = decorate(layout([body("a", 100, 100)]), size);
|
||||
expect(placed.get("a")!.label).toEqual({ x: 100, y: 120, anchorY: 0 });
|
||||
});
|
||||
|
||||
it("moves a label above its body when below would cover another body", () => {
|
||||
const placed = decorate(layout([body("a", 100, 100), body("b", 110, 140)]), size);
|
||||
expect(placed.get("a")!.label).toEqual({ x: 100, y: 80, anchorY: 1 });
|
||||
});
|
||||
|
||||
it("puts the idle badge on a diagonal clear of other bodies", () => {
|
||||
// The default spot (up-left) is taken by another body.
|
||||
const placed = decorate(layout([body("a", 100, 100), body("b", 70, 70)]), size);
|
||||
const badge = placed.get("a")!.badge;
|
||||
expect(Math.hypot(badge.x - 70, badge.y - 70)).toBeGreaterThan(24);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import type { Ship } from "@deadhead/schema";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { locationOverlays } from "../src/map/overlays";
|
||||
import { buildScene } from "../src/map/scene";
|
||||
import { mapStyle } from "../src/map/style";
|
||||
import { samplePlayerState } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
const idleAtMoon = (id: string): Ship => ({
|
||||
id,
|
||||
modelId: "courier",
|
||||
name: id,
|
||||
status: "idle",
|
||||
locationId: "moon",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
});
|
||||
|
||||
/** After the deal: an Earth bay, two idle ships at the Moon, Mars and beyond undiscovered. */
|
||||
const scene = () =>
|
||||
buildScene(
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
ships: [idleAtMoon("s1"), idleAtMoon("s2")],
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
|
||||
describe("location state overlays (Step 10C.7)", () => {
|
||||
it("desaturates, dims and locks undiscovered locations only", () => {
|
||||
const overlays = new Map(locationOverlays(scene()).map((o) => [o.id, o]));
|
||||
for (const id of ["mars", "ceres-station", "jupiter-cloud-platform"]) {
|
||||
expect(overlays.get(id), id).toMatchObject({
|
||||
locked: true,
|
||||
desaturate: true,
|
||||
alpha: mapStyle.undiscoveredAlpha,
|
||||
});
|
||||
}
|
||||
for (const id of ["earth", "moon"]) {
|
||||
expect(overlays.get(id), id).toMatchObject({ locked: false, desaturate: false, alpha: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks bay locations as HQs", () => {
|
||||
const hqs = locationOverlays(scene())
|
||||
.filter((o) => o.hq)
|
||||
.map((o) => o.id);
|
||||
expect(hqs).toEqual(["earth"]);
|
||||
});
|
||||
|
||||
it("shows two idle ships as one badge reading 2, and no single ship sprites", () => {
|
||||
const s = scene();
|
||||
const moon = locationOverlays(s).find((o) => o.id === "moon")!;
|
||||
expect(moon.idleBadge).toBe(2);
|
||||
expect(locationOverlays(s).find((o) => o.id === "earth")!.idleBadge).toBeNull();
|
||||
// Ships are only drawn for flights; idle ships have none.
|
||||
expect(s.flights).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -28,7 +28,7 @@ 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, "courier");
|
||||
const scene = flightMarker(flight, NOW);
|
||||
expect(scene.progress).toBe(0.5);
|
||||
|
||||
const a = toScreen(scene.from, viewport, camera);
|
||||
|
|
@ -43,10 +43,8 @@ describe("ship markers", () => {
|
|||
});
|
||||
|
||||
it("starts at the origin and ends at the destination", () => {
|
||||
expect(flightMarker(flight, flight.departureAt, "courier").marker).toEqual(
|
||||
flightMarker(flight, NOW, "courier").from,
|
||||
);
|
||||
const end = flightMarker(flight, flight.arrivalAt + 60_000, "courier");
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { hexSpiral, separateShips, shipRotation, type FlightLayout } from "../src/map/layout";
|
||||
import { mapStyle } from "../src/map/style";
|
||||
|
||||
const deg = (radians: number) => (radians * 180) / Math.PI;
|
||||
|
||||
describe("ships in flight (Step 10C.8)", () => {
|
||||
it("turns the up-pointing art to face the direction of travel", () => {
|
||||
expect(deg(shipRotation({ x: 0, y: 0 }, { x: 10, y: 0 }))).toBeCloseTo(90);
|
||||
expect(deg(shipRotation({ x: 0, y: 0 }, { x: 0, y: 10 }))).toBeCloseTo(180);
|
||||
expect(deg(shipRotation({ x: 0, y: 10 }, { x: 0, y: 0 }))).toBeCloseTo(0);
|
||||
expect(deg(shipRotation({ x: 10, y: 0 }, { x: 0, y: 0 }))).toBeCloseTo(270); // = -90°
|
||||
});
|
||||
|
||||
const flight = (dispatchId: string, x = 100, y = 100): FlightLayout => ({
|
||||
dispatchId,
|
||||
from: { x: 0, y: 100 },
|
||||
to: { x: 200, y: 100 },
|
||||
center: { x, y },
|
||||
rotation: Math.PI / 2,
|
||||
size: 24,
|
||||
tap: 44,
|
||||
});
|
||||
|
||||
it("spreads three ships at the same spot apart, at least a tap target apart", () => {
|
||||
const spread = separateShips([flight("a"), flight("b"), flight("c")]);
|
||||
const points = spread.map((f) => f.center);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
for (let j = i + 1; j < points.length; j++) {
|
||||
const d = Math.hypot(points[i]!.x - points[j]!.x, points[i]!.y - points[j]!.y);
|
||||
expect(d).toBeGreaterThanOrEqual(mapStyle.minTapPx);
|
||||
}
|
||||
}
|
||||
// Perpendicular to the (horizontal) route, centred on where they were.
|
||||
expect(points.map((p) => p.x)).toEqual([100, 100, 100]);
|
||||
expect(points.map((p) => p.y).reduce((a, b) => a + b) / 3).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("leaves ships that are already apart where they are", () => {
|
||||
const apart = [flight("a", 20), flight("b", 180)];
|
||||
expect(separateShips(apart)).toEqual(apart);
|
||||
});
|
||||
|
||||
it("packs a crowd of ten compactly, every pair a tap target apart", () => {
|
||||
const crowd = Array.from({ length: 10 }, (_, i) => flight(`s${i}`));
|
||||
const points = separateShips(crowd).map((f) => f.center);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
for (let j = i + 1; j < points.length; j++) {
|
||||
const d = Math.hypot(points[i]!.x - points[j]!.x, points[i]!.y - points[j]!.y);
|
||||
expect(d).toBeGreaterThanOrEqual(mapStyle.minTapPx - 1e-9);
|
||||
}
|
||||
// Within two hex rings of where they were.
|
||||
expect(Math.hypot(points[i]!.x - 100, points[i]!.y - 100)).toBeLessThanOrEqual(
|
||||
2 * mapStyle.shipSeparationPx + 1e-9,
|
||||
);
|
||||
}
|
||||
expect(hexSpiral(19, 10)).toHaveLength(19);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mapStyle, mapStyleSchema } from "../src/map/style";
|
||||
|
||||
describe("map style config (CR-003)", () => {
|
||||
it("validates", () => {
|
||||
expect(mapStyleSchema.safeParse(mapStyle).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-positive sizes, min scale above max, and parallax outside 0–1", () => {
|
||||
const bad = [
|
||||
{ ...mapStyle, shipSizePx: 0 },
|
||||
{ ...mapStyle, spriteSizePx: { ...mapStyle.spriteSizePx, sun: -1 } },
|
||||
{ ...mapStyle, minTapPx: 0 },
|
||||
{ ...mapStyle, spriteScale: { min: 2, max: 1, zoomExponent: 0.35 } },
|
||||
{ ...mapStyle, parallax: 1.5 },
|
||||
{ ...mapStyle, parallax: -0.1 },
|
||||
];
|
||||
for (const style of bad) expect(mapStyleSchema.safeParse(style).success).toBe(false);
|
||||
});
|
||||
|
||||
it("sizes every body sprite and keeps tap targets at least 44 px", () => {
|
||||
for (const id of [
|
||||
"sun",
|
||||
"body-earth",
|
||||
"body-moon",
|
||||
"body-mars",
|
||||
"body-ceres",
|
||||
"body-jupiter",
|
||||
]) {
|
||||
expect(mapStyle.spriteSizePx[id], id).toBeGreaterThan(0);
|
||||
}
|
||||
expect(mapStyle.minTapPx).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWhileVisible } from "../src/map/ticker";
|
||||
|
||||
const setVisibility = (state: DocumentVisibilityState) => {
|
||||
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
};
|
||||
|
||||
describe("map ticker lifecycle (Step 10C.9)", () => {
|
||||
afterEach(() => setVisibility("visible"));
|
||||
|
||||
it("pauses when the page is hidden and resumes when it is visible", () => {
|
||||
const ticker = { start: vi.fn(), stop: vi.fn() };
|
||||
const cleanup = runWhileVisible(ticker);
|
||||
expect(ticker.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("hidden");
|
||||
expect(ticker.stop).toHaveBeenCalledTimes(1);
|
||||
setVisibility("visible");
|
||||
expect(ticker.start).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("stops on unmount and ignores visibility changes afterwards", () => {
|
||||
const ticker = { start: vi.fn(), stop: vi.fn() };
|
||||
const cleanup = runWhileVisible(ticker);
|
||||
cleanup();
|
||||
expect(ticker.stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("hidden");
|
||||
setVisibility("visible");
|
||||
expect(ticker.start).toHaveBeenCalledTimes(1);
|
||||
expect(ticker.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -28,7 +28,6 @@ export default defineConfig({
|
|||
server: {
|
||||
// Same-domain API in development too (decision 0.5.20). The server listens on
|
||||
// IPv4 only, so target 127.0.0.1 rather than localhost.
|
||||
// API_PROXY_TARGET lets a second dev stack (e.g. screenshots) use another API port.
|
||||
proxy: { "/api": process.env.API_PROXY_TARGET ?? "http://127.0.0.1:3000" },
|
||||
proxy: { "/api": "http://127.0.0.1:3000" },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 766 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 762 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 756 KiB |
|
Before Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 768 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 766 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 762 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 766 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 707 KiB |
|
Before Width: | Height: | Size: 770 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
|
@ -452,12 +452,6 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
|||
| `src/routes.ts` | Screen list shared by router and navigation |
|
||||
| `src/map/art/manifest.json` | Map art manifest (CR-003): asset id → `{ kind, file }` in `public/assets/map/` (never edited); `null` file = missing, placeholder used |
|
||||
| `scripts/map-art.mjs` | Generates `public/assets/map-gen/` (`<id>@1x.png`, `<id>@2x.png`, `bg.webp`) from the manifest with sharp; output committed |
|
||||
| `src/map/style.ts` | Map presentation config (CR-003): sprite sizes, zoom scale limits, child-body offsets, badges, tap size, parallax, colors; validated by `mapStyleSchema` |
|
||||
| `src/map/art/load.ts`, `art/visuals.ts` | Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41) |
|
||||
| `src/map/layout.ts` | Pure screen layout: `backgroundRect`, `spriteScale`, `childOffset`, `layoutScene` (centres, sizes, tap targets; used by renderer and hit test), `decorate` (label and badge placement) |
|
||||
| `src/map/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer |
|
||||
| `src/map/ticker.ts` | `runWhileVisible`: pause the map's ticker while the page is hidden; stop it on unmount |
|
||||
| `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state |
|
||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
|
||||
|
|
|
|||
|
|
@ -160,7 +160,6 @@ Record these in `DECISIONS.md` and in `implementation-plan.md` §0.5.
|
|||
- The map is available from the first screen, including during the tutorial.
|
||||
- Undiscovered locations are drawn dimmed with a lock. Tapping one shows "Undiscovered — deliver here to unlock" and which ship models can currently reach it from Earth (using the sim range check over purchasable models), for example "Reachable with: Freighter, Bulk Hauler".
|
||||
- **Test:** Component test: undiscovered locations render as locked; the reachability list for Mars matches the sim range check for the mocked time; discovered locations render normally.
|
||||
- **Amended by CR-003:** the lock rendering is replaced by Step 10C.7 (desaturated, dimmed sprite with a code-drawn padlock); the tap panel is unchanged.
|
||||
|
||||
### Step 10A.17 — Client: something waiting on return
|
||||
- If goals became ready while the player was away, list them in the "while you were away" modal with a link to claim.
|
||||
|
|
|
|||
|
|
@ -470,12 +470,10 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 10.1 — PixiJS map component
|
||||
- Add a Map route with a PixiJS canvas showing the Sun and the five locations at their current positions. Use a compressed (non-linear) radial scale so inner and outer locations are both visible. Support pan and pinch-zoom.
|
||||
- **Test:** Unit test confirms the scale function is monotonic (farther in reality → farther on screen). Manual check: all five locations are visible on a phone-sized screen at default zoom; pinch-zoom works on a touch device.
|
||||
- **Amended by CR-003:** Bodies use sprites with a roughly constant screen size (zoom^0.35, clamped 0.75–1.75×) and the Moon at a fixed screen offset from Earth (Step 10C.6, decisions 0.5.38–0.5.39).
|
||||
|
||||
### Step 10.2 — Ships on the map
|
||||
- Draw in-flight ships as markers along their route lines, positioned with sim interpolation every frame, and idle ships as badges at their location.
|
||||
- **Test:** Unit test: marker screen position for a mocked dispatch at 50% progress lies on the route line between the two endpoints. Manual check: ships move smoothly.
|
||||
- **Amended by CR-003:** In-flight ships use their model's sprite rotated to face their direction of travel, nudged apart when crowded; idle ships are shown only as count badges (Steps 10C.7, 10C.8).
|
||||
|
||||
### Step 10.3 — Map interactions
|
||||
- Tapping a location opens a panel with its details and a link to its contract board (if a bay exists). Tapping a ship shows its route, pilot, and ETA.
|
||||
|
|
@ -483,7 +481,6 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
|
||||
---
|
||||
- **Amended by CR-002:** The location panel links every discovered location to "View contracts", with or without a bay (Step 10B.9).
|
||||
- **Amended by CR-003:** Every tap target is at least 44 px, hit-tested against the same screen layout the renderer draws (Step 10C.6).
|
||||
|
||||
## Phase 10A — Faster Start (CR-001)
|
||||
|
||||
|
|
@ -508,7 +505,6 @@ Steps 10C.1–10C.10 are specified in `memory-bank/change-requests/CR-003-map-ar
|
|||
### Step 11.1 — Installable PWA
|
||||
- Add `vite-plugin-pwa` with a manifest (name "Deadhead: Space Freight Tycoon", icons, theme color, standalone display) and a service worker that caches the app shell.
|
||||
- **Test:** A Lighthouse PWA audit in CI reports the app as installable. Manual check: the app installs to a phone home screen and launches standalone.
|
||||
- **Amended by CR-003:** The service worker precaches the generated map assets in `public/assets/map-gen/` (not the source art in `public/assets/map/`, which the build omits).
|
||||
|
||||
### Step 11.2 — Offline shell behavior
|
||||
- When offline, the app loads from cache and shows the last known state with a clear "Offline — showing last update" banner. In-flight progress bars keep advancing from cached dispatch records. Actions are disabled until back online.
|
||||
|
|
|
|||
|
|
@ -151,11 +151,11 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as
|
|||
|
||||
- [x] **Step 10C.1 — Inventory and asset manifest.** `apps/web/src/map/art/manifest.json` maps 12 asset ids to the 11 files in `apps/web/public/assets/map/` (all 1254×1254; sprites RGBA). `badge-cloud-platform` is listed with `file: null` (not provided). Content: `sprite` on every location and ship model, `badge` on Jupiter Cloud Platform. Tests: `art-manifest.test.ts`, content sprite-key tests.
|
||||
- [x] **Step 10C.2 — Asset optimization script.** `apps/web/scripts/map-art.mjs` (sharp; `pnpm --filter @deadhead/web map-art`, and first in `build` with `--if-available`) writes 21 files to `public/assets/map-gen/` (committed): bodies and Sun 256/512 px, ships 128/256 px (trimmed), `bg.webp` 1254 px / 106 KB. The Vite build drops the source art from `dist`. Test: `map-art.test.ts` (sizes, byte-identical reruns, pixels match the committed output, bg < 1 MB, transparent source corners).
|
||||
- [x] **Step 10C.3 — Map style config.** `apps/web/src/map/style.ts`: `mapStyleSchema` (Zod) and `mapStyle` with sprite sizes (Sun 64, Jupiter 48, Earth/Mars 32, Ceres 22, Moon 18, ships 24 px), sprite scale 0.75–1.75×, Moon offset 30 px at −45° from Earth, idle and location badge offsets, 44 px minimum tap, parallax 0.15, background margin 10%, label and colors. Test: `map-style.test.ts`.
|
||||
- [x] **Step 10C.4 — Asset loading and fallback.** `map/art/load.ts`: `artBundle(dpr)` (2x at DPR ≥ 2) registered once as PixiJS bundle `map-art`, each asset loaded separately so a failure only costs that asset (one warning, placeholder). `map/art/visuals.ts` picks texture or placeholder per item; the scene now carries sprite keys and each flight's ship model (loaner → Company Courier). Drawing moved from `MapCanvas` into `map/renderer.ts` (`MapRenderer`); the map shows "Loading map…" until art and renderer are ready. Test: `map-art-loading.test.ts`.
|
||||
- [x] **Step 10C.5 — Background.** `map/layout.ts` `backgroundRect`: cover viewport + 10% margin each side, zoom follow `zoom^0.05` (never below cover), pan × 0.15 parallax, clamped so no edge shows; drawn as one sprite under everything. The Map screen now fills the space down to the nav (it stopped 42 px short). Test: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c5-background-tutorial-{desktop,phone}{,-panned}.png`.
|
||||
- [x] **Step 10C.6 — Bodies, orbits, and labels.** `layoutScene` (map/layout.ts) gives every body, the Sun and each flight a screen centre, size (base × `spriteScale(zoom)` = zoom^0.35 clamped 0.75–1.75) and tap size (≥ 44 px); hit-testing uses it. The Moon is drawn 46 px from Earth at −30° from the Sun→Earth direction, with a faint ring around Earth; Moon routes start and end there. `decorate` places labels (bold, dark halo) below or above to avoid bodies and other labels, and idle badges on a clear diagonal. Jupiter's badge art is drawn on its edge when provided (not yet). Tests: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c6-bodies-{tutorial,midgame}-{desktop,phone}.png`.
|
||||
- [x] **Step 10C.7 — Location state overlays.** `map/overlays.ts` `locationOverlays(scene)` decides per location: undiscovered → desaturated (one shared `ColorMatrixFilter`), alpha 0.55 and a code-drawn padlock (replaces the 🔒 label of Step 10A.16; the tap panel is unchanged); bay → a small green house marker on the far side of the label; idle ships → count badge only. Test: `map-overlays.test.ts`. **Manual check:** `docs/screenshots/10c7-overlays-{tutorial,midgame}-{desktop,phone}.png`.
|
||||
- [x] **Step 10C.8 — Ships in flight.** Each flight draws its model's sprite rotated by `shipRotation` (art points up: right = 90°, down = 180°), positioned by sim progress every frame. Routes are faint end to end and brighter ahead of the ship. `separateShips` spreads ships closer than 44 px apart, perpendicular to their route and 44 px apart; hit-testing uses the same positions. The selected ship or location gets an amber ring (`MapCanvas` `selected` prop). Test: `map-ships.test.ts`. **Manual check:** `docs/screenshots/10c8-ships-midgame-{desktop,desktop-zoomed,phone}.png`.
|
||||
- [x] **Step 10C.9 — Performance.** `map/ticker.ts` `runWhileVisible`: the PixiJS ticker stops when the page is hidden, restarts when visible, and stops on unmount. Canvas resolution capped at 2×. Labels restyle only on change. Big ship crowds (> 3) pack on a hexagonal grid (`hexSpiral`) instead of a long line. Tests: `map-ticker.test.ts`, crowd test in `map-ships.test.ts`. **Measured** (headless Chrome, software WebGL, 10 ships in flight, panning): 58.5 fps at DPR 1 even with CPU throttled 4×; 11 fps at DPR 3 before the 2× cap, 22 fps after — fill-rate bound in software rendering, not JavaScript. **Manual check for the user:** pan and zoom on a real mid-range phone (GPU rendering). Screenshots: `docs/screenshots/10c9-busy-busy-{desktop,desktop-zoomed,phone}.png`.
|
||||
- [x] **Step 10C.10 — Plan amendments and regression.** CR-003 §6 amendments added to plan steps 10.1, 10.2, 10.3 and 11.1, and to CR-001 Step 10A.16 (§0.5.37–41 were added with the CR). Full suite (629 tests), coverage, lint and web build green. **Review screenshots:** `docs/screenshots/10c10-final-{tutorial,midgame}-{desktop,phone}.png` (the tutorial map also shows the undiscovered locations), plus the per-step shots from 10C.5–10C.9. **Phase 10C complete — stopped for review.**
|
||||
- [ ] Step 10C.3 — Map style config
|
||||
- [ ] Step 10C.4 — Asset loading and fallback
|
||||
- [ ] Step 10C.5 — Background
|
||||
- [ ] Step 10C.6 — Bodies, orbits, and labels
|
||||
- [ ] Step 10C.7 — Location state overlays
|
||||
- [ ] Step 10C.8 — Ships in flight
|
||||
- [ ] Step 10C.9 — Performance
|
||||
- [ ] Step 10C.10 — Plan amendments and regression
|
||||
|
|
|
|||