From edc7925d0e9bf4a33ae5e6e10be16cd910101884 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 19:17:34 -0400 Subject: [PATCH] Step 10C.5: parallax map background Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 +++ apps/web/src/map/layout.ts | 43 ++++++++++++++ apps/web/src/map/renderer.ts | 12 ++++ apps/web/src/screens/MapScreen.tsx | 4 +- apps/web/test/map-layout.test.ts | 56 ++++++++++++++++++ apps/web/vite.config.ts | 3 +- ...0c5-background-tutorial-desktop-panned.png | Bin 0 -> 773727 bytes .../10c5-background-tutorial-desktop.png | Bin 0 -> 782449 bytes .../10c5-background-tutorial-phone-panned.png | Bin 0 -> 1483904 bytes .../10c5-background-tutorial-phone.png | Bin 0 -> 1548649 bytes memory-bank/progress.md | 2 +- 11 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/map/layout.ts create mode 100644 apps/web/test/map-layout.test.ts create mode 100644 docs/screenshots/10c5-background-tutorial-desktop-panned.png create mode 100644 docs/screenshots/10c5-background-tutorial-desktop.png create mode 100644 docs/screenshots/10c5-background-tutorial-phone-panned.png create mode 100644 docs/screenshots/10c5-background-tutorial-phone.png diff --git a/DECISIONS.md b/DECISIONS.md index acdbf23..fbea94e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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). - **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. diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts new file mode 100644 index 0000000..b41958e --- /dev/null +++ b/apps/web/src/map/layout.ts @@ -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, + }; +} diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts index ef2f9d7..d18a0ad 100644 --- a/apps/web/src/map/renderer.ts +++ b/apps/web/src/map/renderer.ts @@ -2,6 +2,7 @@ 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 { backgroundRect } from "./layout"; import type { MapPoint } from "./projection"; import type { Scene } from "./scene"; import { mapStyle } from "./style"; @@ -25,6 +26,8 @@ function fit(sprite: Sprite, size: number) { * Graphics layers that are 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(); @@ -39,6 +42,9 @@ export class MapRenderer { stage: Container, private readonly textures: ArtTextures, ) { + 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); } @@ -74,6 +80,12 @@ export class MapRenderer { 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(); diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index 989c254..b09b698 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -26,7 +26,9 @@ export function MapScreen() { const [selection, setSelection] = useState(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. +

Map