Step 16.6: map: belt haze
CI / check (push) Has been cancelled Details

Draws the asteroid belt's haze as a ring mesh between 314M and 494M km
on the map's compressed scale: a strip drawn in code (or belt-haze.png
when provided) wrapped around the ring, static, non-interactive, above
the orbit rings and below routes and bodies. Review screenshots added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 13:52:13 -04:00
parent 89b2e2689c
commit 9cd2bbe3fd
9 changed files with 305 additions and 5 deletions

View File

@ -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-<n>@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.

132
apps/web/src/map/belt.ts Normal file
View File

@ -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<T>(
textures: ReadonlyMap<string, T>,
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),
};
}

View File

@ -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;
}

View File

@ -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>): 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<Texture>): 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();

View File

@ -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,

View File

@ -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<string, string>());
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 });
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

View File

@ -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