2bd1b54936
Adds the gear-icon popover on the map view with per-game persistence of every category toggle plus the wrap-mode radio. Hide-by-id and visibility-fog facilities land on the renderer so every flip applies within one frame without a Pixi remount; the wrap-mode toggle keeps its existing remount + camera-preserve path. A new server-side turn force-resets every flag to defaults so a hidden category never makes the player miss the next turn's news. Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go (plus the single Go caller); the TS side keeps duplicating the formula until a race-level WASM bridge lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
// Geometry primitives used by the map renderer.
|
||
//
|
||
// All distances are in world units (TS numbers, float64). Functions
|
||
// in this file are pure and side-effect-free; tests exercise them
|
||
// directly.
|
||
|
||
import type { Camera, Viewport } from "./world";
|
||
|
||
// clamp returns v constrained to [lo, hi]. If lo > hi the function
|
||
// returns lo (callers are expected to keep the bounds well-formed).
|
||
export function clamp(v: number, lo: number, hi: number): number {
|
||
if (v < lo) return lo;
|
||
if (v > hi) return hi;
|
||
return v;
|
||
}
|
||
|
||
// torusShortestDelta returns the signed delta from a to b on a circle
|
||
// of circumference `size`, picking the direction with the smaller
|
||
// absolute distance. Result lies in (-size/2, size/2].
|
||
//
|
||
// At exactly size/2 the function returns +size/2 (positive direction);
|
||
// the lower bound is exclusive so a delta of -size/2 wraps to +size/2.
|
||
// This deterministic tie-break keeps the function self-consistent
|
||
// regardless of input order. The `+0` at the end normalises -0 (which
|
||
// JavaScript produces for some modulo cases) to +0.
|
||
export function torusShortestDelta(a: number, b: number, size: number): number {
|
||
if (!(size > 0)) {
|
||
throw new Error(`torusShortestDelta: size must be positive, got ${size}`);
|
||
}
|
||
let d = (b - a) % size;
|
||
if (d > size / 2) d -= size;
|
||
else if (d <= -size / 2) d += size;
|
||
return d + 0;
|
||
}
|
||
|
||
// torusShortestDistance returns the wrap-aware Euclidean distance
|
||
// between (ax, ay) and (bx, by) on a torus of size width × height.
|
||
// Built on top of `torusShortestDelta` so the two axes share the
|
||
// "shortest signed delta" semantics. Used by the Phase 29 reach
|
||
// filter (hide planets beyond `FlightDistance` of every LOCAL
|
||
// planet); both modes (torus / no-wrap) consume the same metric — in
|
||
// no-wrap mode the wrapped distance is never shorter than the
|
||
// straight-line one because the player cannot fly across the seam.
|
||
export function torusShortestDistance(
|
||
ax: number,
|
||
ay: number,
|
||
bx: number,
|
||
by: number,
|
||
width: number,
|
||
height: number,
|
||
): number {
|
||
const dx = torusShortestDelta(ax, bx, width);
|
||
const dy = torusShortestDelta(ay, by, height);
|
||
return Math.hypot(dx, dy);
|
||
}
|
||
|
||
// distSqPointToSegment returns the squared distance from point (px,py)
|
||
// to the segment (ax,ay)–(bx,by). For zero-length segments it falls
|
||
// back to point-to-point distance.
|
||
export function distSqPointToSegment(
|
||
px: number,
|
||
py: number,
|
||
ax: number,
|
||
ay: number,
|
||
bx: number,
|
||
by: number,
|
||
): number {
|
||
const dx = bx - ax;
|
||
const dy = by - ay;
|
||
const lenSq = dx * dx + dy * dy;
|
||
if (lenSq === 0) {
|
||
const ex = px - ax;
|
||
const ey = py - ay;
|
||
return ex * ex + ey * ey;
|
||
}
|
||
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
|
||
if (t < 0) t = 0;
|
||
else if (t > 1) t = 1;
|
||
const fx = ax + t * dx;
|
||
const fy = ay + t * dy;
|
||
const ex = px - fx;
|
||
const ey = py - fy;
|
||
return ex * ex + ey * ey;
|
||
}
|
||
|
||
// screenToWorld converts cursor pixel coordinates (relative to the
|
||
// viewport top-left) to world coordinates under the given camera.
|
||
export function screenToWorld(
|
||
cursorPx: { x: number; y: number },
|
||
camera: Camera,
|
||
viewport: Viewport,
|
||
): { x: number; y: number } {
|
||
const offX = cursorPx.x - viewport.widthPx / 2;
|
||
const offY = cursorPx.y - viewport.heightPx / 2;
|
||
return {
|
||
x: camera.centerX + offX / camera.scale,
|
||
y: camera.centerY + offY / camera.scale,
|
||
};
|
||
}
|
||
|
||
// worldToScreen converts a world-space point to viewport pixel
|
||
// coordinates under the given camera.
|
||
export function worldToScreen(
|
||
world: { x: number; y: number },
|
||
camera: Camera,
|
||
viewport: Viewport,
|
||
): { x: number; y: number } {
|
||
return {
|
||
x: viewport.widthPx / 2 + (world.x - camera.centerX) * camera.scale,
|
||
y: viewport.heightPx / 2 + (world.y - camera.centerY) * camera.scale,
|
||
};
|
||
}
|