Step 10C.5: parallax map background

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 19:17:34 -04:00
parent db69e0b2b1
commit edc7925d0e
11 changed files with 124 additions and 3 deletions

View File

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

View File

@ -0,0 +1,43 @@
import type { Camera, Viewport } from "./camera";
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,
};
}

View File

@ -2,6 +2,7 @@ import { Container, Graphics, Sprite, Text, type Texture } from "pixi.js";
import type { ArtTextures } from "./art/load"; import type { ArtTextures } from "./art/load";
import { sceneVisuals, type Visual } from "./art/visuals"; import { sceneVisuals, type Visual } from "./art/visuals";
import { toScreen, type Camera, type Viewport } from "./camera"; import { toScreen, type Camera, type Viewport } from "./camera";
import { backgroundRect } from "./layout";
import type { MapPoint } from "./projection"; import type { MapPoint } from "./projection";
import type { Scene } from "./scene"; import type { Scene } from "./scene";
import { mapStyle } from "./style"; import { mapStyle } from "./style";
@ -25,6 +26,8 @@ function fit(sprite: Sprite, size: number) {
* Graphics layers that are redrawn each frame. * Graphics layers that are redrawn each frame.
*/ */
export class MapRenderer { 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 orbits = new Graphics();
private readonly routes = new Graphics(); private readonly routes = new Graphics();
private readonly bodies = new Container(); private readonly bodies = new Container();
@ -39,6 +42,9 @@ export class MapRenderer {
stage: Container, stage: Container,
private readonly textures: ArtTextures<Texture>, private readonly textures: ArtTextures<Texture>,
) { ) {
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); stage.addChild(this.orbits, this.routes, this.bodies, this.ships, this.overlay, this.texts);
} }
@ -74,6 +80,12 @@ export class MapRenderer {
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x; const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
for (const sprite of this.sprites.values()) sprite.visible = false; 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.orbits.clear();
this.routes.clear(); this.routes.clear();
this.overlay.clear(); this.overlay.clear();

View File

@ -26,7 +26,9 @@ export function MapScreen() {
const [selection, setSelection] = useState<MapSelection | null>(null); const [selection, setSelection] = useState<MapSelection | null>(null);
return ( return (
<section className="relative h-[calc(100dvh-7.5rem)]"> // 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))]">
<h1 className="sr-only">Map</h1> <h1 className="sr-only">Map</h1>
<MapCanvas state={state} onSelect={setSelection} /> <MapCanvas state={state} onSelect={setSelection} />
<button <button

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera";
import { backgroundRect } from "../src/map/layout";
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);
});
});

View File

@ -28,6 +28,7 @@ export default defineConfig({
server: { server: {
// Same-domain API in development too (decision 0.5.20). The server listens on // 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. // IPv4 only, so target 127.0.0.1 rather than localhost.
proxy: { "/api": "http://127.0.0.1:3000" }, // 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" },
}, },
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -153,7 +153,7 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as
- [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.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.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.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 - [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`.
- [ ] Step 10C.6 — Bodies, orbits, and labels - [ ] Step 10C.6 — Bodies, orbits, and labels
- [ ] Step 10C.7 — Location state overlays - [ ] Step 10C.7 — Location state overlays
- [ ] Step 10C.8 — Ships in flight - [ ] Step 10C.8 — Ships in flight