// Map pick-mode contract: a generic "pick a destination on the map" // interaction the inspector triggers and the renderer drives. Phase // 16 adds the cargo-route picker on top of this; later phases // (19/20) drive ship-group dispatch through the same surface. // // The renderer-facing API lives on `RendererHandle.setPickMode` // (see `render.ts`); this module owns the option / handle types and // the pure overlay-draw helper that translates the pick state into a // drawing spec the renderer can lift straight onto a Pixi `Graphics`. // Keeping the math here means the lifecycle (dim / cursor line / // hover outline / click+Escape resolution) can be tested without // booting a Pixi `Application`. import { DEFAULT_POINT_RADIUS_PX, type PointPrim, type PrimitiveID } from "./world"; /** * PickModeOptions configures a pick-mode session. The caller is * responsible for computing `reachableIds` from the current report * (e.g. cargo routes apply the `40 * driveTech` rule before opening * the picker). The renderer never validates reach itself — it only * dims primitives whose id is missing from this set. */ export interface PickModeOptions { /** Numeric id of the source planet primitive. Stays full-alpha * during the session and anchors the cursor line. */ readonly sourcePrimitiveId: PrimitiveID; /** World coordinates of the source. Pre-computed so the renderer * can draw the anchor ring and the line endpoint without * crawling the primitive list. */ readonly sourceX: number; readonly sourceY: number; /** Ids whose primitives stay full-alpha and accept clicks. */ readonly reachableIds: ReadonlySet; /** Resolution callback. Fires with the chosen primitive id on a * successful pick, or `null` when the player cancels via Escape * or the imperative `cancel()` handle. */ readonly onPick: (id: PrimitiveID | null) => void; } export interface PickModeHandle { /** * cancel terminates the session immediately and resolves * `onPick(null)`. Idempotent — repeated calls are no-ops. */ cancel(): void; } /** * PickOverlaySpec is the pure description the renderer paints onto * its overlay graphic each frame. Keeps the lifecycle logic * Pixi-free so it can be exercised by Vitest. */ export interface PickOverlaySpec { /** Highlight ring around the source planet (slightly outside the * visible disc). */ readonly anchor: { readonly x: number; readonly y: number; readonly radius: number; }; /** Line from source to current cursor; `null` while the cursor * is off-canvas. */ readonly line: { readonly x1: number; readonly y1: number; readonly x2: number; readonly y2: number; } | null; /** Outline circle around the hovered reachable planet; `null` * when the hover is empty or aimed at a non-reachable primitive. */ readonly hoverOutline: { readonly x: number; readonly y: number; readonly radius: number; } | null; /** Ids to dim (alpha 0.3). Everything not in `reachableIds` and * not the source. */ readonly dimmedIds: ReadonlySet; } /** Anchor / hover outline padding in world units (the rings sit * outside the visible disc so the planet stays clearly visible). */ export const ANCHOR_PADDING_WORLD = 6; export const HOVER_PADDING_WORLD = 4; /** * computePickOverlay produces a `PickOverlaySpec` for the current * pick state. Pure: no DOM access, no Pixi calls. Callers prepare * `pointPrimitivesById` from the active world before invoking. */ export function computePickOverlay( options: PickModeOptions, cursorWorld: { x: number; y: number } | null, hoveredId: PrimitiveID | null, pointPrimitivesById: ReadonlyMap, allPrimitiveIds: Iterable, ): PickOverlaySpec { const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId); const sourceRadius = (sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) + ANCHOR_PADDING_WORLD; const dimmed = new Set(); for (const id of allPrimitiveIds) { if (id === options.sourcePrimitiveId) continue; if (options.reachableIds.has(id)) continue; dimmed.add(id); } const line = cursorWorld === null ? null : { x1: options.sourceX, y1: options.sourceY, x2: cursorWorld.x, y2: cursorWorld.y, }; let hoverOutline: PickOverlaySpec["hoverOutline"] = null; if ( hoveredId !== null && hoveredId !== options.sourcePrimitiveId && options.reachableIds.has(hoveredId) ) { const target = pointPrimitivesById.get(hoveredId); if (target !== undefined) { hoverOutline = { x: target.x, y: target.y, radius: (target.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) + HOVER_PADDING_WORLD, }; } } return { anchor: { x: options.sourceX, y: options.sourceY, radius: sourceRadius, }, line, hoverOutline, dimmedIds: dimmed, }; } /** * PICK_OVERLAY_STYLE captures the colours / widths the renderer * applies to each spec channel. Exported so tests and future themes * can read the same values. * * `dimAlpha` and `dimTint` are applied together to non-reachable * primitives during a pick session: the alpha drops their * brightness, and the tint multiplies their fill colour toward dark * gray so the colour identity (planet kind) collapses into a * single muted shade. The combination has to read as "obviously * disabled" against the dark theme — bright planets such as * `STYLE_LOCAL` (`0x6dd2ff`) survive a 0.3 alpha alone too * comfortably, so the tint pulls them down too. */ export const PICK_OVERLAY_STYLE = { anchor: { color: 0xffe082, alpha: 0.9, width: 2 }, line: { color: 0xffe082, alpha: 0.5, width: 1 }, hover: { color: 0xffe082, alpha: 1, width: 2 }, dimAlpha: 0.35, dimTint: 0x303841, } as const;