diff --git a/DECISIONS.md b/DECISIONS.md index fcf0c83..b783929 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1526,3 +1526,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The manifest declares how many rocks the sheet holds** (`pieces: 6`), and the script checks it: the loader needs to know the file names without reading the folder, and a changed sheet then fails loudly instead of silently loading the wrong set. - **Slicing uses alpha ≥ 16 and drops everything else around a rock**: the user's rocks have a soft glow that reaches toward their neighbours; below 16/255 it is invisible on the map, and keeping it would put slivers of other rocks' glow in each sprite. - **Rocks are exported at 64 and 128 px** (the CR's sizes), named `belt-rocks-@1x/@2x`, in reading order of the sheet. + +## Step 16.6 + +- **The haze mesh lives in map units**: the map's screen transform is a uniform scale plus an offset, so the ring is built once and the camera only moves and scales it, instead of rebuilding 512 vertices every frame. +- **The generated strip is seeded and small (1024 × 64)**: it is stretched around the ring, so detail beyond that is invisible, and a seed keeps the belt identical between visits. +- **Review screenshots now come from a Playwright script against the test build** (reach the deal, discover everything with the developer tool, open the map at 1280 × 800 and 390 × 844), since the server-era setup that took the earlier ones is gone. diff --git a/apps/web/src/map/belt.ts b/apps/web/src/map/belt.ts new file mode 100644 index 0000000..fa3f7ff --- /dev/null +++ b/apps/web/src/map/belt.ts @@ -0,0 +1,132 @@ +import { createRng } from "@deadhead/sim"; +import { pixelsPerUnit, toScreen, type Camera, type Viewport } from "./camera"; +import { radialScale, type MapPoint } from "./projection"; +import { mapStyle, type MapStyle } from "./style"; + +/** + * The asteroid belt (CR-016 §5, decision 0.5.104): a static band between two + * radii from the Sun, on the map's compressed radial scale. Visual only. + */ + +/** The belt's inner and outer edges in map units (the map's compressed scale). */ +export function beltMapRadii(style: MapStyle = mapStyle): { inner: number; outer: number } { + return { + inner: radialScale(style.belt.innerMillionKm), + outer: radialScale(style.belt.outerMillionKm), + }; +} + +/** Where the belt is on screen: its centre (the Sun) and edge radii in px. */ +export function beltScreenRadii( + view: Viewport, + camera: Camera, + style: MapStyle = mapStyle, +): { center: MapPoint; inner: number; outer: number } { + const k = pixelsPerUnit(view, camera); + const { inner, outer } = beltMapRadii(style); + return { center: toScreen({ x: 0, y: 0 }, view, camera), inner: inner * k, outer: outer * k }; +} + +export interface RingGeometry { + /** x, y per vertex, in the radii's units, centred on 0. */ + positions: Float32Array; + /** u around the ring (0 → 1), v from the inner edge (0) to the outer edge (1). */ + uvs: Float32Array; + indices: Uint32Array; +} + +/** + * A ring of `segments` quads between two radii, with a horizontal strip + * texture wrapped around it: the strip's width runs once around the circle and + * its height from the inner edge to the outer edge (CR-016 §5). + */ +export function ringGeometry(inner: number, outer: number, segments: number): RingGeometry { + const columns = segments + 1; + const positions = new Float32Array(columns * 2 * 2); + const uvs = new Float32Array(columns * 2 * 2); + for (let i = 0; i < columns; i++) { + const u = i / segments; + const angle = u * Math.PI * 2; + const [cos, sin] = [Math.cos(angle), Math.sin(angle)]; + // Two vertices per column: inner (v = 0), then outer (v = 1). + positions.set([cos * inner, sin * inner, cos * outer, sin * outer], i * 4); + uvs.set([u, 0, u, 1], i * 4); + } + const indices = new Uint32Array(segments * 6); + for (let i = 0; i < segments; i++) { + const [a, b, c, d] = [i * 2, i * 2 + 1, i * 2 + 2, i * 2 + 3]; + indices.set([a, b, c, b, d, c], i * 6); + } + return { positions, uvs, indices }; +} + +/** + * The haze strip drawn in code until the user provides `belt-haze.png` (CR-016 + * §5): a faint warm-grey band, fading to transparent at the top and bottom, + * with soft noise and thousands of one- or two-pixel specks for distant + * asteroids. Seamless left to right, so it wraps into a ring. RGBA, row-major. + */ +export function hazeStripPixels(width: number, height: number, seed: number): Uint8Array { + const pixels = new Uint8Array(width * height * 4); + const rng = createRng(seed); + const [r, g, b] = [196, 184, 168]; + // Noise built from whole waves around the strip, so the left and right edges meet. + const waves = Array.from({ length: 6 }, (_, i) => ({ + cycles: [3, 5, 8, 13, 21, 34][i]!, + phase: rng.next() * Math.PI * 2, + amplitude: 1 / (i + 2), + })); + const noise = (x: number) => + waves.reduce( + (sum, w) => sum + w.amplitude * Math.sin((x / width) * w.cycles * Math.PI * 2 + w.phase), + 0, + ); + for (let y = 0; y < height; y++) { + // Densest along the middle, fully transparent at the top and bottom rows. + const band = Math.sin((Math.PI * (y + 0.5)) / height) ** 2; + for (let x = 0; x < width; x++) { + const alpha = band * (0.55 + 0.2 * noise(x)) * 255; + const i = (y * width + x) * 4; + pixels.set([r, g, b, Math.max(0, Math.min(255, Math.round(alpha)))], i); + } + } + // Tiny distant asteroids: brighter specks, fewer toward the edges. + const specks = Math.round((width * height) / 40); + for (let n = 0; n < specks; n++) { + const x = Math.floor(rng.next() * width); + const y = Math.floor(rng.next() * height); + const band = Math.sin((Math.PI * (y + 0.5)) / height) ** 2; + if (rng.next() > band) continue; + const size = rng.next() < 0.25 ? 2 : 1; + const light = 150 + Math.floor(rng.next() * 90); + for (let dy = 0; dy < size; dy++) { + for (let dx = 0; dx < size; dx++) { + const px = (x + dx) % width; + const py = Math.min(height - 1, y + dy); + pixels.set([light, light - 8, light - 20, 255], (py * width + px) * 4); + } + } + } + return pixels; +} + +/** + * Where the haze texture comes from (CR-016 §5): the user's `belt-haze.png` + * when it loaded, otherwise the strip drawn in code from the map style. + */ +export function hazeSource( + textures: ReadonlyMap, + style: MapStyle = mapStyle, +): + | { kind: "file"; texture: T } + | { kind: "generated"; width: number; height: number; pixels: Uint8Array } { + const provided = textures.get("belt-haze"); + if (provided) return { kind: "file", texture: provided }; + const { width, height } = style.belt.hazeStrip; + return { + kind: "generated", + width, + height, + pixels: hazeStripPixels(width, height, style.belt.seed), + }; +} diff --git a/apps/web/src/map/camera.ts b/apps/web/src/map/camera.ts index f9ede55..3c3ba14 100644 --- a/apps/web/src/map/camera.ts +++ b/apps/web/src/map/camera.ts @@ -45,7 +45,7 @@ export function resetMapView(): void { } /** Pixels per map unit at this zoom. */ -function pixelsPerUnit(viewport: Viewport, camera: Camera): number { +export function pixelsPerUnit(viewport: Viewport, camera: Camera): number { return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom; } diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts index 09ca9a0..0f9f6ed 100644 --- a/apps/web/src/map/renderer.ts +++ b/apps/web/src/map/renderer.ts @@ -1,7 +1,17 @@ -import { ColorMatrixFilter, Container, Graphics, Sprite, Text, type Texture } from "pixi.js"; +import { + BufferImageSource, + ColorMatrixFilter, + Container, + Graphics, + MeshSimple, + Sprite, + Text, + Texture, +} from "pixi.js"; +import { beltMapRadii, hazeSource, ringGeometry } from "./belt"; import type { ArtTextures } from "./art/load"; import { sceneVisuals, type Visual } from "./art/visuals"; -import { toScreen, type Camera, type Viewport } from "./camera"; +import { pixelsPerUnit, toScreen, type Camera, type Viewport } from "./camera"; import { reducedMotionOn, usePreferences } from "../state/preferences"; import { backgroundRect, decorate, layoutScene, mapMotionStyle, polar } from "./layout"; import type { MapSelection } from "./hitTest"; @@ -56,6 +66,33 @@ function drawHq(g: Graphics, at: MapPoint, size: number) { .stroke({ width: 1, color: colors.labelHalo }); } +/** + * The haze texture (CR-016 §5): the user's `belt-haze.png` when it is there, + * otherwise the strip drawn in code. Either way, no warning beyond the art + * loader's own for a missing file. + */ +function hazeTexture(textures: ArtTextures): Texture { + const source = hazeSource(textures); + if (source.kind === "file") return source.texture; + const { pixels: resource, width, height } = source; + return new Texture({ source: new BufferImageSource({ resource, width, height }) }); +} + +/** The haze ring mesh, built once in map units; not interactive (taps pass through). */ +function beltHaze(textures: ArtTextures): MeshSimple { + const { inner, outer } = beltMapRadii(); + const ring = ringGeometry(inner, outer, mapStyle.belt.segments); + const mesh = new MeshSimple({ + texture: hazeTexture(textures), + vertices: ring.positions, + uvs: ring.uvs, + indices: ring.indices, + }); + mesh.alpha = mapStyle.belt.hazeAlpha; + mesh.eventMode = "none"; + return mesh; +} + /** * 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 @@ -65,6 +102,8 @@ 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(); + /** The asteroid belt's haze (CR-016 §5): one mesh in map units, moved and scaled each frame. */ + private readonly haze: MeshSimple; private readonly routes = new Graphics(); private readonly bodies = new Container(); private readonly ships = new Container(); @@ -84,7 +123,16 @@ export class MapRenderer { 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); + this.haze = beltHaze(textures); + stage.addChild( + this.orbits, + this.haze, + this.routes, + this.bodies, + this.ships, + this.overlay, + this.texts, + ); } /** A sprite for `key`, created on first use; hidden until positioned this frame. */ @@ -143,6 +191,10 @@ export class MapRenderer { this.background.setSize(r.width, r.height); } + // The belt is fixed in map units; only the camera moves it (decision 0.5.104). + this.haze.position.set(sun.x, sun.y); + this.haze.scale.set(pixelsPerUnit(view, camera)); + this.orbits.clear(); this.routes.clear(); this.overlay.clear(); diff --git a/apps/web/src/map/style.ts b/apps/web/src/map/style.ts index db13fb3..594c674 100644 --- a/apps/web/src/map/style.ts +++ b/apps/web/src/map/style.ts @@ -49,6 +49,25 @@ export const mapStyleSchema = z 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, + /** + * The asteroid belt (CR-016 §5, decision 0.5.104): a band between two real + * distances from the Sun, drawn on the map's compressed scale. Visual only. + */ + belt: z.object({ + innerMillionKm: px, + outerMillionKm: px, + /** Opacity of the haze ring under the rocks. */ + hazeAlpha: z.number().min(0).max(1), + /** Quads around the ring: enough that its edges read as circles. */ + segments: z.number().int().min(16), + /** Size of the haze strip drawn in code when `belt-haze.png` is missing (px). */ + hazeStrip: z.object({ + width: z.number().int().positive(), + height: z.number().int().positive(), + }), + /** Seed for the generated haze, so it looks the same every time. */ + seed: z.number().int().nonnegative(), + }), colors: z.object({ background: color, orbit: color, @@ -68,6 +87,10 @@ export const mapStyleSchema = z sun: color, }), }) + .refine((s) => s.belt.innerMillionKm < s.belt.outerMillionKm, { + message: "belt.innerMillionKm must be inside belt.outerMillionKm", + path: ["belt"], + }) .refine((s) => s.spriteScale.min <= s.spriteScale.max, { message: "spriteScale.min must not exceed spriteScale.max", path: ["spriteScale"], @@ -98,6 +121,15 @@ export const mapStyle: MapStyle = mapStyleSchema.parse({ backgroundZoomFollow: 0.05, label: { fontSizePx: 11, gapPx: 4, haloPx: 3 }, shipSeparationPx: 44, + // The real main belt, between Mars and Jupiter, with Ceres inside it (CR-016 §5). + belt: { + innerMillionKm: 314, + outerMillionKm: 494, + hazeAlpha: 0.35, + segments: 256, + hazeStrip: { width: 1024, height: 64 }, + seed: 20_260_101, + }, colors: { background: 0x020617, orbit: 0x94a3b8, diff --git a/apps/web/test/belt-haze.test.ts b/apps/web/test/belt-haze.test.ts new file mode 100644 index 0000000..feddea4 --- /dev/null +++ b/apps/web/test/belt-haze.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { + beltScreenRadii, + hazeSource, + hazeStripPixels, + ringGeometry, + beltMapRadii, +} from "../src/map/belt"; +import { MAX_ZOOM, MIN_ZOOM, toScreen } from "../src/map/camera"; +import { radialScale } from "../src/map/projection"; +import { mapStyle } from "../src/map/style"; + +const view = { width: 1280, height: 720 }; + +/** Screen distance from the Sun of a point `millionKm` out on the compressed scale. */ +function screenRadius(millionKm: number, camera: { zoom: number; panX: number; panY: number }) { + const sun = toScreen({ x: 0, y: 0 }, view, camera); + const edge = toScreen({ x: radialScale(millionKm), y: 0 }, view, camera); + return Math.hypot(edge.x - sun.x, edge.y - sun.y); +} + +describe("the belt's haze (Step 16.6, decision 0.5.104)", () => { + it("sits between the compressed-scale screen radii of 314M and 494M km at any zoom", () => { + for (const zoom of [MIN_ZOOM, 0.75, 1, 3, MAX_ZOOM]) { + const camera = { zoom, panX: 40, panY: -25 }; + const { center, inner, outer } = beltScreenRadii(view, camera); + expect(center).toEqual(toScreen({ x: 0, y: 0 }, view, camera)); + expect(inner).toBeCloseTo(screenRadius(314, camera), 6); + expect(outer).toBeCloseTo(screenRadius(494, camera), 6); + } + }); + + it("builds the ring mesh exactly on those radii, with the strip wrapped once around", () => { + const { inner, outer } = beltMapRadii(); + const ring = ringGeometry(inner, outer, 64); + for (let v = 0; v < ring.positions.length / 2; v++) { + const r = Math.hypot(ring.positions[v * 2]!, ring.positions[v * 2 + 1]!); + // Even vertices are the inner edge (v = 0), odd ones the outer edge (v = 1). + // Float32 vertices: equal to about 7 significant digits. + expect(r).toBeCloseTo(v % 2 === 0 ? inner : outer, 6); + expect(ring.uvs[v * 2 + 1]).toBe(v % 2 === 0 ? 0 : 1); + } + // u runs 0 → 1 around the circle, closing the seam where it started. + expect(ring.uvs[0]).toBe(0); + expect(ring.uvs[ring.uvs.length - 2]).toBe(1); + expect(ring.indices).toHaveLength(64 * 6); + }); + + it("draws its own haze strip, seamless and fading out at the edges, when no file is given", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const source = hazeSource(new Map()); + expect(source.kind).toBe("generated"); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + if (source.kind !== "generated") return; + const { width, height, pixels } = source; + expect([width, height]).toEqual([ + mapStyle.belt.hazeStrip.width, + mapStyle.belt.hazeStrip.height, + ]); + const alpha = (x: number, y: number) => pixels[(y * width + x) * 4 + 3]!; + const rowMean = (y: number) => + Array.from({ length: width }, (_, x) => alpha(x, y)).reduce((a, b) => a + b, 0) / width; + // Densest in the middle, nearly transparent along the top and bottom rows. + expect(rowMean(0)).toBeLessThan(rowMean(height / 2) * 0.05); + expect(rowMean(height - 1)).toBeLessThan(rowMean(height / 2) * 0.05); + // The left and right edges meet: a ring shows no seam. + let seam = 0; + for (let y = 0; y < height; y++) seam += Math.abs(alpha(0, y) - alpha(width - 1, y)); + expect(seam / height).toBeLessThan(12); + }); + + it("draws the same haze every time, and uses the user's file when there is one", () => { + expect(hazeStripPixels(256, 32, 7)).toEqual(hazeStripPixels(256, 32, 7)); + const file = { name: "belt-haze texture" }; + expect(hazeSource(new Map([["belt-haze", file]]))).toEqual({ kind: "file", texture: file }); + }); +}); diff --git a/docs/screenshots/16-6-belt-haze-desktop.png b/docs/screenshots/16-6-belt-haze-desktop.png new file mode 100644 index 0000000..6f6beac Binary files /dev/null and b/docs/screenshots/16-6-belt-haze-desktop.png differ diff --git a/docs/screenshots/16-6-belt-haze-phone.png b/docs/screenshots/16-6-belt-haze-phone.png new file mode 100644 index 0000000..d315099 Binary files /dev/null and b/docs/screenshots/16-6-belt-haze-phone.png differ diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 03a30df..b1bd9eb 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -398,7 +398,7 @@ Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as - [x] **Step 16.3 — Content tuning: reach.** No tuning needed: with Phase 15's tanks every cell of CR-015 §4 and of CR-016 §3 already holds (Clipper ≈ 252M km reaches Venus at ≈ 200 but not Mars from Venus at ≈ 332; Freighter ≈ 460 reaches Mars from Venus and Io/Europa from Ceres at ≈ 393 but not Ceres from Venus at ≈ 520, nor Io from Mars at ≈ 592 × 1.15 short; Bulk Hauler ≈ 1,251 covers Venus–Jupiter at ≈ 885). Tests (`reach-table.test.ts`, CR-016 §3): one test per new cell (the Jupiter-system loop for Courier, Lighter, Clipper and Freighter; Earth↔Venus and Moon→Venus for Clipper and Freighter, not Courier or Lighter; Venus→Mars only from the Freighter up; Ceres↔Io/Europa for the Freighter; nothing from the inner system to the Jupiter system short of the Bulk Hauler, which flies every leg between all eight); the Bulk Hauler's full-tank reach ≥ 900M km; a Courier at the platform flies to Io and Europa and back without stranding. The no-strand test already covers every leg between all eight places. - [x] **Step 16.4 — Engine: save migration and boards.** No save migration and no save-version change: the save lists only discovered places, so a place that is in content but not in that list is undiscovered, and a save from before CR-016 opens with the three new places undiscovered and nothing else changed. Boards needed no change either: first contacts and fit slots already draw from undiscovered places in reach. Tests (`packages/engine/test/new-places.test.ts`): a pre-CR-016 save imported through the engine shows eight places with Venus, Io and Europa undiscovered, and its save version, discoveries and company are unchanged; 200 Earth boards for the deal's Clipper and veteran include first contacts to Venus; a Clipper delivering a first contact to Venus discovers it (the logbook says so) and Venus then has its own board, every contract on it a good made at Venus. - [x] **Step 16.5 — Assets: sprites and belt rocks.** (The Venus, Io and Europa sprites came in 16.1.) Two new art kinds: `rocks` (a sheet of separate sprites; the manifest's `belt-rocks` entry names `belt-asteroids.png` and says it holds `pieces: 6`) and `strip` (`belt-haze`, file `null` until the user provides one). `scripts/slice.mjs`: `findRegions` labels connected regions of pixels with alpha ≥ 16 (fainter glow and specks under 0.2 % of the sheet are dropped) in reading order; `sliceSheet` cuts each region out with every other pixel cleared, so a neighbour's glow never comes along. `map-art.mjs` writes `belt-rocks-1` … `belt-rocks-6` at 64 and 128 px (long side) and refuses a sheet whose piece count doesn't match the manifest; a haze strip would be kept within 4096 × 256. The loader registers each rock piece and the strip. The source image is only read. Tests: `apps/web/test/map-art.test.ts` (a fixture sheet with three separate squares and faint bridging glow slices into exactly three sprites, trimmed, in reading order; one square gives one; no faint glow survives in a piece; the real sheet's six pieces are written at the right sizes, deterministically and matching the committed output; the transparency check covers the belt image). -- [ ] Step 16.6 — Map: belt haze +- [x] **Step 16.6 — Map: belt haze.** `src/map/belt.ts`: `beltMapRadii`/`beltScreenRadii` (the band between 314M and 494M km on the compressed radial scale), `ringGeometry` (quads around the ring; the strip's width wraps once around, its height runs inner → outer edge), `hazeStripPixels` (the strip drawn in code: a faint warm-grey band fading to transparent at top and bottom, seamless noise, thousands of one- or two-pixel specks; seeded so it is the same every time) and `hazeSource` (`belt-haze.png` when it loaded, else the generated strip). The renderer builds the haze mesh once in map units and only moves and scales it with the camera, at 35 % opacity, above the orbit rings and below routes, bodies and ships; it takes no input. Map style gains `belt` (radii, haze opacity, segments, strip size, seed). Tests (`apps/web/test/belt-haze.test.ts`): the ring's screen radii equal those of 314M and 494M km at five zooms; the mesh vertices lie exactly on the two radii with u wrapping 0 → 1 and v inner 0, outer 1; with no haze file the strip is generated without any warning, fades to under 5 % at its edges and has no visible seam; the strip is the same every time and a provided file wins. Screenshots: `docs/screenshots/16-6-belt-haze-desktop.png`, `…-phone.png` (taken from the test build after the deal with every place discovered; the crowded Jupiter-system labels are Step 16.8's). - [ ] Step 16.7 — Map: belt rocks - [ ] Step 16.8 — Map: new bodies and Jupiter-system layout - [ ] Step 16.9 — Plan amendments and regression