From 0aa8cee9531ff4332fc39db85bed0be2e4ebbe5e Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 19:32:46 -0400 Subject: [PATCH] Step 10C.8: rotated ship sprites, routes and separation Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ apps/web/src/map/MapCanvas.tsx | 6 ++ apps/web/src/map/layout.ts | 78 ++++++++++++++---- apps/web/src/map/renderer.ts | 35 +++++++- apps/web/src/screens/MapScreen.tsx | 2 +- apps/web/test/map-ships.test.ts | 43 ++++++++++ .../10c8-ships-midgame-desktop-zoomed.png | Bin 0 -> 712438 bytes .../10c8-ships-midgame-desktop.png | Bin 0 -> 783880 bytes docs/screenshots/10c8-ships-midgame-phone.png | Bin 0 -> 1534145 bytes memory-bank/progress.md | 2 +- 10 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 apps/web/test/map-ships.test.ts create mode 100644 docs/screenshots/10c8-ships-midgame-desktop-zoomed.png create mode 100644 docs/screenshots/10c8-ships-midgame-desktop.png create mode 100644 docs/screenshots/10c8-ships-midgame-phone.png diff --git a/DECISIONS.md b/DECISIONS.md index 3fbf2f0..bed06c3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -734,3 +734,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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`. + +## Step 10C.8 + +- **Clustering threshold and spacing are both 44 px** (`shipSeparationPx` = the minimum tap size), so after nudging each ship has its own full tap target. Ships are grouped greedily by distance and spread along the normal of the first ship's route, centred on the group's average position. +- **Ships keep the sim position on the route line** except when nudged; the route line itself is not moved. +- **Highlight rings** (amber, 5 px outside the sprite) go on the selected ship, and on a selected location too, since "selection highlights" are in the CR's code-drawn list. +- **Earth–Moon flights stay close to both bodies**: with the Moon at a fixed 46 px from Earth (decision 0.5.39), those routes are always short on screen. That is the intended trade-off. diff --git a/apps/web/src/map/MapCanvas.tsx b/apps/web/src/map/MapCanvas.tsx index c5ea606..0621fc7 100644 --- a/apps/web/src/map/MapCanvas.tsx +++ b/apps/web/src/map/MapCanvas.tsx @@ -17,16 +17,21 @@ import { mapStyle } from "./style"; export function MapCanvas({ state, onSelect, + selected = null, }: { state: PlayerState; /** A tap on a location or ship, or on empty space (null) (Step 10.3). */ onSelect?: (selection: MapSelection | null) => void; + /** Highlighted with a ring (Step 10C.8). */ + selected?: MapSelection | null; }) { const containerRef = useRef(null); const stateRef = useRef(state); stateRef.current = state; const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; + const selectedRef = useRef(selected); + selectedRef.current = selected; const [loading, setLoading] = useState(true); useEffect(() => { @@ -64,6 +69,7 @@ export function MapCanvas({ buildScene(stateRef.current, serverNow()), viewport(), useCamera.getState(), + selectedRef.current, ), ); }) diff --git a/apps/web/src/map/layout.ts b/apps/web/src/map/layout.ts index 6be917e..26fd55c 100644 --- a/apps/web/src/map/layout.ts +++ b/apps/web/src/map/layout.ts @@ -105,7 +105,10 @@ export interface FlightLayout { /** Route ends on screen, matching where the locations are drawn. */ from: MapPoint; to: MapPoint; + /** Where the ship is drawn: on the route, nudged apart from nearby ships. */ center: MapPoint; + /** Radians; the art points up (Step 10C.8). */ + rotation: number; size: number; tap: number; } @@ -142,24 +145,69 @@ export function layoutScene( 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), - }; - }), + flights: separateShips( + 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, + }, + rotation: shipRotation(from, to), + size: shipSize, + tap: tapSize(shipSize, style), + }; + }), + style, + ), }; } +/** + * A ship sprite's rotation for travel from `from` to `to` (screen space): the + * art points up, so moving right is +90° and moving down is 180°. + */ +export function shipRotation(from: MapPoint, to: MapPoint): number { + return Math.atan2(to.y - from.y, to.x - from.x) + Math.PI / 2; +} + +/** + * Nudges ships that would be drawn within `shipSeparationPx` of each other + * apart, perpendicular to the first one's route, `shipSeparationPx` apart, so + * each stays visible and tappable (Step 10C.8). + */ +export function separateShips(flights: FlightLayout[], style: MapStyle = mapStyle): FlightLayout[] { + const gap = style.shipSeparationPx; + const clusters: FlightLayout[][] = []; + for (const flight of flights) { + const near = clusters.find((c) => + c.some((o) => Math.hypot(o.center.x - flight.center.x, o.center.y - flight.center.y) < gap), + ); + if (near) near.push(flight); + else clusters.push([flight]); + } + return clusters.flatMap((cluster) => { + if (cluster.length === 1) return cluster; + const first = cluster[0]!; + const dx = first.to.x - first.from.x; + const dy = first.to.y - first.from.y; + const length = Math.hypot(dx, dy) || 1; + const normal = { x: -dy / length, y: dx / length }; + const middle = { + x: cluster.reduce((sum, f) => sum + f.center.x, 0) / cluster.length, + y: cluster.reduce((sum, f) => sum + f.center.y, 0) / cluster.length, + }; + return cluster.map((flight, i) => { + const k = (i - (cluster.length - 1) / 2) * gap; + return { ...flight, center: { x: middle.x + normal.x * k, y: middle.y + normal.y * k } }; + }); + }); +} + interface Box { left: number; top: number; diff --git a/apps/web/src/map/renderer.ts b/apps/web/src/map/renderer.ts index 70e40e5..42d8c97 100644 --- a/apps/web/src/map/renderer.ts +++ b/apps/web/src/map/renderer.ts @@ -3,6 +3,7 @@ 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 type { MapSelection } from "./hitTest"; import { locationOverlays } from "./overlays"; import type { MapPoint } from "./projection"; import type { Scene } from "./scene"; @@ -127,7 +128,7 @@ export class MapRenderer { return text; } - draw(scene: Scene, view: Viewport, camera: Camera) { + draw(scene: Scene, view: Viewport, camera: Camera, selected: MapSelection | null = null) { const visuals = sceneVisuals(scene, this.textures); const layout = layoutScene(scene, view, camera); const sun = layout.sun.center; @@ -258,14 +259,24 @@ export class MapRenderer { } } - // In-flight ships: the route line and the ship on it (Step 10.2). + // In-flight ships (Steps 10.2, 10C.8): a faint route, brighter ahead of the + // ship, and the ship's sprite facing its direction of travel. for (const flight of layout.flights) { + const scened = scene.flights.find((f) => f.dispatchId === flight.dispatchId)!; + const onRoute = { + x: flight.from.x + (flight.to.x - flight.from.x) * scened.progress, + y: flight.from.y + (flight.to.y - flight.from.y) * scened.progress, + }; this.routes .moveTo(flight.from.x, flight.from.y) .lineTo(flight.to.x, flight.to.y) - .stroke({ width: 1, color: colors.routeBehind }); + .stroke({ width: 1, color: colors.routeBehind, alpha: 0.35 }); + this.routes + .moveTo(onRoute.x, onRoute.y) + .lineTo(flight.to.x, flight.to.y) + .stroke({ width: 1.5, color: colors.routeAhead, alpha: 0.9 }); const { center } = flight; - this.place( + const ship = this.place( `ship:${flight.dispatchId}`, visuals.flights.get(flight.dispatchId)!, this.ships, @@ -273,6 +284,22 @@ export class MapRenderer { flight.size, () => this.overlay.circle(center.x, center.y, 4).fill(colors.placeholderShip), ); + if (ship) ship.rotation = flight.rotation; + if (selected?.kind === "flight" && selected.dispatchId === flight.dispatchId) { + this.overlay + .circle(center.x, center.y, flight.size / 2 + 5) + .stroke({ width: 2, color: colors.selection }); + } + } + + // A selected location gets the same highlight ring. + if (selected?.kind === "location") { + const location = placed.get(selected.id); + if (location) { + this.overlay + .circle(location.center.x, location.center.y, location.size / 2 + 5) + .stroke({ width: 2, color: colors.selection }); + } } // Ships whose flight ended: drop their sprites (the texture is shared and kept). diff --git a/apps/web/src/screens/MapScreen.tsx b/apps/web/src/screens/MapScreen.tsx index b09b698..ebc68ed 100644 --- a/apps/web/src/screens/MapScreen.tsx +++ b/apps/web/src/screens/MapScreen.tsx @@ -30,7 +30,7 @@ export function MapScreen() { // area); -mb-20 cancels the layout's scroll padding so the page never scrolls.

Map

- +