eb5018342e
* 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>
229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
// 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;
|