Step 10C.7: locks, HQ markers and idle badges on the map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 19:29:01 -04:00
parent 5916cf8728
commit 09a89b2a74
11 changed files with 180 additions and 18 deletions

View File

@ -727,3 +727,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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.
## Step 10C.7
- **Overlays are decided by a pure function** (`locationOverlays`) that the renderer applies. PixiJS `Text` cannot be measured under jsdom, so the scene test checks this plan (filter, lock, HQ, badge) rather than live display objects. The manual screenshots cover the drawing.
- **The padlock is drawn over the body's centre** and the label loses its 🔒 emoji. Undiscovered labels keep the muted color.
- **The HQ marker is a small green house** placed beyond the label (below a label that is below the body, above one that is above), so it never sits between label and body. The old green ring around bay bodies is gone.
- **Lock arc fix:** a bare `Graphics.arc` in PixiJS continues the current path, which drew long stray lines. The lock now starts its shackle with `moveTo`.

View File

@ -0,0 +1,27 @@
import type { Scene } from "./scene";
import { mapStyle, type MapStyle } from "./style";
/** How a location's state is shown on the map (Step 10C.7). */
export interface LocationOverlay {
id: string;
/** Undiscovered: a code-drawn lock over the body. */
locked: boolean;
/** Undiscovered: the sprite is desaturated (color matrix filter) and dimmed. */
desaturate: boolean;
alpha: number;
/** The player has a bay (HQ) here: a small marker by the label. */
hq: boolean;
/** Idle ships here, as a count badge; null for none. Idle ships are never drawn singly. */
idleBadge: number | null;
}
export function locationOverlays(scene: Scene, style: MapStyle = mapStyle): LocationOverlay[] {
return scene.locations.map((l) => ({
id: l.id,
locked: !l.discovered,
desaturate: !l.discovered,
alpha: l.discovered ? 1 : style.undiscoveredAlpha,
hq: l.hasBay,
idleBadge: l.idleShips > 0 ? l.idleShips : null,
}));
}

View File

@ -1,8 +1,9 @@
import { Container, Graphics, Sprite, Text, type Texture } from "pixi.js";
import { ColorMatrixFilter, 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, decorate, layoutScene, polar } from "./layout";
import { locationOverlays } from "./overlays";
import type { MapPoint } from "./projection";
import type { Scene } from "./scene";
import { mapStyle } from "./style";
@ -15,6 +16,44 @@ function fit(sprite: Sprite, size: number) {
sprite.scale.set(size / Math.max(width, height, 1));
}
/** A padlock (undiscovered location), `size` px tall, centred on `at`. */
function drawLock(g: Graphics, at: MapPoint, size: number) {
const w = size * 0.75;
const h = size * 0.55;
const top = at.y - size / 2 + size * 0.45;
// moveTo first: a bare arc would join on from the previous path point.
g.moveTo(at.x - w * 0.3, top)
.arc(at.x, top, w * 0.3, Math.PI, 0)
.stroke({ width: size * 0.14, color: colors.lock });
g.roundRect(at.x - w / 2, top, w, h, size * 0.12)
.fill(colors.lock)
.stroke({ width: 1.5, color: colors.labelHalo });
g.circle(at.x, top + h * 0.45, size * 0.08).fill(colors.labelHalo);
}
/** The HQ (bay) marker: a small house, `size` px, centred on `at`. */
function drawHq(g: Graphics, at: MapPoint, size: number) {
const s = size / 2;
g.poly([
at.x - s,
at.y,
at.x,
at.y - s,
at.x + s,
at.y,
at.x + s * 0.7,
at.y,
at.x + s * 0.7,
at.y + s,
at.x - s * 0.7,
at.y + s,
at.x - s * 0.7,
at.y,
])
.fill(colors.hq)
.stroke({ width: 1, color: colors.labelHalo });
}
/**
* 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
@ -32,11 +71,14 @@ export class MapRenderer {
private readonly sprites = new Map<string, Sprite>();
private readonly labels = new Map<string, Text>();
private readonly badges = new Map<string, Text>();
/** One shared filter for every undiscovered location's sprite. */
private readonly desaturate = new ColorMatrixFilter();
constructor(
stage: Container,
private readonly textures: ArtTextures<Texture>,
) {
this.desaturate.desaturate();
const bg = textures.get("bg");
this.background = bg ? new Sprite({ texture: bg }) : null;
if (this.background) stage.addChild(this.background);
@ -124,19 +166,25 @@ export class MapRenderer {
this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun),
);
const overlays = new Map(locationOverlays(scene).map((o) => [o.id, o]));
for (const location of scene.locations) {
const { center: p, size } = placed.get(location.id)!;
const placeholderColor = location.discovered
? colors.placeholderBody
: colors.labelUndiscovered;
this.place(
const state = overlays.get(location.id)!;
const placeholderColor = state.locked ? colors.labelUndiscovered : colors.placeholderBody;
const sprite = this.place(
`loc:${location.id}`,
visuals.locations.get(location.id)!,
this.bodies,
p,
size,
() => this.overlay.circle(p.x, p.y, 5).fill(placeholderColor),
() =>
this.overlay.circle(p.x, p.y, 5).fill({ color: placeholderColor, alpha: state.alpha }),
);
// Undiscovered: desaturated and dimmed (Step 10C.7).
if (sprite) {
sprite.filters = state.desaturate ? [this.desaturate] : [];
sprite.alpha = state.alpha;
}
// Location badge art (Jupiter's cloud platform) on the body's edge, if provided.
const badgeArt = visuals.badges.get(location.id);
@ -144,12 +192,18 @@ export class MapRenderer {
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, () => {});
const art = this.place(
`badge:${location.id}`,
badgeArt,
this.bodies,
at,
badgeSize,
() => {},
);
if (art) art.alpha = state.alpha;
}
if (location.hasBay) {
this.overlay.circle(p.x, p.y, size / 2 + 4).stroke({ width: 2, color: colors.hq });
}
if (state.locked) drawLock(this.overlay, p, mapStyle.lockSizePx);
// The name under the body, with a dark halo for contrast on the background.
const label = this.text(this.labels, location.id, () => {
@ -165,11 +219,9 @@ export class MapRenderer {
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.style.fill = state.locked ? colors.labelUndiscovered : colors.label;
// Idle ships here: a count badge just off the body's edge (Step 10.2).
// Idle ships here: a count badge just off the body's edge; never single sprites.
const badge = this.text(this.badges, location.id, () => {
const text = new Text({
text: "",
@ -182,17 +234,23 @@ export class MapRenderer {
text.anchor.set(0.5);
return text;
});
badge.visible = location.idleShips > 0;
badge.text = String(location.idleShips);
badge.visible = state.idleBadge !== null;
badge.text = String(state.idleBadge ?? "");
}
// Labels below (or above) bodies, badges on a clear diagonal (Step 10C.6).
// 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)!);
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);
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);
}
const count = this.badges.get(location.id)!;
if (count.visible) {
this.overlay.circle(badge.x, badge.y, mapStyle.idleBadge.radiusPx).fill(colors.badgeFill);

View File

@ -33,6 +33,11 @@ export const mapStyleSchema = z
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() }),
/** Opacity of an undiscovered location's (desaturated) sprite. */
undiscoveredAlpha: z.number().min(0).max(1),
/** Code-drawn lock (undiscovered) and HQ marker sizes, px. */
lockSizePx: px,
hqSizePx: px,
/** No tap target is smaller than this (px, square). */
minTapPx: px,
/** Background moves this fraction of the camera pan (decision 0.5.40). */
@ -84,6 +89,9 @@ export const mapStyle: MapStyle = mapStyleSchema.parse({
childOffsets: { moon: { parentId: "earth", distancePx: 46, angleDeg: -30 } },
idleBadge: { distancePx: 2, angleDeg: -135, radiusPx: 8, fontSizePx: 10 },
locationBadge: { sizePx: 16, angleDeg: -45 },
undiscoveredAlpha: 0.55,
lockSizePx: 12,
hqSizePx: 9,
minTapPx: 44,
parallax: 0.15,
backgroundMargin: 0.1,

View File

@ -0,0 +1,61 @@
import type { Ship } from "@deadhead/schema";
import { describe, expect, it } from "vitest";
import { locationOverlays } from "../src/map/overlays";
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 idleAtMoon = (id: string): Ship => ({
id,
modelId: "courier",
name: id,
status: "idle",
locationId: "moon",
dispatchId: null,
pilotId: null,
lastArrivalAt: 0,
});
/** After the deal: an Earth bay, two idle ships at the Moon, Mars and beyond undiscovered. */
const scene = () =>
buildScene(
samplePlayerState({
tutorial: null,
bays: [{ id: "b1", locationId: "earth" }],
ships: [idleAtMoon("s1"), idleAtMoon("s2")],
}),
NOW,
);
describe("location state overlays (Step 10C.7)", () => {
it("desaturates, dims and locks undiscovered locations only", () => {
const overlays = new Map(locationOverlays(scene()).map((o) => [o.id, o]));
for (const id of ["mars", "ceres-station", "jupiter-cloud-platform"]) {
expect(overlays.get(id), id).toMatchObject({
locked: true,
desaturate: true,
alpha: mapStyle.undiscoveredAlpha,
});
}
for (const id of ["earth", "moon"]) {
expect(overlays.get(id), id).toMatchObject({ locked: false, desaturate: false, alpha: 1 });
}
});
it("marks bay locations as HQs", () => {
const hqs = locationOverlays(scene())
.filter((o) => o.hq)
.map((o) => o.id);
expect(hqs).toEqual(["earth"]);
});
it("shows two idle ships as one badge reading 2, and no single ship sprites", () => {
const s = scene();
const moon = locationOverlays(s).find((o) => o.id === "moon")!;
expect(moon.idleBadge).toBe(2);
expect(locationOverlays(s).find((o) => o.id === "earth")!.idleBadge).toBeNull();
// Ships are only drawn for flights; idle ships have none.
expect(s.flights).toEqual([]);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -455,6 +455,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
| `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/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer |
| `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

@ -155,7 +155,7 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as
- [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`.
- [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
- [x] **Step 10C.7 — Location state overlays.** `map/overlays.ts` `locationOverlays(scene)` decides per location: undiscovered → desaturated (one shared `ColorMatrixFilter`), alpha 0.55 and a code-drawn padlock (replaces the 🔒 label of Step 10A.16; the tap panel is unchanged); bay → a small green house marker on the far side of the label; idle ships → count badge only. Test: `map-overlays.test.ts`. **Manual check:** `docs/screenshots/10c7-overlays-{tutorial,midgame}-{desktop,phone}.png`.
- [ ] Step 10C.8 — Ships in flight
- [ ] Step 10C.9 — Performance
- [ ] Step 10C.10 — Plan amendments and regression