Files
galaxy-game/ui/frontend/src/map/math.ts
T
Ilia Denisov db415f8aa4 ui/phase-9: PixiJS map renderer with torus and no-wrap modes
Stand up the vector map renderer in ui/frontend/src/map/ on top of
PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container
copies for seamless wrap; no-wrap mode pins the camera at world
bounds and centres on an axis when the viewport exceeds the world
along that axis. Hit-test is a brute-force pass with deterministic
[-priority, distSq, kindOrder, id] ordering and torus-shortest
distance, validated by hand-built unit cases.

The development playground at /__debug/map exposes a window
debug surface for the Playwright spec, which forces WebGPU on
chromium-desktop, WebGL on webkit-desktop, and accepts the
auto-picked backend on mobile projects.

Algorithm spec lives in ui/docs/renderer.md, which also pins the
new deprecation status of galaxy/client (the entire Fyne client
module, including client/world). client/world/README.md and the
Phase 9 stub in ui/PLAN.md gain matching deprecation banners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:06:23 +02:00

92 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
// 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,
};
}