Step 16.6: map: belt haze
CI / check (push) Has been cancelled
Details
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:
parent
89b2e2689c
commit
9cd2bbe3fd
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue