ui/phase-16: cargo routes inspector + map pick foundation
Add per-planet cargo routes (COL/CAP/MAT/EMP) to the inspector with a renderer-driven destination picker (faded out-of-reach planets, cursor-line anchor, hover-highlight) and per-route arrows on the map. The pick-mode primitives are exposed via `MapPickService` so ship-group dispatch in Phase 19/20 can reuse the same surface. Pass A — generic map foundation: - hit-test now sizes the click zone to `pointRadiusPx + slopPx` so the visible disc is always part of the target. - `RendererHandle` gains `onPointerMove`, `onHoverChange`, `setPickMode`, `getPickState`, `getPrimitiveAlpha`, `setExtraPrimitives`, `getPrimitives`. The click dispatcher is centralised: pick-mode swallows clicks atomically so the standard selection consumers do not race against teardown. - `MapPickService` (`lib/map-pick.svelte.ts`) wraps the renderer contract in a promise-shaped `pick(...)`. The in-game shell layout owns the service so sidebar and bottom-sheet inspectors see the same instance. - Debug-surface registry exposes `getMapPrimitives`, `getMapPickState`, `getMapCamera` to e2e specs without spawning a separate debug page after navigation. Pass B — cargo-route feature: - `CargoLoadType`, `setCargoRoute`, `removeCargoRoute` typed variants with `(source, loadType)` collapse rule on the order draft; round-trip through the FBS encoder/decoder. - `GameReport` decodes `routes` and the local player's drive tech for the inline reach formula (40 × drive). `applyOrderOverlay` upserts/drops route entries for valid/submitting/applied commands. - `lib/inspectors/planet/cargo-routes.svelte` renders the four-slot section. `Add` / `Edit` call `MapPickService.pick`, `Remove` emits `removeCargoRoute`. - `map/cargo-routes.ts` builds shaft + arrowhead primitives per cargo type; the map view pushes them through `setExtraPrimitives` so the renderer never re-inits Pixi on route mutations (Pixi 8 doesn't support that on a reused canvas). Docs: - `docs/cargo-routes-ux.md` covers engine semantics + UI map. - `docs/renderer.md` documents pick mode and the debug surface. - `docs/calc-bridge.md` records the Phase 16 reach waiver. - `PLAN.md` rewrites Phase 16 to reflect the foundation + feature split and the decisions baked in (map-driven picker, inline reach, optimistic overlay via `setExtraPrimitives`). Tests: - `tests/map-pick-mode.test.ts` — pure overlay-spec helper. - `tests/map-cargo-routes.test.ts` — `buildCargoRouteLines`. - `tests/inspector-planet-cargo-routes.test.ts` — slot rendering, picker invocation, collapse, cancel, remove. - Extensions to `order-draft`, `submit`, `order-load`, `order-overlay`, `state-binding`, `inspector-planet`, `inspector-overlay`, `game-shell-sidebar`, `game-shell-header`. - `tests/e2e/cargo-routes.spec.ts` — Playwright happy path: add COL, add CAP, remove COL, asserting both the inspector and the arrow count via `__galaxyDebug.getMapPrimitives()`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// Map-side cargo-route arrows. Each `ReportRouteEntry` becomes a
|
||||
// short arrow from the source planet to its destination, drawn as
|
||||
// three `LinePrim` segments — one shaft and two arrowhead wings —
|
||||
// styled per load type so the four cargo kinds are
|
||||
// distinguishable at a glance. Phase 16 ships placeholder
|
||||
// colours; Phase 35 polish picks final values.
|
||||
//
|
||||
// Geometry uses `torusShortestDelta` so an arrow that crosses the
|
||||
// torus seam takes the wrap, not the long way round, matching the
|
||||
// engine's reach test (`util.ShortDistance`,
|
||||
// `pkg/util/map.go.deltas`).
|
||||
|
||||
import type { GameReport, ReportPlanet } from "../api/game-state";
|
||||
import type { CargoLoadType } from "../sync/order-types";
|
||||
import { torusShortestDelta } from "./math";
|
||||
import type { LinePrim, PrimitiveID, Style } from "./world";
|
||||
|
||||
export const STYLE_ROUTE_COL: Style = {
|
||||
strokeColor: 0x4fc3f7,
|
||||
strokeAlpha: 0.95,
|
||||
strokeWidthPx: 2,
|
||||
};
|
||||
export const STYLE_ROUTE_CAP: Style = {
|
||||
strokeColor: 0xffb74d,
|
||||
strokeAlpha: 0.95,
|
||||
strokeWidthPx: 2,
|
||||
};
|
||||
export const STYLE_ROUTE_MAT: Style = {
|
||||
strokeColor: 0x81c784,
|
||||
strokeAlpha: 0.95,
|
||||
strokeWidthPx: 2,
|
||||
};
|
||||
export const STYLE_ROUTE_EMP: Style = {
|
||||
strokeColor: 0x90a4ae,
|
||||
strokeAlpha: 0.85,
|
||||
strokeWidthPx: 1,
|
||||
};
|
||||
|
||||
const STYLE_BY_LOAD_TYPE: Record<CargoLoadType, Style> = {
|
||||
COL: STYLE_ROUTE_COL,
|
||||
CAP: STYLE_ROUTE_CAP,
|
||||
MAT: STYLE_ROUTE_MAT,
|
||||
EMP: STYLE_ROUTE_EMP,
|
||||
};
|
||||
|
||||
/** Per-load-type priority. Higher wins hit-test ties; planets sit
|
||||
* at 1..4 (`state-binding.ts.priorityFor`), so route arrows always
|
||||
* lose to planet primitives. The internal ordering follows the
|
||||
* engine's COL > CAP > MAT > EMP preference so when two arrows
|
||||
* overlap exactly, the higher-priority cargo wins the click. */
|
||||
const PRIORITY_BY_LOAD_TYPE: Record<CargoLoadType, number> = {
|
||||
COL: 8,
|
||||
CAP: 7,
|
||||
MAT: 6,
|
||||
EMP: 5,
|
||||
};
|
||||
|
||||
const LOAD_TYPE_INDEX: Record<CargoLoadType, number> = {
|
||||
COL: 0,
|
||||
CAP: 1,
|
||||
MAT: 2,
|
||||
EMP: 3,
|
||||
};
|
||||
|
||||
/** High-bit prefix on every cargo-route line id so it cannot
|
||||
* collide with a planet number (planets use uint64 numbers ≪
|
||||
* 2^31). The renderer's hit-test treats ids opaquely; the
|
||||
* inspector never resolves a planet by a line id, so the prefix
|
||||
* is internal-only. */
|
||||
export const ROUTE_LINE_ID_PREFIX = 0x80000000;
|
||||
|
||||
const SHAFT_OFFSET = 0;
|
||||
const WING_LEFT_OFFSET = 1;
|
||||
const WING_RIGHT_OFFSET = 2;
|
||||
|
||||
/** Arrowhead size in world units. Picked so the head is visible
|
||||
* at default zoom but does not eat the destination planet glyph. */
|
||||
const HEAD_LENGTH_WORLD = 6;
|
||||
/** Half-angle of the arrowhead opening, in radians (~25°). */
|
||||
const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* buildCargoRouteLines emits one `LinePrim` per shaft + two per
|
||||
* arrowhead wing for every (source, loadType, destination) entry
|
||||
* in `report.routes`. Skips routes whose source or destination is
|
||||
* not present in the planet list (e.g. a destination newly
|
||||
* unidentified after a turn cutoff). Pure: relies only on the
|
||||
* report; no DOM access; no Pixi calls.
|
||||
*/
|
||||
export function buildCargoRouteLines(report: GameReport): LinePrim[] {
|
||||
if (report.routes.length === 0) return [];
|
||||
const planetById = new Map<number, ReportPlanet>();
|
||||
for (const planet of report.planets) {
|
||||
planetById.set(planet.number, planet);
|
||||
}
|
||||
const lines: LinePrim[] = [];
|
||||
for (const route of report.routes) {
|
||||
const source = planetById.get(route.sourcePlanetNumber);
|
||||
if (source === undefined) continue;
|
||||
for (const entry of route.entries) {
|
||||
const dest = planetById.get(entry.destinationPlanetNumber);
|
||||
if (dest === undefined) continue;
|
||||
const dx = torusShortestDelta(source.x, dest.x, report.mapWidth);
|
||||
const dy = torusShortestDelta(source.y, dest.y, report.mapHeight);
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (length === 0) continue;
|
||||
const headX = source.x + dx;
|
||||
const headY = source.y + dy;
|
||||
const ux = dx / length;
|
||||
const uy = dy / length;
|
||||
const cosA = Math.cos(HEAD_HALF_ANGLE);
|
||||
const sinA = Math.sin(HEAD_HALF_ANGLE);
|
||||
const leftX = headX - HEAD_LENGTH_WORLD * (ux * cosA + uy * sinA);
|
||||
const leftY = headY - HEAD_LENGTH_WORLD * (uy * cosA - ux * sinA);
|
||||
const rightX = headX - HEAD_LENGTH_WORLD * (ux * cosA - uy * sinA);
|
||||
const rightY = headY - HEAD_LENGTH_WORLD * (uy * cosA + ux * sinA);
|
||||
const baseId = routeLineBaseId(
|
||||
route.sourcePlanetNumber,
|
||||
entry.loadType,
|
||||
);
|
||||
const style = STYLE_BY_LOAD_TYPE[entry.loadType];
|
||||
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
|
||||
lines.push({
|
||||
kind: "line",
|
||||
id: baseId + SHAFT_OFFSET,
|
||||
priority,
|
||||
style,
|
||||
hitSlopPx: 0,
|
||||
x1: source.x,
|
||||
y1: source.y,
|
||||
x2: headX,
|
||||
y2: headY,
|
||||
});
|
||||
lines.push({
|
||||
kind: "line",
|
||||
id: baseId + WING_LEFT_OFFSET,
|
||||
priority,
|
||||
style,
|
||||
hitSlopPx: 0,
|
||||
x1: headX,
|
||||
y1: headY,
|
||||
x2: leftX,
|
||||
y2: leftY,
|
||||
});
|
||||
lines.push({
|
||||
kind: "line",
|
||||
id: baseId + WING_RIGHT_OFFSET,
|
||||
priority,
|
||||
style,
|
||||
hitSlopPx: 0,
|
||||
x1: headX,
|
||||
y1: headY,
|
||||
x2: rightX,
|
||||
y2: rightY,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Unique numeric id for a route's three line primitives. The
|
||||
* three segments occupy `baseId + 0..2`. Encoded as
|
||||
* `prefix | (source << 8) | (loadTypeIndex << 4)` so a planet
|
||||
* number up to 2^23 and the four load-type slots fit without
|
||||
* collision. */
|
||||
function routeLineBaseId(
|
||||
sourcePlanetNumber: number,
|
||||
loadType: CargoLoadType,
|
||||
): PrimitiveID {
|
||||
return (
|
||||
ROUTE_LINE_ID_PREFIX |
|
||||
((sourcePlanetNumber & 0x7fffff) << 8) |
|
||||
(LOAD_TYPE_INDEX[loadType] << 4)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user