Step 16.7: map: belt rocks
CI / check (push) Successful in 6m16s Details

Scatters 220 rocks from the six sliced images across the belt with a
seeded generator (denser in the middle, mostly small, varied rotation
and brightness), keeps them clear of Ceres at every zoom, and draws them
in one non-interactive container. Review screenshots added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 13:56:41 -04:00
parent 9cd2bbe3fd
commit 1d9dbdb7d5
8 changed files with 242 additions and 3 deletions

View File

@ -1532,3 +1532,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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 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. - **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. - **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.
## Step 16.7
- **Rocks are fixed in map units but sized in screen pixels**, like every other sprite on the map (decision 0.5.38): zooming in spreads them out rather than turning them into boulders.
- **Ceres's clear area is in screen pixels** (its drawn radius plus 6 px), applied as the rocks are placed each frame: a fixed area in map units would be too small when zoomed out or needlessly large when zoomed in.

View File

@ -130,3 +130,79 @@ export function hazeSource<T>(
pixels: hazeStripPixels(width, height, style.belt.seed), pixels: hazeStripPixels(width, height, style.belt.seed),
}; };
} }
/** One rock of the belt, fixed in map units (CR-016 §5). */
export interface BeltRock {
/** Position in map units (Sun at 0, 0). */
x: number;
y: number;
/** Which sliced rock image (0-based). */
piece: number;
/** Long side in px at sprite scale 1. */
sizePx: number;
/** Radians. */
rotation: number;
/** Tint brightness, 0–1. */
brightness: number;
}
/**
* The belt's rocks (CR-016 §5): a fixed count scattered by a seeded generator,
* so the belt looks the same every time. Denser toward the band's middle
* (the mean of two uniform draws), mostly small with a few larger ones, any
* rotation, varied brightness.
*/
export function beltRocks(pieces: number, style: MapStyle = mapStyle): BeltRock[] {
const { inner, outer } = beltMapRadii(style);
const { count, sizePx, brightness } = style.belt.rocks;
const rng = createRng(style.belt.seed + 1);
return Array.from({ length: count }, () => {
const across = (rng.next() + rng.next()) / 2;
const radius = inner + (outer - inner) * across;
const angle = rng.next() * Math.PI * 2;
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
piece: Math.floor(rng.next() * Math.max(1, pieces)),
sizePx: sizePx.min + (sizePx.max - sizePx.min) * rng.next() ** 3,
rotation: rng.next() * Math.PI * 2,
brightness: brightness.min + (brightness.max - brightness.min) * rng.next(),
};
});
}
/** A rock as drawn this frame. */
export interface PlacedRock {
rock: BeltRock;
center: MapPoint;
size: number;
}
/**
* Where the rocks are drawn at this camera: screen positions, sizes at the
* sprite scale, and none within the clear area around Ceres (its drawn
* radius plus a margin), so no rock ever covers it.
*/
export function placeRocks(
rocks: readonly BeltRock[],
view: Viewport,
camera: Camera,
scale: number,
ceres: { center: MapPoint; size: number } | null,
style: MapStyle = mapStyle,
): PlacedRock[] {
const clear = ceres ? ceres.size / 2 + style.belt.rocks.ceresMarginPx : 0;
const placed: PlacedRock[] = [];
for (const rock of rocks) {
const center = toScreen(rock, view, camera);
const size = rock.sizePx * scale;
if (
ceres &&
Math.hypot(center.x - ceres.center.x, center.y - ceres.center.y) < clear + size / 2
) {
continue;
}
placed.push({ rock, center, size });
}
return placed;
}

View File

@ -8,7 +8,15 @@ import {
Text, Text,
Texture, Texture,
} from "pixi.js"; } from "pixi.js";
import { beltMapRadii, hazeSource, ringGeometry } from "./belt"; import { artManifest, pieceIds } from "./art/manifest";
import {
beltMapRadii,
beltRocks,
hazeSource,
placeRocks,
ringGeometry,
type BeltRock,
} from "./belt";
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 { pixelsPerUnit, toScreen, type Camera, type Viewport } from "./camera"; import { pixelsPerUnit, toScreen, type Camera, type Viewport } from "./camera";
@ -104,6 +112,11 @@ export class MapRenderer {
private readonly orbits = new Graphics(); private readonly orbits = new Graphics();
/** The asteroid belt's haze (CR-016 §5): one mesh in map units, moved and scaled each frame. */ /** The asteroid belt's haze (CR-016 §5): one mesh in map units, moved and scaled each frame. */
private readonly haze: MeshSimple; private readonly haze: MeshSimple;
/** The belt's rocks (CR-016 §5): one batched container, one sprite per rock, never interactive. */
private readonly rocks = new Container({ eventMode: "none" });
private readonly rockDots = new Graphics();
private readonly beltRocks: BeltRock[];
private readonly rockSprites = new Map<BeltRock, Sprite>();
private readonly routes = new Graphics(); private readonly routes = new Graphics();
private readonly bodies = new Container(); private readonly bodies = new Container();
private readonly ships = new Container(); private readonly ships = new Container();
@ -124,9 +137,26 @@ export class MapRenderer {
this.background = bg ? new Sprite({ texture: bg }) : null; this.background = bg ? new Sprite({ texture: bg }) : null;
if (this.background) stage.addChild(this.background); if (this.background) stage.addChild(this.background);
this.haze = beltHaze(textures); this.haze = beltHaze(textures);
// One texture per sliced rock image; the dots stand in if none loaded (decision 0.5.41).
const rockTextures = pieceIds("belt-rocks", artManifest.assets["belt-rocks"]!)
.map((id) => textures.get(id))
.filter((t): t is Texture => t !== undefined);
this.beltRocks = beltRocks(Math.max(1, rockTextures.length));
if (rockTextures.length > 0) {
for (const rock of this.beltRocks) {
const sprite = new Sprite({ texture: rockTextures[rock.piece]!, anchor: 0.5 });
sprite.rotation = rock.rotation;
const level = Math.round(rock.brightness * 255);
sprite.tint = (level << 16) | (level << 8) | level;
this.rockSprites.set(rock, sprite);
this.rocks.addChild(sprite);
}
}
stage.addChild( stage.addChild(
this.orbits, this.orbits,
this.haze, this.haze,
this.rocks,
this.rockDots,
this.routes, this.routes,
this.bodies, this.bodies,
this.ships, this.ships,
@ -217,6 +247,29 @@ export class MapRenderer {
} }
} }
// The belt's rocks, clear of Ceres (CR-016 §5).
this.rockDots.clear();
for (const sprite of this.rockSprites.values()) sprite.visible = false;
const ceres = placed.get("ceres-station");
for (const { rock, center, size } of placeRocks(
this.beltRocks,
view,
camera,
layout.scale,
ceres ?? null,
)) {
const sprite = this.rockSprites.get(rock);
if (sprite) {
sprite.visible = true;
sprite.position.set(center.x, center.y);
fit(sprite, size);
} else {
this.rockDots
.circle(center.x, center.y, size / 3)
.fill({ color: colors.orbit, alpha: rock.brightness * 0.6 });
}
}
this.place("sun", visuals.sun, this.bodies, sun, layout.sun.size, () => this.place("sun", visuals.sun, this.bodies, sun, layout.sun.size, () =>
this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun), this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun),
); );

View File

@ -65,8 +65,17 @@ export const mapStyleSchema = z
width: z.number().int().positive(), width: z.number().int().positive(),
height: z.number().int().positive(), height: z.number().int().positive(),
}), }),
/** Seed for the generated haze, so it looks the same every time. */ /** Seed for the generated haze and the rocks, so the belt looks the same every time. */
seed: z.number().int().nonnegative(), seed: z.number().int().nonnegative(),
rocks: z.object({
/** Rocks scattered across the band. */
count: z.number().int().nonnegative(),
/** Long side at sprite scale 1 (px): mostly near `min`, a few near `max`. */
sizePx: z.object({ min: px, max: px }),
brightness: z.object({ min: z.number().min(0).max(1), max: z.number().min(0).max(1) }),
/** Kept clear around Ceres beyond its drawn radius (px). */
ceresMarginPx: z.number().min(0),
}),
}), }),
colors: z.object({ colors: z.object({
background: color, background: color,
@ -129,6 +138,12 @@ export const mapStyle: MapStyle = mapStyleSchema.parse({
segments: 256, segments: 256,
hazeStrip: { width: 1024, height: 64 }, hazeStrip: { width: 1024, height: 64 },
seed: 20_260_101, seed: 20_260_101,
rocks: {
count: 220,
sizePx: { min: 3, max: 14 },
brightness: { min: 0.55, max: 1 },
ceresMarginPx: 6,
},
}, },
colors: { colors: {
background: 0x020617, background: 0x020617,

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { beltMapRadii, beltRocks, placeRocks } from "../src/map/belt";
import { MAX_ZOOM, MIN_ZOOM, toScreen } from "../src/map/camera";
import { hitTest } from "../src/map/hitTest";
import { layoutScene, spriteScale } from "../src/map/layout";
import { buildScene } from "../src/map/scene";
import { mapStyle } from "../src/map/style";
import { samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const views = [
{ width: 1280, height: 800 },
{ width: 390, height: 844 },
];
const zooms = [MIN_ZOOM, 1, 3, 12, MAX_ZOOM];
const scene = buildScene(samplePlayerState(), NOW);
describe("the belt's rocks (Step 16.7, decision 0.5.104)", () => {
it("places the same rocks every time", () => {
expect(beltRocks(6)).toEqual(beltRocks(6));
expect(beltRocks(6)).toHaveLength(mapStyle.belt.rocks.count);
});
it("keeps every rock inside the band, denser toward its middle", () => {
const { inner, outer } = beltMapRadii();
const rocks = beltRocks(6);
const middle = rocks.filter((r) => {
const t = (Math.hypot(r.x, r.y) - inner) / (outer - inner);
expect(t).toBeGreaterThanOrEqual(0);
expect(t).toBeLessThanOrEqual(1);
return t > 0.25 && t < 0.75;
});
// A uniform scatter would put half in the middle half; the falloff puts more there.
expect(middle.length / rocks.length).toBeGreaterThan(0.6);
});
it("is mostly small rocks with a few larger ones, using every rock image", () => {
const rocks = beltRocks(6);
const { min, max } = mapStyle.belt.rocks.sizePx;
const small = rocks.filter((r) => r.sizePx < min + (max - min) / 3).length;
expect(small / rocks.length).toBeGreaterThan(0.6);
expect(rocks.some((r) => r.sizePx > min + ((max - min) * 2) / 3)).toBe(true);
expect(new Set(rocks.map((r) => r.piece)).size).toBe(6);
});
it("never draws a rock over Ceres, at any zoom or screen size", () => {
const rocks = beltRocks(6);
for (const view of views) {
for (const zoom of zooms) {
const camera = { zoom, panX: 0, panY: 0 };
const layout = layoutScene(scene, view, camera);
const ceres = layout.locations.find((l) => l.id === "ceres-station")!;
const placed = placeRocks(rocks, view, camera, layout.scale, ceres);
for (const { center, size } of placed) {
const gap = Math.hypot(center.x - ceres.center.x, center.y - ceres.center.y);
expect(gap).toBeGreaterThanOrEqual(ceres.size / 2 + mapStyle.belt.rocks.ceresMarginPx);
expect(gap - size / 2).toBeGreaterThanOrEqual(ceres.size / 2);
}
// Rocks near Ceres were left out; the rest are drawn at the sprite scale.
expect(placed.length).toBeLessThanOrEqual(rocks.length);
for (const { rock, size } of placed) {
expect(size).toBeCloseTo(rock.sizePx * spriteScale(zoom), 9);
}
}
}
});
it("lets a tap on a rock reach whatever is beneath it", () => {
const view = views[0]!;
const camera = { zoom: 1, panX: 0, panY: 0 };
const layout = layoutScene(scene, view, camera);
const placed = placeRocks(beltRocks(6), view, camera, layout.scale, null);
for (const { center } of placed) {
const hit = hitTest(scene, center, view, camera);
// Never the rock: empty map, or a location whose tap target is there.
if (hit !== null) {
expect(hit.kind).toBe("location");
const loc = layout.locations.find((l) => hit.kind === "location" && l.id === hit.id)!;
expect(Math.hypot(center.x - loc.center.x, center.y - loc.center.y)).toBeLessThanOrEqual(
loc.tap / 2,
);
}
}
// And most rocks sit over empty map.
expect(
placed.filter(({ center }) => hitTest(scene, center, view, camera) === null).length,
).toBeGreaterThan(placed.length / 2);
expect(toScreen({ x: 0, y: 0 }, view, camera)).toEqual(layout.sun.center);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

View File

@ -399,6 +399,6 @@ Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as
- [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.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). - [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).
- [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). - [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 - [x] **Step 16.7 — Map: belt rocks.** `beltRocks` scatters 220 rocks (map style `belt.rocks`) with a seeded generator: positions in map units across the band, denser toward its middle (the mean of two uniform draws), sizes 3–14 px mostly small (cubed draw), any rotation, brightness 0.55–1, each one of the six sliced rock images. `placeRocks` puts them on screen at the sprite scale and leaves out any rock that would come within Ceres's drawn radius plus 6 px, at any zoom. The renderer creates one sprite per rock once, in a single non-interactive container above the haze, and only moves, sizes and shows them each frame (code-drawn dots if the rock art is missing); taps never consider rocks, so they reach the place or empty map beneath. Tests (`apps/web/test/belt-rocks.test.ts`): the same rocks every time; every rock in the band, over 60 % in its middle half; mostly small with some large, all six images used; no rock over Ceres at five zooms on desktop and phone sizes, sizes following the sprite scale; a tap on any rock finds empty map or a place whose tap target is there. Screenshots: `docs/screenshots/16-7-belt-rocks-desktop.png`, `…-phone.png`. **Phone performance check (CR-003 Step 10C.9): left for the user** (no phone here); listed in the Phase 16 review.
- [ ] Step 16.8 — Map: new bodies and Jupiter-system layout - [ ] Step 16.8 — Map: new bodies and Jupiter-system layout
- [ ] Step 16.9 — Plan amendments and regression - [ ] Step 16.9 — Plan amendments and regression