3d8aa91973
Tests · UI / test (push) Successful in 3m4s
Pick overlay (anchor ring, cursor line, hover outline) was drawn into a single Pixi container — copies[ORIGIN_COPY_INDEX] — so any view of a wrap copy lost it: picker from A1/A2 to the right (across the seam) showed no hover highlight on A3's wrap copy, and the picker on A3 (x≈1.44, near the left edge) put its anchor far left of the viewport. Fix replicates the overlay across all nine torus copies (matching how primitives and fog already render) and switches the cursor-line endpoint to torus-shortest geometry via torusShortestDelta. Anchor and hover-outline coordinates stay canonical; the per-copy replication renders them under the user's view in whatever tile is on screen. Also reduces cargo-route arrow strokes: COL/CAP/MAT 2->0.6 wu and EMP 1->0.4 wu (~3 / ~2 screen px at typical zoom) per the owner's request. Tests cover the new torus path: source near the left edge with cursor on the wrap copy across the seam (x axis), source near the top edge with cursor across the y seam, and a guard that anchor / hover-outline coords stay canonical regardless of the world argument. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.1 KiB
TypeScript
180 lines
6.1 KiB
TypeScript
// 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. The stroke colours come from the
|
|
// active `Theme` (dark or light); the alpha and width are fixed.
|
|
//
|
|
// 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 { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } from "./world";
|
|
|
|
/**
|
|
* routeStylesByLoadType builds the per-load-type stroke styles for the
|
|
* active theme. A single `Style` object is shared by every line of a
|
|
* given load type within one call so the renderer can dedupe them.
|
|
*/
|
|
function routeStylesByLoadType(theme: Theme): Record<CargoLoadType, Style> {
|
|
return {
|
|
COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
|
CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
|
MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 0.6 },
|
|
EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 0.4 },
|
|
};
|
|
}
|
|
|
|
/** 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.
|
|
*
|
|
* `opts.skipPlanets` (Phase 29) is an optional set of planet numbers
|
|
* whose routes — outgoing or incoming — should be filtered out so the
|
|
* arrows do not point at hidden glyphs. Empty / undefined means no
|
|
* extra filtering, preserving the pre-Phase-29 contract.
|
|
*
|
|
* `theme` supplies the per-load-type stroke colours and defaults to
|
|
* `DARK_THEME`.
|
|
*/
|
|
export function buildCargoRouteLines(
|
|
report: GameReport,
|
|
opts?: { skipPlanets?: ReadonlySet<number> },
|
|
theme: Theme = DARK_THEME,
|
|
): LinePrim[] {
|
|
if (report.routes.length === 0) return [];
|
|
const skip = opts?.skipPlanets;
|
|
const styleByLoadType = routeStylesByLoadType(theme);
|
|
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) {
|
|
if (skip !== undefined && skip.has(route.sourcePlanetNumber)) continue;
|
|
const source = planetById.get(route.sourcePlanetNumber);
|
|
if (source === undefined) continue;
|
|
for (const entry of route.entries) {
|
|
if (skip !== undefined && skip.has(entry.destinationPlanetNumber)) {
|
|
continue;
|
|
}
|
|
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 = styleByLoadType[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)
|
|
);
|
|
}
|