diff --git a/DECISIONS.md b/DECISIONS.md index 300a643..71ac4aa 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1537,3 +1537,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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. + +## Step 16.8 + +- **The Jupiter system folds below a zoom threshold (2.5), not only on collision**: Io and Europa sit at fixed screen offsets (decision 0.5.39), so zooming in never moves their labels apart by itself; the threshold is what makes "zoom in to see all three" work. Earth and the Moon, whose labels don't collide, keep the CR's collision-only rule. +- **Child labels face away from their parent first**, then below, then above: that is what keeps three labels apart in a 64 px family. +- **The "+N" marker toggles**: a second tap folds the family again; zooming out below the threshold doesn't re-fold a family the player opened. +- **Io and Europa's angles** (100° and −160° from the outward direction) keep them off Jupiter's platform badge (screen −45°) and 100° apart, so their 18 px sprites never touch at the largest sprite scale. +- **Haze strip 2048 × 256, repeated 8 times around the ring**: the 1024 × 64 strip wrapped once made specks into dashes when zoomed in on the belt. +- **On a phone the Jupiter family's 64 px ring is large next to the zoomed-out map** and sits near Ceres; it stays legible, and is flagged for the user's review. diff --git a/apps/web/e2e/12-5-discovery.e2e.ts b/apps/web/e2e/12-5-discovery.e2e.ts index 93a8268..97fed53 100644 --- a/apps/web/e2e/12-5-discovery.e2e.ts +++ b/apps/web/e2e/12-5-discovery.e2e.ts @@ -1,7 +1,9 @@ import { expect, test } from "@playwright/test"; -import type { Dispatch } from "@deadhead/schema"; +import { content } from "@deadhead/content"; +import type { Contract, Dispatch } from "@deadhead/schema"; import { acknowledgeAll, + advance, addCredits, advanceTo, board, @@ -13,6 +15,8 @@ import { tryDispatch, } from "./support"; +const BOARD_PERIOD_MS = content.config.contractBoardPeriodRealMinutes * 60_000; + test.describe("end to end (Phase 12)", () => { test("12.5: a first contact to Mars discovers it, opens its board and lets you lease there", async ({ page, @@ -42,11 +46,16 @@ test.describe("end to end (Phase 12)", () => { } await acknowledgeAll(page); - // The board offers a first contact the Clipper can fly (decision 0.5.85). - const offer = (await board(page, "earth")).contracts.find( - (c) => c.firstContact && c.destinationId === "mars", - ); - expect(offer, "a first contact to Mars on the Earth board").toBeDefined(); + // Each board offers a first contact the Clipper can fly (decision 0.5.85); since + // CR-016 it may be Venus rather than Mars, so wait for a board offering Mars. + let offer: Contract | undefined; + for (let period = 0; period < 12 && !offer; period++) { + if (period > 0) await advance(page, BOARD_PERIOD_MS); + offer = (await board(page, "earth")).contracts.find( + (c) => c.firstContact && c.destinationId === "mars", + ); + } + expect(offer, "a first contact to Mars on an Earth board").toBeDefined(); const flight = await tryDispatch(page, offer!.id, clipper.id, veteran.id); expect(flight, "the Clipper and veteran can take it").not.toBeNull(); await advanceTo(page, flight!.arrivalAt); diff --git a/apps/web/src/map/MapCanvas.tsx b/apps/web/src/map/MapCanvas.tsx index db5b89e..171cec5 100644 --- a/apps/web/src/map/MapCanvas.tsx +++ b/apps/web/src/map/MapCanvas.tsx @@ -40,6 +40,7 @@ export function MapCanvas({ if (!container) return; let cancelled = false; let stopTicker = () => {}; + let renderer: MapRenderer | null = null; const app = new Application(); const viewport = (): Viewport => ({ @@ -67,9 +68,10 @@ export function MapCanvas({ return; } container.appendChild(app.canvas); - const renderer = new MapRenderer(app.stage, textures); + const map = new MapRenderer(app.stage, textures); + renderer = map; app.ticker.add(() => - renderer.draw( + map.draw( buildScene(stateRef.current, serverNow()), viewport(), useCamera.getState(), @@ -123,6 +125,11 @@ export function MapCanvas({ const onUp = (e: PointerEvent) => { pointers.delete(e.pointerId); if (e.type === "pointerup" && tap?.id === e.pointerId && tap.moved < 8) { + // "+N" markers fold and unfold crowded labels (decision 0.5.105). + if (renderer?.tapMarker(local(e))) { + tap = null; + return; + } const scene = buildScene(stateRef.current, serverNow()); onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState())); } diff --git a/apps/web/src/map/belt.ts b/apps/web/src/map/belt.ts index 46e9d66..b0255ca 100644 --- a/apps/web/src/map/belt.ts +++ b/apps/web/src/map/belt.ts @@ -30,23 +30,30 @@ export function beltScreenRadii( 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). */ + /** u around the ring (0 → `repeats`), 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). + * texture wrapped around it: the strip's width runs `repeats` times around the + * circle (so its specks don't stretch when zoomed in) and its height from the + * inner edge to the outer edge (CR-016 §5). */ -export function ringGeometry(inner: number, outer: number, segments: number): RingGeometry { +export function ringGeometry( + inner: number, + outer: number, + segments: number, + repeats = 1, +): 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 turn = i / segments; + const angle = turn * Math.PI * 2; + const u = turn * repeats; 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); @@ -81,11 +88,12 @@ export function hazeStripPixels(width: number, height: number, seed: number): Ui (sum, w) => sum + w.amplitude * Math.sin((x / width) * w.cycles * Math.PI * 2 + w.phase), 0, ); + const column = Float32Array.from({ length: width }, (_, x) => 0.55 + 0.2 * noise(x)); 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 alpha = band * column[x]! * 255; const i = (y * width + x) * 4; pixels.set([r, g, b, Math.max(0, Math.min(255, Math.round(alpha)))], i); } diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index 8a833c4..e344b21 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -233,7 +233,7 @@ export function separateShips(flights: FlightLayout[], style: MapStyle = mapStyl }); } -interface Box { +export interface Box { left: number; top: number; right: number; @@ -250,23 +250,43 @@ const square = (center: MapPoint, size: number): Box => ({ bottom: center.y + size / 2, }); +export interface LabelSpot { + x: number; + y: number; + /** Which point of the text sits at (x, y): 0 left/top, 0.5 centre, 1 right/bottom. */ + anchorX: 0 | 0.5 | 1; + anchorY: 0 | 0.5 | 1; +} + export interface Decoration { - /** Label anchor: top-centre when below the body (anchorY 0), bottom-centre when above (1). */ - label: { x: number; y: number; anchorY: 0 | 1 }; + /** Below the body (anchorY 0), above (1), or beside a child body, away from its parent. */ + label: LabelSpot; + /** False when the label is folded into its parent's "+N" marker (decision 0.5.105). */ + shown: boolean; /** Idle-ship badge centre. */ badge: MapPoint; } +/** The screen box a label of `size` covers at `spot`. */ +export function labelBox(spot: LabelSpot, size: { width: number; height: number }): Box { + const left = spot.x - size.width * spot.anchorX; + const top = spot.y - size.height * spot.anchorY; + return { left, top, right: left + size.width, bottom: top + size.height }; +} + /** * Places each location's label below its body, or above it when below would - * cover another body or an earlier label; then its idle-ship badge on the - * first diagonal clear of other bodies and the labels. `labelSize` measures a - * label. + * cover another body or an earlier label; a child body (the Moon, Io, Europa) + * tries the side facing away from its parent first. Then its idle-ship badge + * goes on the first diagonal clear of other bodies and the labels. + * `labelSize` measures a label; `hidden` labels are folded into a "+N" marker + * (decision 0.5.105) and take no room. */ export function decorate( layout: SceneLayout, labelSize: (id: string) => { width: number; height: number }, style: MapStyle = mapStyle, + hidden: ReadonlySet = new Set(), ): Map { const bodies = [ { id: "sun", box: square(layout.sun.center, layout.sun.size) }, @@ -277,27 +297,37 @@ export function decorate( !bodies.some((b) => b.id !== ownId && overlaps(box, b.box)); const clearOfLabels = (box: Box) => !labels.some((l) => overlaps(box, l)); + const byId = new Map(layout.locations.map((l) => [l.id, l])); const placed = layout.locations.map((location) => { - const { width, height } = labelSize(location.id); + const textSize = labelSize(location.id); const { center, size } = location; const gap = style.label.gapPx; - const below = { x: center.x, y: center.y + size / 2 + gap, anchorY: 0 as const }; - const above = { x: center.x, y: center.y - size / 2 - gap, anchorY: 1 as const }; - const boxOf = (l: { x: number; y: number; anchorY: 0 | 1 }): Box => ({ - left: l.x - width / 2, - right: l.x + width / 2, - top: l.anchorY === 0 ? l.y : l.y - height, - bottom: l.anchorY === 0 ? l.y + height : l.y, - }); - const label = - [below, above].find((l) => clearOfBodies(boxOf(l), location.id) && clearOfLabels(boxOf(l))) ?? - below; - labels.push(boxOf(label)); - return { location, label }; + const below: LabelSpot = { + x: center.x, + y: center.y + size / 2 + gap, + anchorX: 0.5, + anchorY: 0, + }; + const above: LabelSpot = { + x: center.x, + y: center.y - size / 2 - gap, + anchorX: 0.5, + anchorY: 1, + }; + const candidates = [below, above]; + const parent = byId.get(style.childOffsets[location.id]?.parentId ?? ""); + if (parent) candidates.unshift(awayFrom(parent.center, center, size / 2 + gap)); + const fits = (spot: LabelSpot) => + clearOfBodies(labelBox(spot, textSize), location.id) && + clearOfLabels(labelBox(spot, textSize)); + const label = candidates.find(fits) ?? candidates[0]!; + const shown = !hidden.has(location.id); + if (shown) labels.push(labelBox(label, textSize)); + return { location, label, shown }; }); const result = new Map(); - for (const { location, label } of placed) { + for (const { location, label, shown } of placed) { const { distancePx, angleDeg, radiusPx } = style.idleBadge; const angles = [angleDeg, -180 - angleDeg, -angleDeg, 180 + angleDeg]; const spots = angles.map((a) => add(location.center, polar(location.size / 2 + distancePx, a))); @@ -306,7 +336,7 @@ export function decorate( const box = square(p, radiusPx * 2); return clearOfBodies(box, location.id) && clearOfLabels(box); }) ?? spots[0]!; - result.set(location.id, { label, badge }); + result.set(location.id, { label, shown, badge }); } return result; } @@ -318,3 +348,101 @@ export function decorate( export function mapMotionStyle(reducedMotion: boolean, style: MapStyle = mapStyle): MapStyle { return reducedMotion ? { ...style, parallax: 0, backgroundZoomFollow: 0 } : style; } + +/** + * A label spot just off a child body on the side facing away from its parent: + * beside it when the child sits mostly left or right of the parent, else above + * or below it. + */ +function awayFrom(parent: MapPoint, child: MapPoint, distance: number): LabelSpot { + const dx = child.x - parent.x; + const dy = child.y - parent.y; + if (Math.abs(dx) >= Math.abs(dy)) { + return { + x: child.x + Math.sign(dx || 1) * distance, + y: child.y, + anchorX: dx < 0 ? 1 : 0, + anchorY: 0.5, + }; + } + return { + x: child.x, + y: child.y + Math.sign(dy) * distance, + anchorX: 0.5, + anchorY: dy < 0 ? 1 : 0, + }; +} + +/** A "+N" marker standing for a parent's folded child labels (decision 0.5.105). */ +export interface LabelMarker { + parentId: string; + /** How many labels it stands for. */ + count: number; + center: MapPoint; + /** Tap target, square side (px). */ + tap: number; +} + +export interface LabelPlan { + decorations: Map; + markers: LabelMarker[]; +} + +/** + * Label decluttering (CR-016 §6, decision 0.5.105): a parent and its children + * (Earth and the Moon; Jupiter's platform, Io and Europa) show only the + * parent's label with a "+N" marker when their labels would overlap, or, for + * families with an `expandZoom`, whenever the map is zoomed out below it. + * Tapping the marker (`expanded`) or zooming in past the threshold shows all. + */ +export function planLabels( + layout: SceneLayout, + labelSize: (id: string) => { width: number; height: number }, + zoom: number, + expanded: ReadonlySet = new Set(), + style: MapStyle = mapStyle, +): LabelPlan { + const families = new Map(); + for (const [childId, { parentId }] of Object.entries(style.childOffsets)) { + if (!layout.locations.some((l) => l.id === childId)) continue; + families.set(parentId, [...(families.get(parentId) ?? []), childId]); + } + const all = decorate(layout, labelSize, style); + const box = (id: string) => labelBox(all.get(id)!.label, labelSize(id)); + const hidden = new Set(); + const folded: [string, string[]][] = []; + for (const [parentId, children] of families) { + if (expanded.has(parentId) || !all.has(parentId)) continue; + const members = [parentId, ...children]; + const collide = members.some((a, i) => + members.slice(i + 1).some((b) => overlaps(box(a), box(b))), + ); + const expandZoom = style.labelDeclutter[parentId]?.expandZoom; + if (collide || (expandZoom !== undefined && zoom < expandZoom)) { + children.forEach((c) => hidden.add(c)); + folded.push([parentId, children]); + } + } + const decorations = hidden.size > 0 ? decorate(layout, labelSize, style, hidden) : all; + const markers = folded.map(([parentId, children]): LabelMarker => { + const labelEdge = labelBox(decorations.get(parentId)!.label, labelSize(parentId)); + const { radiusPx, gapPx } = style.labelMarker; + return { + parentId, + count: children.length, + center: { + x: labelEdge.right + gapPx + radiusPx, + y: (labelEdge.top + labelEdge.bottom) / 2, + }, + tap: tapSize(radiusPx * 2, style), + }; + }); + return { decorations, markers }; +} + +/** The marker a tap at `tap` lands on, if any. */ +export function markerAt(markers: readonly LabelMarker[], tap: MapPoint): LabelMarker | null { + return ( + markers.find((m) => Math.hypot(m.center.x - tap.x, m.center.y - tap.y) <= m.tap / 2) ?? null + ); +} diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts index 48a3175..2e35287 100644 --- a/apps/web/src/map/renderer.ts +++ b/apps/web/src/map/renderer.ts @@ -21,7 +21,15 @@ import type { ArtTextures } from "./art/load"; import { sceneVisuals, type Visual } from "./art/visuals"; import { pixelsPerUnit, toScreen, type Camera, type Viewport } from "./camera"; import { reducedMotionOn, usePreferences } from "../state/preferences"; -import { backgroundRect, decorate, layoutScene, mapMotionStyle, polar } from "./layout"; +import { + backgroundRect, + layoutScene, + mapMotionStyle, + markerAt, + planLabels, + polar, + type LabelMarker, +} from "./layout"; import type { MapSelection } from "./hitTest"; import { locationOverlays } from "./overlays"; import type { MapPoint } from "./projection"; @@ -81,15 +89,21 @@ function drawHq(g: Graphics, at: MapPoint, size: number) { */ function hazeTexture(textures: ArtTextures): Texture { const source = hazeSource(textures); - if (source.kind === "file") return source.texture; + if (source.kind === "file") { + // Seamless left to right: tile it around the ring. + source.texture.source.addressMode = "repeat"; + return source.texture; + } const { pixels: resource, width, height } = source; - return new Texture({ source: new BufferImageSource({ resource, width, height }) }); + return new Texture({ + source: new BufferImageSource({ resource, width, height, addressModeU: "repeat" }), + }); } /** 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 ring = ringGeometry(inner, outer, mapStyle.belt.segments, mapStyle.belt.hazeRepeats); const mesh = new MeshSimple({ texture: hazeTexture(textures), vertices: ring.positions, @@ -125,6 +139,11 @@ export class MapRenderer { private readonly sprites = new Map(); private readonly labels = new Map(); private readonly badges = new Map(); + /** "+N" markers for folded labels (decision 0.5.105), by parent id. */ + private readonly markerTexts = new Map(); + private markers: LabelMarker[] = []; + /** Parents whose folded labels the player opened by tapping "+N". */ + private readonly expanded = new Set(); /** One shared filter for every undiscovered location's sprite. */ private readonly desaturate = new ColorMatrixFilter(); @@ -165,6 +184,18 @@ export class MapRenderer { ); } + /** + * A tap on a "+N" marker shows or hides that family's labels again (decision + * 0.5.105). True when the tap landed on one, so it selects nothing else. + */ + tapMarker(at: MapPoint): boolean { + const marker = markerAt(this.markers, at); + if (!marker) return false; + if (this.expanded.has(marker.parentId)) this.expanded.delete(marker.parentId); + else this.expanded.add(marker.parentId); + return true; + } + /** A sprite for `key`, created on first use; hidden until positioned this frame. */ private sprite(key: string, texture: Texture, layer: Container): Sprite { let sprite = this.sprites.get(key); @@ -350,16 +381,27 @@ export class MapRenderer { // Labels below (or above) bodies, badges on a clear diagonal (Step 10C.6), and // the HQ marker on the far side of the label from its body (Step 10C.7). - const decorations = decorate(layout, (id) => this.labels.get(id)!); + // Crowded families fold into the parent's label with a "+N" marker (decision 0.5.105). + const { decorations, markers } = planLabels( + layout, + (id) => this.labels.get(id)!, + camera.zoom, + this.expanded, + ); + this.markers = markers; for (const location of scene.locations) { - const { label, badge } = decorations.get(location.id)!; + const { label, shown, badge } = decorations.get(location.id)!; const text = this.labels.get(location.id)!; - text.anchor.set(0.5, label.anchorY); + text.visible = shown; + text.anchor.set(label.anchorX, label.anchorY); text.position.set(label.x, label.y); if (overlays.get(location.id)!.hq) { const gap = 3 + mapStyle.hqSizePx / 2; - const y = label.anchorY === 0 ? label.y + text.height + gap : label.y - text.height - gap; - drawHq(this.overlay, { x: label.x, y }, mapStyle.hqSizePx); + // On the far side of the label from the body; under a label beside it. + const top = label.y - text.height * label.anchorY; + const y = label.anchorY === 1 ? top - gap : top + text.height + gap; + const x = label.x + text.width * (0.5 - label.anchorX); + drawHq(this.overlay, { x, y }, mapStyle.hqSizePx); } const count = this.badges.get(location.id)!; if (count.visible) { @@ -368,6 +410,30 @@ export class MapRenderer { } } + for (const text of this.markerTexts.values()) text.visible = false; + for (const marker of markers) { + const text = this.text(this.markerTexts, marker.parentId, () => { + const t = new Text({ + text: "", + style: { + fontSize: mapStyle.labelMarker.fontSizePx, + fontWeight: "700", + fill: colors.badgeText, + }, + }); + t.anchor.set(0.5); + return t; + }); + text.visible = true; + text.text = `+${marker.count}`; + text.position.set(marker.center.x, marker.center.y); + const r = mapStyle.labelMarker.radiusPx; + this.overlay + .roundRect(marker.center.x - r * 1.3, marker.center.y - r, r * 2.6, r * 2, r) + .fill(colors.label) + .stroke({ width: 1, color: colors.labelHalo }); + } + // In-flight ships (Steps 10.2, 10C.8): a faint route, brighter ahead of the // ship, and the ship's sprite facing its direction of travel. for (const flight of layout.flights) { diff --git a/apps/web/src/map/style.ts b/apps/web/src/map/style.ts index c34f7ca..0e3a512 100644 --- a/apps/web/src/map/style.ts +++ b/apps/web/src/map/style.ts @@ -31,6 +31,13 @@ export const mapStyleSchema = z * or the first of the other diagonals that is clear of bodies and labels. */ idleBadge: offset.extend({ radiusPx: px, fontSizePx: px }), + /** + * Label decluttering (decision 0.5.105): per parent, the zoom below which its + * children's labels fold into a "+N" marker even when they don't collide. + */ + labelDeclutter: z.record(z.string(), z.object({ expandZoom: z.number().positive() })), + /** The "+N" marker beside a parent's label: a small pill, tappable at `minTapPx`. */ + labelMarker: z.object({ radiusPx: px, gapPx: z.number().min(0), fontSizePx: px }), /** Location badge art (e.g. Jupiter's cloud platform), on the body's edge. */ locationBadge: z.object({ sizePx: px, angleDeg: z.number() }), /** Opacity of an undiscovered location's (desaturated) sprite. */ @@ -60,7 +67,9 @@ export const mapStyleSchema = z 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). */ + /** Times the haze strip repeats around the ring (it is seamless, so it tiles). */ + hazeRepeats: z.number().int().positive(), + /** Size of the haze strip drawn in code when `belt-haze.png` is missing (px); fine enough not to streak when zoomed in. */ hazeStrip: z.object({ width: z.number().int().positive(), height: z.number().int().positive(), @@ -114,11 +123,24 @@ export const mapStyle: MapStyle = mapStyleSchema.parse({ "body-mars": 32, "body-ceres": 22, "body-moon": 18, + "body-venus": 30, + "body-io": 18, + "body-europa": 18, }, shipSizePx: 24, spriteScale: { min: 0.75, max: 1.75, zoomExponent: 0.35 }, // 46 px clears Earth and the Moon at the largest sprite scale: (32 + 18) / 2 × 1.75 = 43.75. - childOffsets: { moon: { parentId: "earth", distancePx: 46, angleDeg: -30 } }, + childOffsets: { + moon: { parentId: "earth", distancePx: 46, angleDeg: -30 }, + // Io and Europa around Jupiter (CR-016 §6): 64 px clears Jupiter and an 18 px + // child at the largest sprite scale, (48 + 18) / 2 × 1.75 = 57.75; the angles + // keep both off the platform badge (screen -45°) and 100° apart. + "io-sulfur-works": { parentId: "jupiter-cloud-platform", distancePx: 64, angleDeg: 100 }, + "europa-ice-station": { parentId: "jupiter-cloud-platform", distancePx: 64, angleDeg: -160 }, + }, + // The Jupiter system folds to "Jupiter Cloud Platform +2" until zoomed in (CR-016 §6). + labelDeclutter: { "jupiter-cloud-platform": { expandZoom: 2.5 } }, + labelMarker: { radiusPx: 8, gapPx: 4, fontSizePx: 10 }, idleBadge: { distancePx: 2, angleDeg: -135, radiusPx: 8, fontSizePx: 10 }, locationBadge: { sizePx: 16, angleDeg: -45 }, undiscoveredAlpha: 0.55, @@ -136,7 +158,8 @@ export const mapStyle: MapStyle = mapStyleSchema.parse({ outerMillionKm: 494, hazeAlpha: 0.35, segments: 256, - hazeStrip: { width: 1024, height: 64 }, + hazeRepeats: 8, + hazeStrip: { width: 2048, height: 256 }, seed: 20_260_101, rocks: { count: 220, diff --git a/apps/web/test/belt-haze.test.ts b/apps/web/test/belt-haze.test.ts index feddea4..ca5feef 100644 --- a/apps/web/test/belt-haze.test.ts +++ b/apps/web/test/belt-haze.test.ts @@ -30,7 +30,7 @@ describe("the belt's haze (Step 16.6, decision 0.5.104)", () => { } }); - it("builds the ring mesh exactly on those radii, with the strip wrapped once around", () => { + it("builds the ring mesh exactly on those radii, with the strip wrapped around", () => { const { inner, outer } = beltMapRadii(); const ring = ringGeometry(inner, outer, 64); for (let v = 0; v < ring.positions.length / 2; v++) { @@ -40,9 +40,12 @@ describe("the belt's haze (Step 16.6, decision 0.5.104)", () => { 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. + // 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); + // …or 0 → n when the strip repeats n times around. + const repeated = ringGeometry(inner, outer, 64, 8); + expect(repeated.uvs[repeated.uvs.length - 2]).toBe(8); expect(ring.indices).toHaveLength(64 * 6); }); diff --git a/apps/web/test/jupiter-system.test.ts b/apps/web/test/jupiter-system.test.ts new file mode 100644 index 0000000..396f6bf --- /dev/null +++ b/apps/web/test/jupiter-system.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera"; +import { childOffset, labelBox, layoutScene, markerAt, planLabels } 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 view = { width: 1280, height: 800 }; +const scene = buildScene(samplePlayerState(), NOW); +/** Labels about as wide as the real ones: ~6.5 px a character at 11 px. */ +const NAMES = new Map(scene.locations.map((l) => [l.id, l.name])); +const labelSize = (id: string) => ({ width: (NAMES.get(id) ?? id).length * 6.5, height: 14 }); +const cameraAt = (zoom: number) => ({ zoom, panX: 0, panY: 0 }); +const JUPITER = "jupiter-cloud-platform"; +const CHILDREN = ["io-sulfur-works", "europa-ice-station"]; + +describe("the Jupiter system on the map (Step 16.8, decision 0.5.105)", () => { + it("draws Io and Europa exactly at their configured offsets from Jupiter, at any zoom", () => { + for (const zoom of [MIN_ZOOM, 1, 2.5, 10, MAX_ZOOM]) { + const layout = layoutScene(scene, view, cameraAt(zoom)); + const at = (id: string) => layout.locations.find((l) => l.id === id)!.center; + for (const child of CHILDREN) { + const offset = childOffset(child, at(JUPITER), layout.sun.center); + expect(at(child).x).toBeCloseTo(at(JUPITER).x + offset.x, 9); + expect(at(child).y).toBeCloseTo(at(JUPITER).y + offset.y, 9); + expect(Math.hypot(offset.x, offset.y)).toBeCloseTo( + mapStyle.childOffsets[child]!.distancePx, + ); + } + } + }); + + it('shows only Jupiter\'s label, with "+2", when zoomed out', () => { + const layout = layoutScene(scene, view, cameraAt(1)); + const { decorations, markers } = planLabels(layout, labelSize, 1); + expect(decorations.get(JUPITER)!.shown).toBe(true); + for (const child of CHILDREN) expect(decorations.get(child)!.shown, child).toBe(false); + expect(markers).toEqual([expect.objectContaining({ parentId: JUPITER, count: 2 })]); + // The marker sits just right of Jupiter's label. + const label = labelBox(decorations.get(JUPITER)!.label, labelSize(JUPITER)); + expect(markers[0]!.center.x).toBeGreaterThan(label.right); + }); + + it("shows all three labels, without overlapping, once zoomed past the threshold", () => { + const zoom = mapStyle.labelDeclutter[JUPITER]!.expandZoom; + const layout = layoutScene(scene, view, cameraAt(zoom)); + const { decorations, markers } = planLabels(layout, labelSize, zoom); + expect(markers).toEqual([]); + const family = [JUPITER, ...CHILDREN]; + for (const id of family) expect(decorations.get(id)!.shown, id).toBe(true); + const boxes = family.map((id) => labelBox(decorations.get(id)!.label, labelSize(id))); + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const [a, b] = [boxes[i]!, boxes[j]!]; + const overlap = + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + expect(overlap, `${family[i]} / ${family[j]}`).toBe(false); + } + } + }); + + it('opens the folded labels when "+2" is tapped', () => { + const layout = layoutScene(scene, view, cameraAt(1)); + const { markers } = planLabels(layout, labelSize, 1); + const marker = markerAt(markers, markers[0]!.center)!; + expect(marker.parentId).toBe(JUPITER); + expect(markerAt(markers, { x: -500, y: -500 })).toBeNull(); + const opened = planLabels(layout, labelSize, 1, new Set([JUPITER])); + for (const child of CHILDREN) expect(opened.decorations.get(child)!.shown).toBe(true); + expect(opened.markers).toEqual([]); + }); + + it("keeps Earth and the Moon labelled while their labels don't collide", () => { + const layout = layoutScene(scene, view, cameraAt(1)); + const { decorations, markers } = planLabels(layout, labelSize, 1); + expect(decorations.get("moon")!.shown).toBe(true); + expect(markers.some((m) => m.parentId === "earth")).toBe(false); + }); + + it("gives every place and the marker a tap target of at least 44 px", () => { + for (const zoom of [MIN_ZOOM, 1, MAX_ZOOM]) { + const layout = layoutScene(scene, view, cameraAt(zoom)); + for (const l of layout.locations) expect(l.tap, l.id).toBeGreaterThanOrEqual(44); + for (const m of planLabels(layout, labelSize, zoom).markers) { + expect(m.tap).toBeGreaterThanOrEqual(44); + } + } + }); + + it("draws Venus at its own place, not beside another body", () => { + const layout = layoutScene(scene, view, cameraAt(1)); + const venus = layout.locations.find((l) => l.id === "venus-cloud-city")!; + for (const other of layout.locations.filter((l) => l.id !== venus.id)) { + expect( + Math.hypot(venus.center.x - other.center.x, venus.center.y - other.center.y), + ).toBeGreaterThan((venus.size + other.size) / 2); + } + }); +}); diff --git a/apps/web/test/map-layout.test.ts b/apps/web/test/map-layout.test.ts index 184c7c4..36bf7ad 100644 --- a/apps/web/test/map-layout.test.ts +++ b/apps/web/test/map-layout.test.ts @@ -166,12 +166,12 @@ describe("labels and badges (Step 10C.6)", () => { it("puts a label below its body when that is clear", () => { const placed = decorate(layout([body("a", 100, 100)]), size); - expect(placed.get("a")!.label).toEqual({ x: 100, y: 120, anchorY: 0 }); + expect(placed.get("a")!.label).toEqual({ x: 100, y: 120, anchorX: 0.5, anchorY: 0 }); }); it("moves a label above its body when below would cover another body", () => { const placed = decorate(layout([body("a", 100, 100), body("b", 110, 140)]), size); - expect(placed.get("a")!.label).toEqual({ x: 100, y: 80, anchorY: 1 }); + expect(placed.get("a")!.label).toEqual({ x: 100, y: 80, anchorX: 0.5, anchorY: 1 }); }); it("puts the idle badge on a diagonal clear of other bodies", () => { diff --git a/docs/screenshots/16-8-new-bodies-desktop.png b/docs/screenshots/16-8-new-bodies-desktop.png new file mode 100644 index 0000000..7b5543f Binary files /dev/null and b/docs/screenshots/16-8-new-bodies-desktop.png differ diff --git a/docs/screenshots/16-8-new-bodies-jupiter-desktop.png b/docs/screenshots/16-8-new-bodies-jupiter-desktop.png new file mode 100644 index 0000000..d60e4d2 Binary files /dev/null and b/docs/screenshots/16-8-new-bodies-jupiter-desktop.png differ diff --git a/docs/screenshots/16-8-new-bodies-jupiter-phone.png b/docs/screenshots/16-8-new-bodies-jupiter-phone.png new file mode 100644 index 0000000..972c056 Binary files /dev/null and b/docs/screenshots/16-8-new-bodies-jupiter-phone.png differ diff --git a/docs/screenshots/16-8-new-bodies-phone.png b/docs/screenshots/16-8-new-bodies-phone.png new file mode 100644 index 0000000..97111e1 Binary files /dev/null and b/docs/screenshots/16-8-new-bodies-phone.png differ diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 6f2009d..ab0387d 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -400,5 +400,5 @@ Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as - [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.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 +- [x] **Step 16.8 — Map: new bodies and Jupiter-system layout.** Venus (30 px) sits at its fixed position; Io and Europa (18 px) at fixed screen offsets from Jupiter, 64 px out at 100° and −160° from the outward direction (clear of Jupiter and of the platform badge at the largest sprite scale), each with the small child ring the Moon has. Labels (decision 0.5.105): a child body's label first tries the side facing away from its parent; `planLabels` folds a family into its parent's label with a "+N" pill when their labels would overlap, or, for the Jupiter system, whenever zoom is below 2.5 (map style `labelDeclutter`); tapping "+N" (a 44 px target) opens the family, zooming in past 2.5 does too. Earth and the Moon only fold if their labels collide (they don't). The haze strip is now 2048 × 256 and repeats 8 times around the ring, so its specks don't streak when zoomed in. Tests (`apps/web/test/jupiter-system.test.ts`): Io and Europa exactly at their offsets from Jupiter at five zooms; at zoom 1 only Jupiter's label shows, with one "+2" marker right of it; at the threshold all three show without overlapping; tapping "+2" opens the family; Earth and the Moon both labelled; every place and marker tap target ≥ 44 px; Venus clear of every other body. The 12.5 browser test now waits for an Earth board offering a Mars first contact (since CR-016 a board may offer Venus instead), which made it flaky. Screenshots: `docs/screenshots/16-8-new-bodies-desktop.png`, `…-phone.png`, `…-jupiter-desktop.png`, `…-jupiter-phone.png` (zoom 4 on the Jupiter system). - [ ] Step 16.9 — Plan amendments and regression