Files
galaxy-game/ui/frontend/src/map/pick-mode.ts
T
Ilia Denisov eb5018342e
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
feat(ui): F8-12 — owner feedback round 2 (#55)
* Bug fix: theme flip no longer leaves planets oversized. The
  camera-preserving remount now calls a new
  `RendererHandle.refreshCameraDerivedDraws` explicitly after the
  manual moveCenter/setZoom pair so the post-mount geometry tracks
  `viewport.scaled` even if pixi-viewport's `'zoomed'` listener
  races the next Ticker tick.
* Doc #3: clicks on a planet label route through the same hit-test
  path as a click on the disc. The label `Container` now has a
  pointer hit area sized to the text + frame padding; pointertap
  simulates a click at the planet centre, so selection and
  pick-mode resolution behave identically.
* Doc #4: battle X-crosses + cargo arrowhead wings grow
  sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New
  `Style.softLengthAnchor` ('center' / 'start') makes the renderer
  treat the recorded endpoints as the geometry "at the reference
  scale" and rescale around the midpoint (X-cross) or the start
  endpoint (arrow wings). Arrowhead base length is halved from 6
  to 3 world units to match the owner's "in half" request.
* Doc #5: picker overlay loses the anchor ring at the source, the
  cursor line drops to a cargo-route-thin 0.6 px stroke, and the
  hover ring around the destination is replaced by a planet-style
  outline (visible disc + 1 px padding) in the `pickHighlight`
  accent — so candidate destinations read like selection in warm
  yellow.
* Doc #6: regression test pins the in-disc hit zone.
* Perf #1: camera-driven redraws are throttled onto the next
  Ticker tick. A rapid wheel / pinch burst now coalesces into at
  most one `clear() + redraw` pass per painted frame, which keeps
  the 500-planet map responsive on zoom and toggle flips.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:40:20 +02:00

229 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { torusShortestDelta } from "./math";
import {
displayPointRadiusWorld,
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<PrimitiveID>;
/** 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<PrimitiveID>;
}
/** Anchor / hover outline padding. F8-12 / #5 retired the anchor
* ring from the picker overlay, so `ANCHOR_PADDING_WORLD` is now
* dead — kept exported for legacy test coverage that asserts the
* spec stays shaped the same way. `HOVER_PADDING_PX` is the
* screen-pixel gap the picker hover-ring leaves between the
* destination disc edge and the stroke; it matches the regular
* planet outline (`OUTLINE_RADIUS_PADDING_PX` in `render.ts`) so
* "selection" and "pick hover" outlines feel identical at every
* zoom. */
export const ANCHOR_PADDING_WORLD = 6;
export const HOVER_PADDING_PX = 1;
/**
* 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.
*
* `world` enables torus-wrap geometry for the cursor line: in torus
* mode the renderer paints the overlay graphic into every torus copy
* (`render.ts.openPickMode`), so the line endpoint is computed as
* `(sourceX + torusShortestDelta(...), sourceY + ...)` — extending
* beyond `[0, W) × [0, H)` when the short path crosses the seam. With
* 9-copy replication the user sees a short line in whatever copy is
* under the cursor. Pass `null` (the default) for no-wrap mode, where
* the line goes straight from source to canonical cursor.
*
* `anchor` and `hoverOutline` stay at the canonical source / target
* coordinates regardless of mode: the copy with the offset that puts
* them under the user's view gets rendered by the renderer's
* per-copy graphics, the others fall off-screen and consume nothing.
*/
export function computePickOverlay(
options: PickModeOptions,
cursorWorld: { x: number; y: number } | null,
hoveredId: PrimitiveID | null,
pointPrimitivesById: ReadonlyMap<PrimitiveID, PointPrim>,
allPrimitiveIds: Iterable<PrimitiveID>,
world: { width: number; height: number } | null = null,
cameraScale: number = 1,
scaleRef: number = 1,
): PickOverlaySpec {
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
const sourceVisibleRadius =
sourcePrim === undefined
? 0
: displayPointRadiusWorld(sourcePrim.style, cameraScale, scaleRef);
const sourceRadius = sourceVisibleRadius + ANCHOR_PADDING_WORLD;
const dimmed = new Set<PrimitiveID>();
for (const id of allPrimitiveIds) {
if (id === options.sourcePrimitiveId) continue;
if (options.reachableIds.has(id)) continue;
dimmed.add(id);
}
let line: PickOverlaySpec["line"] = null;
if (cursorWorld !== null) {
if (world !== null) {
const dx = torusShortestDelta(
options.sourceX,
cursorWorld.x,
world.width,
);
const dy = torusShortestDelta(
options.sourceY,
cursorWorld.y,
world.height,
);
line = {
x1: options.sourceX,
y1: options.sourceY,
x2: options.sourceX + dx,
y2: options.sourceY + dy,
};
} else {
line = {
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) {
const targetRadius = displayPointRadiusWorld(
target.style,
cameraScale,
scaleRef,
);
const paddingWorld = cameraScale > 0 ? HOVER_PADDING_PX / cameraScale : 0;
hoverOutline = {
x: target.x,
y: target.y,
radius: targetRadius + paddingWorld,
};
}
}
return {
anchor: {
x: options.sourceX,
y: options.sourceY,
radius: sourceRadius,
},
line,
hoverOutline,
dimmedIds: dimmed,
};
}
/**
* PICK_OVERLAY_STYLE captures the per-channel alphas and widths the
* renderer applies to the pick overlay, plus the dim alpha for
* non-reachable primitives. The colours themselves — the highlight
* colour shared by the anchor / line / hover channels and the dim
* multiply tint — come from the active `Theme` (`pickHighlight`,
* `pickDimTint`) so the overlay tracks the light / dark palette.
*
* `dimAlpha` and `Theme.pickDimTint` are applied together to
* non-reachable primitives during a pick session: the alpha drops
* their brightness while the tint collapses the colour identity
* (planet kind) into a single muted shade, so the disabled set reads
* as obviously inert against the map background.
*/
export const PICK_OVERLAY_STYLE = {
anchor: { alpha: 0.9, widthPx: 2 },
// F8-12 / #5: cursor line uses the same screen-pixel thickness as
// a regular cargo-route shaft (0.6 px), and the hover ring around
// the destination matches the planet-outline stroke (1.5 px). The
// renderer divides by `cameraScale` before drawing so the values
// stay constant on screen at any zoom.
line: { alpha: 0.95, widthPx: 0.6 },
hover: { alpha: 0.95, widthPx: 1.5 },
dimAlpha: 0.35,
} as const;