diff --git a/DECISIONS.md b/DECISIONS.md index fbea94e..f0c375a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -717,3 +717,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **No background art means no background sprite**: the canvas's solid background color is the placeholder. - **Map height fixed:** the Map section used `100dvh − 7.5rem` and stopped 42 px above the nav, which showed as a dark band under the new art. It now uses the real header and nav heights (4.875rem plus the safe-area inset) and cancels the layout's 5rem scroll padding, so the page does not scroll. - **Screenshots** come from a scratchpad Playwright harness driving local Chrome against a separate dev stack (API 3125, web 5199). The Vite proxy target is now configurable (`API_PROXY_TARGET`, default `http://127.0.0.1:3000`) for that. + +## Step 10C.6 + +- **The child offset angle is measured from the outward direction (Sun → parent on screen)**, not from screen right. With a fixed screen angle the Moon landed on the Sun whenever Earth was on the Sun's opposite side at phone width (seen in the first screenshot). The distance is still a fixed screen offset (decision 0.5.39), and the tests check the exact offset at several zooms and dates. +- **Moon distance 46 px** (the style config draft said 30): at the largest sprite scale Earth and the Moon need (32 + 18) / 2 × 1.75 = 43.75 px to clear each other. +- **Sprite scale = zoom ^ 0.35, clamped to 0.75–1.75** (new `spriteScale.zoomExponent`): sprites grow a little when zooming in and are about the same size at the default zoom. +- **Scene anchors:** a child body's scene `point` is its parent's map point, and flights carry origin and destination ids. `layoutScene` adds the screen offset, so routes to and from the Moon end exactly at the drawn Moon. `locationMapPoint` (map-space Moon offset) is kept but no longer drawn. +- **Labels and badges avoid collisions** with a small greedy pass: a label goes below its body, else above it if below hits another body or label; an idle badge takes the first clear diagonal. This handles Earth and the Moon and long names (Jupiter Cloud Platform next to Mars) without special cases. +- **Tap targets** come from the same layout as drawing: each is a circle of the larger of the sprite size and 44 px. Ships still win ties. +- **The idle badge sits just off the body's edge** (2 px, up-left by default) instead of a fixed distance from the centre, so it clears big bodies like Jupiter. diff --git a/apps/web/src/map/hitTest.ts b/apps/web/src/map/hitTest.ts index 7978427..84c89f4 100644 --- a/apps/web/src/map/hitTest.ts +++ b/apps/web/src/map/hitTest.ts @@ -1,16 +1,15 @@ -import { toScreen, type Camera, type Viewport } from "./camera"; +import type { Camera, Viewport } from "./camera"; +import { layoutScene } from "./layout"; import type { MapPoint } from "./projection"; import type { Scene } from "./scene"; export type MapSelection = { kind: "location"; id: string } | { kind: "flight"; dispatchId: string }; -/** How close (screen pixels) a tap must be to a location or ship: about a fingertip. */ -export const TAP_RADIUS = 22; - /** - * What a tap at `tap` (screen pixels) selects: the nearest ship marker or - * location within reach. Ships win ties, since they are drawn on top. + * What a tap at `tap` (screen pixels) selects: the nearest ship or location + * whose tap target (at least `minTapPx`, Step 10C.6) contains it, as laid out + * on screen. Ships win ties, since they are drawn on top. */ export function hitTest( scene: Scene, @@ -18,19 +17,19 @@ export function hitTest( viewport: Viewport, camera: Camera, ): MapSelection | null { + const layout = layoutScene(scene, viewport, camera); let best: { selection: MapSelection; distance: number } | null = null; - const consider = (point: MapPoint, selection: MapSelection, bias: number) => { - const p = toScreen(point, viewport, camera); - const distance = Math.hypot(p.x - tap.x, p.y - tap.y) - bias; - if (distance <= TAP_RADIUS && (!best || distance < best.distance)) { - best = { selection, distance }; + const consider = (center: MapPoint, size: number, selection: MapSelection, bias: number) => { + const distance = Math.hypot(center.x - tap.x, center.y - tap.y); + if (distance <= size / 2 && (!best || distance - bias < best.distance)) { + best = { selection, distance: distance - bias }; } }; - for (const location of scene.locations) { - consider(location.point, { kind: "location", id: location.id }, 0); + for (const location of layout.locations) { + consider(location.center, location.tap, { kind: "location", id: location.id }, 0); } - for (const flight of scene.flights) { - consider(flight.marker, { kind: "flight", dispatchId: flight.dispatchId }, 4); + for (const flight of layout.flights) { + consider(flight.center, flight.tap, { kind: "flight", dispatchId: flight.dispatchId }, 4); } return (best as { selection: MapSelection } | null)?.selection ?? null; } diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index b41958e..6be917e 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -1,4 +1,6 @@ -import type { Camera, Viewport } from "./camera"; +import { toScreen, type Camera, type Viewport } from "./camera"; +import type { MapPoint } from "./projection"; +import type { Scene } from "./scene"; import { mapStyle, type MapStyle } from "./style"; /** @@ -41,3 +43,197 @@ export function backgroundRect( height, }; } + +/** + * Sprite size factor at a zoom (decision 0.5.38): sprites grow a little as the + * player zooms in and shrink a little as they zoom out, within the limits. + */ +export function spriteScale(zoom: number, style: MapStyle = mapStyle): number { + const { min, max, zoomExponent } = style.spriteScale; + return Math.min(max, Math.max(min, zoom ** zoomExponent)); +} + +/** A screen-space offset of `distance` px at `angleDeg` (clockwise from screen right). */ +export function polar(distance: number, angleDeg: number): MapPoint { + const a = (angleDeg * Math.PI) / 180; + return { x: Math.cos(a) * distance, y: Math.sin(a) * distance }; +} + +/** + * A child body's screen offset from its parent (decision 0.5.39): the + * configured distance, at the configured angle from the outward direction + * (Sun → parent on screen); zero for other locations. + */ +export function childOffset( + locationId: string, + parent: MapPoint, + sun: MapPoint, + style: MapStyle = mapStyle, +): MapPoint { + const child = style.childOffsets[locationId]; + if (!child) return { x: 0, y: 0 }; + const dx = parent.x - sun.x; + const dy = parent.y - sun.y; + const outward = dx === 0 && dy === 0 ? 0 : (Math.atan2(dy, dx) * 180) / Math.PI; + return polar(child.distancePx, outward + child.angleDeg); +} + +/** Base on-screen size (long side, px) of a sprite asset. */ +export function baseSpriteSize(assetId: string, style: MapStyle = mapStyle): number { + return style.spriteSizePx[assetId] ?? style.shipSizePx; +} + +/** A tap target is the sprite's size, but never under the minimum (Step 10C.6). */ +export function tapSize(spriteSize: number, style: MapStyle = mapStyle): number { + return Math.max(style.minTapPx, spriteSize); +} + +export interface BodyLayout { + center: MapPoint; + /** Drawn size, long side (px). */ + size: number; + /** Tap target, square side (px). */ + tap: number; +} + +export interface LocationLayout extends BodyLayout { + id: string; +} + +export interface FlightLayout { + dispatchId: string; + /** Route ends on screen, matching where the locations are drawn. */ + from: MapPoint; + to: MapPoint; + center: MapPoint; + size: number; + tap: number; +} + +export interface SceneLayout { + scale: number; + sun: BodyLayout; + locations: LocationLayout[]; + flights: FlightLayout[]; +} + +const add = (a: MapPoint, b: MapPoint): MapPoint => ({ x: a.x + b.x, y: a.y + b.y }); + +/** Where and how big everything in the scene is drawn on screen (CR-003). */ +export function layoutScene( + scene: Scene, + view: Viewport, + camera: Camera, + style: MapStyle = mapStyle, +): SceneLayout { + const scale = spriteScale(camera.zoom, style); + const sunCenter = toScreen({ x: 0, y: 0 }, view, camera); + /** Screen point of a (possibly child) location whose anchor is at map point `point`. */ + const drawn = (point: MapPoint, locationId: string | null): MapPoint => { + const anchor = toScreen(point, view, camera); + return locationId ? add(anchor, childOffset(locationId, anchor, sunCenter, style)) : anchor; + }; + const body = (point: MapPoint, assetId: string, locationId: string | null): BodyLayout => { + const size = baseSpriteSize(assetId, style) * scale; + return { center: drawn(point, locationId), size, tap: tapSize(size, style) }; + }; + const shipSize = style.shipSizePx * scale; + return { + scale, + sun: body({ x: 0, y: 0 }, "sun", null), + locations: scene.locations.map((l) => ({ id: l.id, ...body(l.point, l.sprite, l.id) })), + flights: scene.flights.map((f) => { + const from = drawn(f.from, f.originId); + const to = drawn(f.to, f.destinationId); + return { + dispatchId: f.dispatchId, + from, + to, + center: { + x: from.x + (to.x - from.x) * f.progress, + y: from.y + (to.y - from.y) * f.progress, + }, + size: shipSize, + tap: tapSize(shipSize, style), + }; + }), + }; +} + +interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +const overlaps = (a: Box, b: Box) => + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + +const square = (center: MapPoint, size: number): Box => ({ + left: center.x - size / 2, + top: center.y - size / 2, + right: center.x + size / 2, + bottom: center.y + size / 2, +}); + +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 }; + /** Idle-ship badge centre. */ + badge: MapPoint; +} + +/** + * 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. + */ +export function decorate( + layout: SceneLayout, + labelSize: (id: string) => { width: number; height: number }, + style: MapStyle = mapStyle, +): Map { + const bodies = [ + { id: "sun", box: square(layout.sun.center, layout.sun.size) }, + ...layout.locations.map((l) => ({ id: l.id, box: square(l.center, l.size) })), + ]; + const labels: Box[] = []; + const clearOfBodies = (box: Box, ownId: string) => + !bodies.some((b) => b.id !== ownId && overlaps(box, b.box)); + const clearOfLabels = (box: Box) => !labels.some((l) => overlaps(box, l)); + + const placed = layout.locations.map((location) => { + const { width, height } = 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 result = new Map(); + for (const { location, label } 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))); + const badge = + spots.find((p) => { + const box = square(p, radiusPx * 2); + return clearOfBodies(box, location.id) && clearOfLabels(box); + }) ?? spots[0]!; + result.set(location.id, { label, badge }); + } + return result; +} diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts index d18a0ad..e953025 100644 --- a/apps/web/src/map/renderer.ts +++ b/apps/web/src/map/renderer.ts @@ -2,18 +2,13 @@ import { Container, Graphics, Sprite, Text, type Texture } from "pixi.js"; import type { ArtTextures } from "./art/load"; import { sceneVisuals, type Visual } from "./art/visuals"; import { toScreen, type Camera, type Viewport } from "./camera"; -import { backgroundRect } from "./layout"; +import { backgroundRect, decorate, layoutScene, polar } from "./layout"; import type { MapPoint } from "./projection"; import type { Scene } from "./scene"; import { mapStyle } from "./style"; const { colors } = mapStyle; -/** Base on-screen size (long side, px) of a sprite asset. */ -function baseSize(assetId: string): number { - return mapStyle.spriteSizePx[assetId] ?? mapStyle.shipSizePx; -} - /** Fits a sprite's long side to `size` px, keeping its aspect ratio. */ function fit(sprite: Sprite, size: number) { const { width, height } = sprite.texture; @@ -21,9 +16,9 @@ function fit(sprite: Sprite, size: number) { } /** - * Draws the map with PixiJS (Steps 10.1–10.2, CR-003). Sprites and text are - * created once per item and updated every frame; code-drawn shapes go in a few - * Graphics layers that are redrawn each frame. + * 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 + * every frame, and code-drawn shapes go in Graphics layers redrawn each frame. */ export class MapRenderer { /** Non-tiled, parallax background (decision 0.5.40); absent if its art is missing. */ @@ -60,6 +55,7 @@ export class MapRenderer { return sprite; } + /** Draws `visual` as a sprite at `at`, or calls `placeholder` (decision 0.5.41). */ private place( key: string, visual: Visual, @@ -67,16 +63,32 @@ export class MapRenderer { at: MapPoint, size: number, placeholder: () => void, - ) { - if (visual.kind === "placeholder") return placeholder(); + ): Sprite | null { + if (visual.kind === "placeholder") { + placeholder(); + return null; + } const sprite = this.sprite(key, visual.texture, layer); fit(sprite, size); sprite.position.set(at.x, at.y); + return sprite; + } + + /** A text for `key` in the text layer, created on first use. */ + private text(map: Map, key: string, create: () => Text): Text { + let text = map.get(key); + if (!text) { + text = create(); + map.set(key, text); + this.texts.addChild(text); + } + return text; } draw(scene: Scene, view: Viewport, camera: Camera) { const visuals = sceneVisuals(scene, this.textures); - const sun = toScreen({ x: 0, y: 0 }, view, camera); + const layout = layoutScene(scene, view, camera); + const sun = layout.sun.center; const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x; for (const sprite of this.sprites.values()) sprite.visible = false; @@ -90,77 +102,118 @@ export class MapRenderer { this.routes.clear(); this.overlay.clear(); + const placed = new Map(layout.locations.map((l) => [l.id, l])); + // Faint orbit rings: around the Sun at each body's current compressed + // radius, and a small one around a parent for its child (the Moon). for (const location of scene.locations) { if (location.orbitRadius !== null) { this.orbits .circle(sun.x, sun.y, location.orbitRadius * unit) .stroke({ width: 1, color: colors.orbit, alpha: colors.orbitAlpha }); } + const child = mapStyle.childOffsets[location.id]; + const parent = location.parentId ? placed.get(location.parentId) : undefined; + if (child && parent) { + this.orbits + .circle(parent.center.x, parent.center.y, child.distancePx) + .stroke({ width: 1, color: colors.orbit, alpha: colors.orbitAlpha }); + } } - this.place("sun", visuals.sun, this.bodies, sun, baseSize("sun"), () => + + this.place("sun", visuals.sun, this.bodies, sun, layout.sun.size, () => this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun), ); for (const location of scene.locations) { - const p = toScreen(location.point, view, camera); - const color = location.discovered ? colors.placeholderBody : colors.labelUndiscovered; - const size = baseSize(location.sprite); + const { center: p, size } = placed.get(location.id)!; + const placeholderColor = location.discovered + ? colors.placeholderBody + : colors.labelUndiscovered; this.place( `loc:${location.id}`, visuals.locations.get(location.id)!, this.bodies, p, size, - () => this.overlay.circle(p.x, p.y, 5).fill(color), + () => this.overlay.circle(p.x, p.y, 5).fill(placeholderColor), ); + + // Location badge art (Jupiter's cloud platform) on the body's edge, if provided. + const badgeArt = visuals.badges.get(location.id); + if (badgeArt) { + const edge = polar(size / 2, mapStyle.locationBadge.angleDeg); + const at = { x: p.x + edge.x, y: p.y + edge.y }; + const badgeSize = mapStyle.locationBadge.sizePx * layout.scale; + this.place(`badge:${location.id}`, badgeArt, this.bodies, at, badgeSize, () => {}); + } + if (location.hasBay) { this.overlay.circle(p.x, p.y, size / 2 + 4).stroke({ width: 2, color: colors.hq }); } - let label = this.labels.get(location.id); - if (!label) { - label = new Text({ text: location.name, style: { fontSize: 12, fill: color } }); - this.labels.set(location.id, label); - this.texts.addChild(label); - } + // The name under the body, with a dark halo for contrast on the background. + const label = this.text(this.labels, location.id, () => { + const text = new Text({ + text: location.name, + style: { + fontSize: mapStyle.label.fontSizePx, + fontWeight: "600", + fill: colors.label, + stroke: { color: colors.labelHalo, width: mapStyle.label.haloPx * 2, join: "round" }, + }, + }); + text.anchor.set(0.5, 0); + return text; + }); // Undiscovered locations are locked until a delivery reaches them (Step 10A.16). label.text = location.discovered ? location.name : `🔒 ${location.name}`; label.style.fill = location.discovered ? colors.label : colors.labelUndiscovered; - label.position.set(p.x + size / 2 + 4, p.y - 7); - // Idle ships here: a small badge with the count (Step 10.2). - let badge = this.badges.get(location.id); - if (!badge) { - badge = new Text({ text: "", style: { fontSize: 10, fill: colors.badgeText } }); - badge.anchor.set(0.5); - this.badges.set(location.id, badge); - this.texts.addChild(badge); - } + // Idle ships here: a count badge just off the body's edge (Step 10.2). + const badge = this.text(this.badges, location.id, () => { + const text = new Text({ + text: "", + style: { + fontSize: mapStyle.idleBadge.fontSizePx, + fontWeight: "700", + fill: colors.badgeText, + }, + }); + text.anchor.set(0.5); + return text; + }); badge.visible = location.idleShips > 0; - if (badge.visible) { - const b = { x: p.x - size / 2 - 4, y: p.y + size / 2 + 4 }; - this.overlay.circle(b.x, b.y, 7).fill(colors.badgeFill); - badge.text = String(location.idleShips); - badge.position.set(b.x, b.y); + badge.text = String(location.idleShips); + } + + // Labels below (or above) bodies, badges on a clear diagonal (Step 10C.6). + const decorations = decorate(layout, (id) => this.labels.get(id)!); + for (const location of scene.locations) { + const { label, badge } = decorations.get(location.id)!; + const text = this.labels.get(location.id)!; + text.anchor.set(0.5, label.anchorY); + text.position.set(label.x, label.y); + const count = this.badges.get(location.id)!; + if (count.visible) { + this.overlay.circle(badge.x, badge.y, mapStyle.idleBadge.radiusPx).fill(colors.badgeFill); + count.position.set(badge.x, badge.y); } } // In-flight ships: the route line and the ship on it (Step 10.2). - for (const flight of scene.flights) { - const from = toScreen(flight.from, view, camera); - const to = toScreen(flight.to, view, camera); - const marker = toScreen(flight.marker, view, camera); + for (const flight of layout.flights) { this.routes - .moveTo(from.x, from.y) - .lineTo(to.x, to.y) + .moveTo(flight.from.x, flight.from.y) + .lineTo(flight.to.x, flight.to.y) .stroke({ width: 1, color: colors.routeBehind }); + const { center } = flight; this.place( `ship:${flight.dispatchId}`, visuals.flights.get(flight.dispatchId)!, this.ships, - marker, - mapStyle.shipSizePx, - () => this.overlay.circle(marker.x, marker.y, 4).fill(colors.placeholderShip), + center, + flight.size, + () => this.overlay.circle(center.x, center.y, 4).fill(colors.placeholderShip), ); } diff --git a/apps/web/src/map/scene.ts b/apps/web/src/map/scene.ts index c0a4c7a..9de2c7c 100644 --- a/apps/web/src/map/scene.ts +++ b/apps/web/src/map/scene.ts @@ -2,11 +2,18 @@ import { content } from "@deadhead/content"; import type { Dispatch, PlayerState } from "@deadhead/schema"; import { bodyPosition, dispatchProgress, loanerModel, realToGame } from "@deadhead/sim"; import { locationMapPoint, radialScale, type MapPoint } from "./projection"; +import { mapStyle } from "./style"; export interface SceneLocation { id: string; name: string; + /** + * Map position; for a child body (the Moon) its parent's position, since + * children are drawn at a fixed screen offset from it (decision 0.5.39). + */ point: MapPoint; + /** The parent location of a child body, else null. */ + parentId: string | null; /** Map radius of its orbit around the Sun (the faint ring); null for moons. */ orbitRadius: number | null; discovered: boolean; @@ -22,7 +29,9 @@ export interface SceneFlight { dispatchId: string; /** The ship's id, or "loaner" for a tutorial run. */ shipId: string; - /** Origin at departure and destination at arrival: the route line. */ + originId: string; + destinationId: string; + /** Origin at departure and destination at arrival (anchors; see `point`): the route line. */ from: MapPoint; to: MapPoint; /** Where the ship is now, on the route line. */ @@ -44,6 +53,11 @@ function location(id: string) { return found; } +/** The location whose map position a location is drawn from: its parent, if it has one. */ +export function anchorLocation(id: string) { + return location(mapStyle.childOffsets[id]?.parentId ?? id); +} + /** * A flight's route and current marker (Step 10.2). The marker moves along the * straight route line by the sim's progress, in map space, so it always stays @@ -51,17 +65,19 @@ function location(id: string) { */ export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight { const from = locationMapPoint( - location(dispatch.originId), + anchorLocation(dispatch.originId), realToGame(dispatch.departureAt, content.config), ); const to = locationMapPoint( - location(dispatch.destinationId), + anchorLocation(dispatch.destinationId), realToGame(dispatch.arrivalAt, content.config), ); const progress = dispatchProgress(dispatch, now); return { dispatchId: dispatch.id, shipId: dispatch.shipId, + originId: dispatch.originId, + destinationId: dispatch.destinationId, from, to, marker: { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress }, @@ -86,7 +102,8 @@ export function buildScene(state: PlayerState, now: number): Scene { return { id: loc.id, name: loc.name, - point: locationMapPoint(loc, gameTime), + point: locationMapPoint(anchorLocation(loc.id), gameTime), + parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null, orbitRadius: loc.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)), discovered: state.locations.some((l) => l.id === loc.id && l.discovered), hasBay: state.bays.some((b) => b.locationId === loc.id), diff --git a/apps/web/src/map/style.ts b/apps/web/src/map/style.ts index 5dbe7f4..48f041f 100644 --- a/apps/web/src/map/style.ts +++ b/apps/web/src/map/style.ts @@ -15,11 +15,21 @@ export const mapStyleSchema = z spriteSizePx: z.record(z.string(), px), /** Every ship sprite's base size (long side, px). */ shipSizePx: px, - /** Sprite size factor limits as the player zooms (decision 0.5.38). */ - spriteScale: z.object({ min: px, max: px }), - /** Child bodies drawn at a fixed screen offset from their parent (decision 0.5.39). */ + /** + * Sprite size factor as the player zooms (decision 0.5.38): zoom ^ exponent, + * clamped to [min, max], so sprites stay roughly the same size on screen. + */ + spriteScale: z.object({ min: px, max: px, zoomExponent: z.number().min(0).max(1) }), + /** + * Child bodies drawn at a fixed screen distance from their parent (decision + * 0.5.39). The angle is measured from the outward direction (Sun → parent), + * so a child never lands on the Sun. + */ childOffsets: z.record(z.string(), offset.extend({ parentId: z.string() })), - /** Idle-ship count badge, from the body's center. */ + /** + * Idle-ship count badge: `distancePx` beyond the body's edge, at `angleDeg`, + * or the first of the other diagonals that is clear of bodies and labels. + */ idleBadge: offset.extend({ radiusPx: px, fontSizePx: px }), /** Location badge art (e.g. Jupiter's cloud platform), on the body's edge. */ locationBadge: z.object({ sizePx: px, angleDeg: z.number() }), @@ -69,9 +79,10 @@ export const mapStyle: MapStyle = mapStyleSchema.parse({ "body-moon": 18, }, shipSizePx: 24, - spriteScale: { min: 0.75, max: 1.75 }, - childOffsets: { moon: { parentId: "earth", distancePx: 30, angleDeg: -45 } }, - idleBadge: { distancePx: 20, angleDeg: 135, radiusPx: 8, fontSizePx: 10 }, + 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 } }, + idleBadge: { distancePx: 2, angleDeg: -135, radiusPx: 8, fontSizePx: 10 }, locationBadge: { sizePx: 16, angleDeg: -45 }, minTapPx: 44, parallax: 0.15, diff --git a/apps/web/test/map-layout.test.ts b/apps/web/test/map-layout.test.ts index b5414b4..ba2a185 100644 --- a/apps/web/test/map-layout.test.ts +++ b/apps/web/test/map-layout.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera"; -import { backgroundRect } from "../src/map/layout"; +import { + backgroundRect, + decorate, + layoutScene, + polar, + spriteScale, + type SceneLayout, +} from "../src/map/layout"; +import { buildScene } from "../src/map/scene"; +import { mapStyle } from "../src/map/style"; +import { samplePlayerState } from "./render"; const viewports = [ { width: 390, height: 700 }, @@ -54,3 +64,119 @@ describe("background placement (decision 0.5.40)", () => { expect(far.width / one.width).toBeLessThan(1.3); }); }); + +const dates = [ + Date.parse("2100-01-01T00:00:00Z"), + Date.parse("2100-03-17T08:00:00Z"), + Date.parse("2101-11-02T19:30:00Z"), +]; +const view = { width: 390, height: 700 }; +const cameraAt = (zoom: number) => ({ zoom, panX: 13, panY: -27 }); + +describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => { + it("keeps the sprite scale within the configured limits at extreme zooms", () => { + const { min, max } = mapStyle.spriteScale; + for (const zoom of [1e-6, MIN_ZOOM, 0.75, 1, 2, MAX_ZOOM, 1e6]) { + expect(spriteScale(zoom)).toBeGreaterThanOrEqual(min); + expect(spriteScale(zoom)).toBeLessThanOrEqual(max); + } + expect(spriteScale(1e-6)).toBe(min); + expect(spriteScale(1e6)).toBe(max); + expect(spriteScale(1)).toBe(1); + }); + + it("puts the Moon exactly the configured offset from Earth, at any zoom and date", () => { + const { distancePx, angleDeg } = mapStyle.childOffsets.moon!; + for (const now of dates) { + const scene = buildScene(samplePlayerState(), now); + for (const zoom of [MIN_ZOOM, 1, 7, MAX_ZOOM]) { + const layout = layoutScene(scene, view, cameraAt(zoom)); + const earth = layout.locations.find((l) => l.id === "earth")!; + const moon = layout.locations.find((l) => l.id === "moon")!; + // The angle is measured from the outward direction, Sun → Earth. + const outward = + (Math.atan2(earth.center.y - layout.sun.center.y, earth.center.x - layout.sun.center.x) * + 180) / + Math.PI; + const offset = polar(distancePx, outward + angleDeg); + expect(moon.center.x - earth.center.x).toBeCloseTo(offset.x, 9); + expect(moon.center.y - earth.center.y).toBeCloseTo(offset.y, 9); + // And the two sprites never overlap. + expect(Math.hypot(offset.x, offset.y)).toBeGreaterThanOrEqual((earth.size + moon.size) / 2); + } + } + }); + + it("starts and ends Moon routes where the Moon is drawn", () => { + const now = dates[0]!; + const state = samplePlayerState({ + activeDispatches: [ + { + id: "d", + shipId: "loaner", + pilotId: "avatar", + contract: null, + originId: "earth", + destinationId: "moon", + tutorial: true, + departureAt: now, + arrivalAt: now + 60_000, + distanceMillionKm: 0.4, + fuelCost: 0, + expectedPayout: 0, + seed: 1, + status: "in-flight", + resolvedAt: null, + }, + ], + }); + const layout = layoutScene(buildScene(state, now + 60_000), view, cameraAt(1)); + const moon = layout.locations.find((l) => l.id === "moon")!; + expect(layout.flights[0]!.to.x).toBeCloseTo(moon.center.x, 6); + expect(layout.flights[0]!.to.y).toBeCloseTo(moon.center.y, 6); + }); + + it("never makes a tap target smaller than the minimum", () => { + const scene = buildScene(samplePlayerState(), dates[0]!); + for (const zoom of [1e-6, MIN_ZOOM, 1, MAX_ZOOM]) { + const layout = layoutScene(scene, view, cameraAt(zoom)); + for (const item of [layout.sun, ...layout.locations, ...layout.flights]) { + expect(item.tap).toBeGreaterThanOrEqual(mapStyle.minTapPx); + expect(item.tap).toBeGreaterThanOrEqual(item.size); + } + } + }); +}); + +describe("labels and badges (Step 10C.6)", () => { + const body = (id: string, x: number, y: number, size = 32) => ({ + id, + center: { x, y }, + size, + tap: 44, + }); + const layout = (locations: ReturnType[]): SceneLayout => ({ + scale: 1, + sun: { center: { x: -1000, y: -1000 }, size: 64, tap: 64 }, + locations, + flights: [], + }); + const size = () => ({ width: 60, height: 14 }); + + 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 }); + }); + + 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 }); + }); + + it("puts the idle badge on a diagonal clear of other bodies", () => { + // The default spot (up-left) is taken by another body. + const placed = decorate(layout([body("a", 100, 100), body("b", 70, 70)]), size); + const badge = placed.get("a")!.badge; + expect(Math.hypot(badge.x - 70, badge.y - 70)).toBeGreaterThan(24); + }); +}); diff --git a/apps/web/test/map-style.test.ts b/apps/web/test/map-style.test.ts index 0066ddc..d4498d1 100644 --- a/apps/web/test/map-style.test.ts +++ b/apps/web/test/map-style.test.ts @@ -11,7 +11,7 @@ describe("map style config (CR-003)", () => { { ...mapStyle, shipSizePx: 0 }, { ...mapStyle, spriteSizePx: { ...mapStyle.spriteSizePx, sun: -1 } }, { ...mapStyle, minTapPx: 0 }, - { ...mapStyle, spriteScale: { min: 2, max: 1 } }, + { ...mapStyle, spriteScale: { min: 2, max: 1, zoomExponent: 0.35 } }, { ...mapStyle, parallax: 1.5 }, { ...mapStyle, parallax: -0.1 }, ]; diff --git a/docs/screenshots/10c6-bodies-midgame-desktop.png b/docs/screenshots/10c6-bodies-midgame-desktop.png new file mode 100644 index 0000000..4530a51 Binary files /dev/null and b/docs/screenshots/10c6-bodies-midgame-desktop.png differ diff --git a/docs/screenshots/10c6-bodies-midgame-phone.png b/docs/screenshots/10c6-bodies-midgame-phone.png new file mode 100644 index 0000000..16ce270 Binary files /dev/null and b/docs/screenshots/10c6-bodies-midgame-phone.png differ diff --git a/docs/screenshots/10c6-bodies-tutorial-desktop.png b/docs/screenshots/10c6-bodies-tutorial-desktop.png new file mode 100644 index 0000000..34e6d42 Binary files /dev/null and b/docs/screenshots/10c6-bodies-tutorial-desktop.png differ diff --git a/docs/screenshots/10c6-bodies-tutorial-phone.png b/docs/screenshots/10c6-bodies-tutorial-phone.png new file mode 100644 index 0000000..ca75cbd Binary files /dev/null and b/docs/screenshots/10c6-bodies-tutorial-phone.png differ diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 81985c1..924f5f0 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -454,6 +454,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen | `scripts/map-art.mjs` | Generates `public/assets/map-gen/` (`@1x.png`, `@2x.png`, `bg.webp`) from the manifest with sharp; output committed | | `src/map/style.ts` | Map presentation config (CR-003): sprite sizes, zoom scale limits, child-body offsets, badges, tap size, parallax, colors; validated by `mapStyleSchema` | | `src/map/art/load.ts`, `art/visuals.ts` | Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41) | +| `src/map/layout.ts` | Pure screen layout: `backgroundRect`, `spriteScale`, `childOffset`, `layoutScene` (centres, sizes, tap targets; used by renderer and hit test), `decorate` (label and badge placement) | | `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/components/*` | `AwaySummary` dialog, `Countdown` | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 193dda4..6d6a400 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -154,7 +154,7 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as - [x] **Step 10C.3 — Map style config.** `apps/web/src/map/style.ts`: `mapStyleSchema` (Zod) and `mapStyle` with sprite sizes (Sun 64, Jupiter 48, Earth/Mars 32, Ceres 22, Moon 18, ships 24 px), sprite scale 0.75–1.75×, Moon offset 30 px at −45° from Earth, idle and location badge offsets, 44 px minimum tap, parallax 0.15, background margin 10%, label and colors. Test: `map-style.test.ts`. - [x] **Step 10C.4 — Asset loading and fallback.** `map/art/load.ts`: `artBundle(dpr)` (2x at DPR ≥ 2) registered once as PixiJS bundle `map-art`, each asset loaded separately so a failure only costs that asset (one warning, placeholder). `map/art/visuals.ts` picks texture or placeholder per item; the scene now carries sprite keys and each flight's ship model (loaner → Company Courier). Drawing moved from `MapCanvas` into `map/renderer.ts` (`MapRenderer`); the map shows "Loading map…" until art and renderer are ready. Test: `map-art-loading.test.ts`. - [x] **Step 10C.5 — Background.** `map/layout.ts` `backgroundRect`: cover viewport + 10% margin each side, zoom follow `zoom^0.05` (never below cover), pan × 0.15 parallax, clamped so no edge shows; drawn as one sprite under everything. The Map screen now fills the space down to the nav (it stopped 42 px short). Test: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c5-background-tutorial-{desktop,phone}{,-panned}.png`. -- [ ] Step 10C.6 — Bodies, orbits, and labels +- [x] **Step 10C.6 — Bodies, orbits, and labels.** `layoutScene` (map/layout.ts) gives every body, the Sun and each flight a screen centre, size (base × `spriteScale(zoom)` = zoom^0.35 clamped 0.75–1.75) and tap size (≥ 44 px); hit-testing uses it. The Moon is drawn 46 px from Earth at −30° from the Sun→Earth direction, with a faint ring around Earth; Moon routes start and end there. `decorate` places labels (bold, dark halo) below or above to avoid bodies and other labels, and idle badges on a clear diagonal. Jupiter's badge art is drawn on its edge when provided (not yet). Tests: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c6-bodies-{tutorial,midgame}-{desktop,phone}.png`. - [ ] Step 10C.7 — Location state overlays - [ ] Step 10C.8 — Ships in flight - [ ] Step 10C.9 — Performance