diff --git a/DECISIONS.md b/DECISIONS.md index f0c375a..3fbf2f0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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`. diff --git a/apps/web/src/map/overlays.ts b/apps/web/src/map/overlays.ts new file mode 100644 index 0000000..30b68e6 --- /dev/null +++ b/apps/web/src/map/overlays.ts @@ -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, + })); +} diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts index e953025..70e40e5 100644 --- a/apps/web/src/map/renderer.ts +++ b/apps/web/src/map/renderer.ts @@ -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(); private readonly labels = new Map(); private readonly badges = new Map(); + /** One shared filter for every undiscovered location's sprite. */ + private readonly desaturate = new ColorMatrixFilter(); constructor( stage: Container, private readonly textures: ArtTextures, ) { + 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); diff --git a/apps/web/src/map/style.ts b/apps/web/src/map/style.ts index 48f041f..db13fb3 100644 --- a/apps/web/src/map/style.ts +++ b/apps/web/src/map/style.ts @@ -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, diff --git a/apps/web/test/map-overlays.test.ts b/apps/web/test/map-overlays.test.ts new file mode 100644 index 0000000..145c693 --- /dev/null +++ b/apps/web/test/map-overlays.test.ts @@ -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([]); + }); +}); diff --git a/docs/screenshots/10c7-overlays-midgame-desktop.png b/docs/screenshots/10c7-overlays-midgame-desktop.png new file mode 100644 index 0000000..e03f7c2 Binary files /dev/null and b/docs/screenshots/10c7-overlays-midgame-desktop.png differ diff --git a/docs/screenshots/10c7-overlays-midgame-phone.png b/docs/screenshots/10c7-overlays-midgame-phone.png new file mode 100644 index 0000000..23c2d28 Binary files /dev/null and b/docs/screenshots/10c7-overlays-midgame-phone.png differ diff --git a/docs/screenshots/10c7-overlays-tutorial-desktop.png b/docs/screenshots/10c7-overlays-tutorial-desktop.png new file mode 100644 index 0000000..7ebba32 Binary files /dev/null and b/docs/screenshots/10c7-overlays-tutorial-desktop.png differ diff --git a/docs/screenshots/10c7-overlays-tutorial-phone.png b/docs/screenshots/10c7-overlays-tutorial-phone.png new file mode 100644 index 0000000..14677b3 Binary files /dev/null and b/docs/screenshots/10c7-overlays-tutorial-phone.png differ diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 924f5f0..f6662d7 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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` | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 6d6a400..3b83069 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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