Files
galaxy-game/ui/frontend/tests/map-hit-test.test.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

264 lines
8.0 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.
// Hand-built cases for the hit-test pass in src/map/hit-test.ts.
//
// Each describe block exercises one rule from the algorithm spec in
// ui/docs/renderer.md. Worlds are kept tiny (15 primitives) so the
// expected hit is obvious from the geometry; the camera is at scale=1
// in most cases so slop in pixels equals slop in world units.
import { describe, expect, test } from "vitest";
import { hitTest } from "../src/map/hit-test";
import {
type Camera,
type Primitive,
type Viewport,
World,
type WrapMode,
} from "../src/map/world";
const VP: Viewport = { widthPx: 200, heightPx: 200 };
// Centre the camera over the world centre at scale=1 so screen px
// equals world units inside the visible region.
function camAt(centerX: number, centerY: number, scale = 1): Camera {
return { centerX, centerY, scale };
}
// Cursor at world point (wx, wy) under the given camera.
function cursorOver(wx: number, wy: number, cam: Camera, vp: Viewport = VP) {
return {
x: vp.widthPx / 2 + (wx - cam.centerX) * cam.scale,
y: vp.heightPx / 2 + (wy - cam.centerY) * cam.scale,
};
}
function point(
id: number,
x: number,
y: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "point",
id,
x,
y,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function circle(
id: number,
x: number,
y: number,
radius: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "circle",
id,
x,
y,
radius,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function line(
id: number,
x1: number,
y1: number,
x2: number,
y2: number,
overrides: Partial<Primitive> = {},
): Primitive {
return {
kind: "line",
id,
x1,
y1,
x2,
y2,
priority: 0,
style: {},
hitSlopPx: 0,
...overrides,
} as Primitive;
}
function ids(world: World, mode: WrapMode, cam: Camera, cursorPx: { x: number; y: number }) {
const h = hitTest(world, cam, VP, cursorPx, mode);
return h?.primitive.id ?? null;
}
describe("hitTest — point primitive", () => {
const cam = camAt(500, 500);
const w = new World(1000, 1000, [point(1, 500, 500)]);
test("direct hit at centre", () => {
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("hit within default slop (8px)", () => {
// 7 world units away at scale=1 → within 8px slop.
expect(ids(w, "torus", cam, cursorOver(507, 500, cam))).toBe(1);
});
test("miss just outside default slop", () => {
expect(ids(w, "torus", cam, cursorOver(509, 500, cam))).toBe(null);
});
test("custom hitSlopPx widens the hit area", () => {
const w2 = new World(1000, 1000, [point(1, 500, 500, { hitSlopPx: 20 })]);
expect(ids(w2, "torus", cam, cursorOver(515, 500, cam))).toBe(1);
});
});
describe("hitTest — torus wrap", () => {
test("point near the right edge is hit by cursor near the left edge", () => {
// World 100×100, point at x=98. Camera at left edge (x=2).
// Cursor at x=4 is 6 units from x=98 via the wrap; default
// point slop is 8px → hit.
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "torus", cam, cursorOver(4, 50, cam))).toBe(1);
});
test("no-wrap mode does not match through the torus seam", () => {
const cam = camAt(2, 50);
const w = new World(100, 100, [point(1, 98, 50)]);
expect(ids(w, "no-wrap", cam, cursorOver(4, 50, cam))).toBe(null);
});
test("line spanning the torus seam is hit at the wrapped midpoint", () => {
// World 100×100, line from (95, 50) to (5, 50).
// Torus-shortest is the wrap segment of length 10.
// Cursor at x=0,y=50 is on the wrapped segment.
const cam = camAt(0, 50);
const w = new World(100, 100, [line(1, 95, 50, 5, 50)]);
expect(ids(w, "torus", cam, cursorOver(0, 50, cam))).toBe(1);
});
});
describe("hitTest — circle primitive", () => {
const cam = camAt(500, 500);
test("filled circle: cursor inside disc hits", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 50, { style: { fillColor: 0xffffff, fillAlpha: 1 } }),
]);
expect(ids(w, "torus", cam, cursorOver(530, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor inside disc but far from ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(510, 500, cam))).toBe(null);
});
test("stroked-only circle: cursor on ring within slop hits", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
// Cursor at (548, 500): distance to centre is 48; ring at 50;
// gap is 2 < default slop 6 → hit.
expect(ids(w, "torus", cam, cursorOver(548, 500, cam))).toBe(1);
});
test("stroked-only circle: cursor far outside the ring misses", () => {
const w = new World(1000, 1000, [circle(1, 500, 500, 50)]);
expect(ids(w, "torus", cam, cursorOver(580, 500, cam))).toBe(null);
});
});
describe("hitTest — line primitive", () => {
const cam = camAt(500, 500);
test("cursor on the segment hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(1);
});
test("cursor near the segment within slop hits", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units away at scale=1 → within default slop 6.
expect(ids(w, "torus", cam, cursorOver(500, 504, cam))).toBe(1);
});
test("cursor near the segment outside slop misses", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 510, cam))).toBe(null);
});
test("cursor beyond endpoint clamps and slop applies", () => {
const w = new World(1000, 1000, [line(1, 480, 500, 520, 500)]);
// 4 world units beyond x=520 along x; default slop 6.
expect(ids(w, "torus", cam, cursorOver(524, 500, cam))).toBe(1);
// 8 world units beyond x=520 → outside slop.
expect(ids(w, "torus", cam, cursorOver(528, 500, cam))).toBe(null);
});
});
describe("hitTest — ordering", () => {
const cam = camAt(500, 500);
test("higher priority wins over lower priority at equal distance", () => {
const w = new World(1000, 1000, [
point(1, 500, 500, { priority: 0 }),
point(2, 500, 500, { priority: 5 }),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("smaller distance wins at equal priority", () => {
const w = new World(1000, 1000, [point(1, 504, 500), point(2, 502, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("kind tie-break: point beats circle at exact distance and priority", () => {
const w = new World(1000, 1000, [
circle(1, 500, 500, 0.0001, { style: { fillColor: 0xffffff } }),
point(2, 500, 500),
]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(2);
});
test("id tie-break: smaller id wins at exact tie", () => {
const w = new World(1000, 1000, [point(7, 500, 500), point(3, 500, 500)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(3);
});
});
describe("hitTest — empty results and scale", () => {
const cam = camAt(500, 500);
test("returns null when nothing matches", () => {
const w = new World(1000, 1000, [point(1, 100, 100)]);
expect(ids(w, "torus", cam, cursorOver(500, 500, cam))).toBe(null);
});
test("higher zoom shrinks the on-screen slop in world units", () => {
// At scale=4, 8px on screen = 2 world units.
// A point 3 world units away misses.
const w = new World(1000, 1000, [point(1, 503, 500)]);
expect(ids(w, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4)))).toBe(
null,
);
// A point 1.5 world units away hits at scale=4 (≤ 2).
const w2 = new World(1000, 1000, [point(1, 501.5, 500)]);
expect(
ids(w2, "torus", camAt(500, 500, 4), cursorOver(500, 500, camAt(500, 500, 4))),
).toBe(1);
});
test("lower zoom widens the on-screen slop in world units", () => {
// At scale=0.5, 8px on screen = 16 world units.
const w = new World(1000, 1000, [point(1, 514, 500)]);
expect(
ids(
w,
"torus",
camAt(500, 500, 0.5),
cursorOver(500, 500, camAt(500, 500, 0.5)),
),
).toBe(1);
});
});