Step 10C.6: sprite bodies, orbits, labels and tap targets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 19:25:01 -04:00
parent edc7925d0e
commit 5916cf8728
14 changed files with 489 additions and 76 deletions

View File

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

View File

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

View File

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

View File

@ -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<Texture>,
@ -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<string, Text>, 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);
}
// 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),
);
}

View File

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

View File

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

View File

@ -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<typeof body>[]): 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);
});
});

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -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/` (`<id>@1x.png`, `<id>@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` |

View File

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