diff --git a/DECISIONS.md b/DECISIONS.md index 0226414..acdbf23 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/apps/web/src/map/MapCanvas.tsx b/apps/web/src/map/MapCanvas.tsx index 5162e5d..c5ea606 100644 --- a/apps/web/src/map/MapCanvas.tsx +++ b/apps/web/src/map/MapCanvas.tsx @@ -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(); - const badges = new Map(); 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(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 && ( +

+ Loading map… +

+ )} + ); } diff --git a/apps/web/src/map/art/load.ts b/apps/web/src/map/art/load.ts new file mode 100644 index 0000000..2848b7d --- /dev/null +++ b/apps/web/src/map/art/load.ts @@ -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 { + const density = devicePixelRatio >= 2 ? "2x" : "1x"; + const bundle: Record = {}; + 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 { + addBundle(name: string, assets: Record): void; + load(alias: string): Promise; +} + +/** Loaded textures by asset id. An id that is absent draws its placeholder (0.5.41). */ +export type ArtTextures = ReadonlyMap; + +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( + loader: AssetLoader, + devicePixelRatio: number, + warn: (message: string) => void = console.warn, +): Promise> { + 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(); + 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; +} diff --git a/apps/web/src/map/art/visuals.ts b/apps/web/src/map/art/visuals.ts new file mode 100644 index 0000000..ca0c563 --- /dev/null +++ b/apps/web/src/map/art/visuals.ts @@ -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 = { kind: "sprite"; texture: T } | { kind: "placeholder" }; + +export function visualFor(assetId: string | null, textures: ArtTextures): Visual { + const texture = assetId === null ? undefined : textures.get(assetId); + return texture === undefined ? { kind: "placeholder" } : { kind: "sprite", texture }; +} + +export interface SceneVisuals { + background: Visual; + sun: Visual; + /** By location id. */ + locations: Map>; + /** Location badge art (e.g. Jupiter's platform), by location id; absent if none. */ + badges: Map>; + /** By dispatch id. */ + flights: Map>; +} + +/** Picks a texture or placeholder for everything in the scene. */ +export function sceneVisuals(scene: Scene, textures: ArtTextures): SceneVisuals { + 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)])), + }; +} diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts new file mode 100644 index 0000000..ef2f9d7 --- /dev/null +++ b/apps/web/src/map/renderer.ts @@ -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(); + private readonly labels = new Map(); + private readonly badges = new Map(); + + constructor( + stage: Container, + private readonly textures: ArtTextures, + ) { + 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, + 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); + } + } + } +} diff --git a/apps/web/src/map/scene.ts b/apps/web/src/map/scene.ts index 725b9ea..c0a4c7a 100644 --- a/apps/web/src/map/scene.ts +++ b/apps/web/src/map/scene.ts @@ -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"), + ), + ), }; } diff --git a/apps/web/test/map-art-loading.test.ts b/apps/web/test/map-art-loading.test.ts new file mode 100644 index 0000000..4d56dcd --- /dev/null +++ b/apps/web/test/map-art-loading.test.ts @@ -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:" unless it is in `failing`. */ +function fakeLoader(failing: string[] = []) { + const loader = { + bundles: [] as Record[], + addBundle: vi.fn((_name: string, assets: Record) => { + 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; +} + +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"); + }); +}); diff --git a/apps/web/test/map-scene.test.ts b/apps/web/test/map-scene.test.ts index 0afbe9c..fe66b87 100644 --- a/apps/web/test/map-scene.test.ts +++ b/apps/web/test/map-scene.test.ts @@ -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); }); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index c9816d2..81985c1 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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/` (`@1x.png`, `@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) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 80190b4..e18d651 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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