8a236bef14
The cargo-route picker filtered out unidentified planets, so an early-game player who had spotted but not surveyed a destination could not configure a route to it — the engine has no such restriction (`game/internal/controller/route.go.PlanetRouteSet` only checks ownership of the origin and `util.ShortDistance(...) <= FligthDistance`). Drop the unidentified guard and document the contract in `cargo-routes-ux.md` plus a comment over `reachableSet()`. Pick-mode dim now drops both alpha and tint on out-of-reach planets so bright shapes (`STYLE_LOCAL` is `0x6dd2ff`) collapse into a single muted gray. The single-channel `dimAlpha=0.3` was too gentle against the dark theme — the user reported the dim wasn't visible. Tighten to `dimAlpha=0.35 + dimTint=0x303841`; restore both on tear-down. Also threads through the user's `pkg/calc/race.go.FligthDistance` addition: `calc-bridge.md` records the new Go-side reference (the engine's `Race.FlightDistance()` already wraps it), and the picker comment points at the canonical formula location. Tests: - `inspector-planet-cargo-routes.test.ts` adds two cases — a reach-spans-every-kind case (own + foreign + uninhabited + unidentified all picked when in range) and a successful pick to an unidentified destination. - All 356 vitest cases + chromium-desktop / webkit-desktop e2e cargo-routes pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
171 lines
5.6 KiB
TypeScript
171 lines
5.6 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 { 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<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 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<PrimitiveID, PointPrim>,
|
|
allPrimitiveIds: Iterable<PrimitiveID>,
|
|
): PickOverlaySpec {
|
|
const sourcePrim = pointPrimitivesById.get(options.sourcePrimitiveId);
|
|
const sourceRadius =
|
|
(sourcePrim?.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX) +
|
|
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);
|
|
}
|
|
|
|
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;
|