Step 10C.4: load map art with per-asset fallback

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 19:12:09 -04:00
parent 5612788c94
commit db69e0b2b1
10 changed files with 463 additions and 97 deletions

View File

@ -702,3 +702,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.

View File

@ -1,25 +1,18 @@
import type { PlayerState } from "@deadhead/schema";
import { Application, Graphics, Text } from "pixi.js";
import { useEffect, useRef } from "react";
import { Application, Assets, type Texture } from "pixi.js";
import { useEffect, useRef, useState } from "react";
import { serverNow } from "../clock/clock";
import { pan, toScreen, useCamera, zoomAt, type Viewport } from "./camera";
import { loadArt } from "./art/load";
import { pan, useCamera, zoomAt, type Viewport } from "./camera";
import { hitTest, type MapSelection } from "./hitTest";
import { MapRenderer } from "./renderer";
import { buildScene } from "./scene";
const COLORS = {
background: 0x020617,
orbit: 0x1e293b,
sun: 0xfbbf24,
discovered: 0xe2e8f0,
undiscovered: 0x475569,
bay: 0x34d399,
route: 0x38bdf8,
ship: 0xfbbf24,
};
import { mapStyle } from "./style";
/**
* 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.
* 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.
*/
export function MapCanvas({
state,
@ -34,103 +27,51 @@ export function MapCanvas({
stateRef.current = state;
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const [loading, setLoading] = useState(true);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let cancelled = false;
const app = new Application();
const graphics = new Graphics();
const labels = new Map<string, Text>();
const badges = new Map<string, Text>();
const viewport = (): Viewport => ({
width: container.clientWidth,
height: container.clientHeight,
});
const draw = () => {
const scene = buildScene(stateRef.current, serverNow());
const camera = useCamera.getState();
const view = viewport();
const sun = toScreen({ x: 0, y: 0 }, view, camera);
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
graphics.clear();
for (const location of scene.locations) {
if (location.orbitRadius !== null) {
graphics
.circle(sun.x, sun.y, location.orbitRadius * unit)
.stroke({ width: 1, color: COLORS.orbit });
}
}
graphics.circle(sun.x, sun.y, 7).fill(COLORS.sun);
for (const location of scene.locations) {
const p = toScreen(location.point, view, camera);
const color = location.discovered ? COLORS.discovered : COLORS.undiscovered;
graphics.circle(p.x, p.y, 5).fill(color);
if (location.hasBay) graphics.circle(p.x, p.y, 9).stroke({ width: 2, color: COLORS.bay });
let label = labels.get(location.id);
if (!label) {
label = new Text({ text: location.name, style: { fontSize: 12, fill: color } });
labels.set(location.id, label);
app.stage.addChild(label);
}
// 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({
// 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({
resizeTo: container,
background: COLORS.background,
background: mapStyle.colors.background,
antialias: true,
autoDensity: true,
resolution: window.devicePixelRatio,
})
.then(() => {
}),
art,
])
.then(([, textures]) => {
if (cancelled) {
app.destroy(true);
return;
}
container.appendChild(app.canvas);
app.stage.addChild(graphics);
app.ticker.add(draw);
const renderer = new MapRenderer(app.stage, textures);
app.ticker.add(() =>
renderer.draw(
buildScene(stateRef.current, serverNow()),
viewport(),
useCamera.getState(),
),
);
})
.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
@ -201,6 +142,12 @@ 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>
);
}

View File

@ -0,0 +1,61 @@
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;
}

View File

@ -0,0 +1,36 @@
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)])),
};
}

View File

@ -0,0 +1,163 @@
import { 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 type { MapPoint } from "./projection";
import type { Scene } from "./scene";
import { mapStyle } from "./style";
const { colors } = mapStyle;
/** Base on-screen size (long side, px) of a sprite asset. */
function baseSize(assetId: string): number {
return mapStyle.spriteSizePx[assetId] ?? mapStyle.shipSizePx;
}
/** 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));
}
/**
* Draws the map with PixiJS (Steps 10.1–10.2, CR-003). Sprites and text are
* created once per item and updated every frame; code-drawn shapes go in a few
* Graphics layers that are redrawn each frame.
*/
export class MapRenderer {
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>();
constructor(
stage: Container,
private readonly textures: ArtTextures<Texture>,
) {
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;
}
private place(
key: string,
visual: Visual<Texture>,
layer: Container,
at: MapPoint,
size: number,
placeholder: () => void,
) {
if (visual.kind === "placeholder") return placeholder();
const sprite = this.sprite(key, visual.texture, layer);
fit(sprite, size);
sprite.position.set(at.x, at.y);
}
draw(scene: Scene, view: Viewport, camera: Camera) {
const visuals = sceneVisuals(scene, this.textures);
const sun = toScreen({ x: 0, y: 0 }, view, camera);
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
for (const sprite of this.sprites.values()) sprite.visible = false;
this.orbits.clear();
this.routes.clear();
this.overlay.clear();
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 });
}
}
this.place("sun", visuals.sun, this.bodies, sun, baseSize("sun"), () =>
this.overlay.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.placeholderBody : colors.labelUndiscovered;
const size = baseSize(location.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),
);
if (location.hasBay) {
this.overlay.circle(p.x, p.y, size / 2 + 4).stroke({ width: 2, color: colors.hq });
}
let label = this.labels.get(location.id);
if (!label) {
label = new Text({ text: location.name, style: { fontSize: 12, fill: color } });
this.labels.set(location.id, label);
this.texts.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 = location.discovered ? colors.label : colors.labelUndiscovered;
label.position.set(p.x + size / 2 + 4, p.y - 7);
// Idle ships here: a small badge with the count (Step 10.2).
let badge = this.badges.get(location.id);
if (!badge) {
badge = new Text({ text: "", style: { fontSize: 10, fill: colors.badgeText } });
badge.anchor.set(0.5);
this.badges.set(location.id, badge);
this.texts.addChild(badge);
}
badge.visible = location.idleShips > 0;
if (badge.visible) {
const b = { x: p.x - size / 2 - 4, y: p.y + size / 2 + 4 };
this.overlay.circle(b.x, b.y, 7).fill(colors.badgeFill);
badge.text = String(location.idleShips);
badge.position.set(b.x, b.y);
}
}
// In-flight ships: the route line and the ship on 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);
this.routes
.moveTo(from.x, from.y)
.lineTo(to.x, to.y)
.stroke({ width: 1, color: colors.routeBehind });
this.place(
`ship:${flight.dispatchId}`,
visuals.flights.get(flight.dispatchId)!,
this.ships,
marker,
mapStyle.shipSizePx,
() => this.overlay.circle(marker.x, marker.y, 4).fill(colors.placeholderShip),
);
}
// 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);
}
}
}
}

View File

@ -1,6 +1,6 @@
import { content } from "@deadhead/content";
import type { Dispatch, PlayerState } from "@deadhead/schema";
import { bodyPosition, dispatchProgress, realToGame } from "@deadhead/sim";
import { bodyPosition, dispatchProgress, loanerModel, realToGame } from "@deadhead/sim";
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
export interface SceneLocation {
@ -13,6 +13,9 @@ export interface SceneLocation {
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 {
@ -25,6 +28,9 @@ export interface SceneFlight {
/** 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 {
@ -43,7 +49,7 @@ function location(id: string) {
* straight route line by the sim's progress, in map space, so it always stays
* on the line however the map is scaled.
*/
export function flightMarker(dispatch: Dispatch, now: number): SceneFlight {
export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight {
const from = locationMapPoint(
location(dispatch.originId),
realToGame(dispatch.departureAt, content.config),
@ -60,6 +66,8 @@ export function flightMarker(dispatch: Dispatch, now: number): SceneFlight {
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}`,
};
}
@ -83,8 +91,18 @@ export function buildScene(state: PlayerState, now: number): Scene {
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)),
flights: state.activeDispatches.map((d) =>
flightMarker(
d,
now,
d.tutorial
? loanerModel(content).id
: (state.ships.find((s) => s.id === d.shipId)?.modelId ?? "courier"),
),
),
};
}

View File

@ -0,0 +1,129 @@
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");
});
});

View File

@ -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);
const scene = flightMarker(flight, NOW, "courier");
expect(scene.progress).toBe(0.5);
const a = toScreen(scene.from, viewport, camera);
@ -43,8 +43,10 @@ describe("ship markers", () => {
});
it("starts at the origin and ends at the destination", () => {
expect(flightMarker(flight, flight.departureAt).marker).toEqual(flightMarker(flight, NOW).from);
const end = flightMarker(flight, flight.arrivalAt + 60_000);
expect(flightMarker(flight, flight.departureAt, "courier").marker).toEqual(
flightMarker(flight, NOW, "courier").from,
);
const end = flightMarker(flight, flight.arrivalAt + 60_000, "courier");
expect(end.marker.x).toBeCloseTo(end.to.x, 12);
expect(end.marker.y).toBeCloseTo(end.to.y, 12);
});

View File

@ -453,6 +453,8 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `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/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) |

View File

@ -152,7 +152,7 @@ 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`.
- [ ] Step 10C.4 — Asset loading and fallback
- [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`.
- [ ] Step 10C.5 — Background
- [ ] Step 10C.6 — Bodies, orbits, and labels
- [ ] Step 10C.7 — Location state overlays