diff --git a/ui/frontend/src/map/index.ts b/ui/frontend/src/map/index.ts index a5eca30..5c4d861 100644 --- a/ui/frontend/src/map/index.ts +++ b/ui/frontend/src/map/index.ts @@ -33,6 +33,8 @@ export { pivotZoom, } from "./no-wrap"; +export { wrapCameraTorus } from "./torus"; + export { hitTest, type Hit } from "./hit-test"; export { diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 24a8115..b50d695 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -3,10 +3,16 @@ // Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance // configured for the active wrap mode. Torus mode renders nine // container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the -// user a seamless toroidal world. No-wrap mode hides eight of the -// nine copies and pins the camera with `pixi-viewport`'s `clamp` -// plugin plus a `moved` listener that recentres the camera when the -// visible viewport exceeds the world along an axis. +// user a seamless toroidal world while panning past the edge — and +// keeps the camera centre snapped into the central tile via a +// `moved` listener so the fixed 3×3 layout is sufficient for any +// distance of pan. No-wrap mode hides eight of the nine copies and +// pins the camera with `pixi-viewport`'s `clamp` plugin plus a +// `moved` listener that recentres the camera when the visible +// viewport exceeds the world along an axis. Both modes share a +// `clampZoom({ minScale })` so the world (origin copy) always fills +// at least the viewport — without it torus mode would expose all +// nine copies at once. // // Hit-test is owned by ./hit-test.ts; this file only exposes the // current camera and viewport so callers can run hits. @@ -16,6 +22,7 @@ import { Viewport as PixiViewport } from "pixi-viewport"; import { hitTest, type Hit } from "./hit-test"; import { minScaleNoWrap } from "./no-wrap"; +import { wrapCameraTorus } from "./torus"; import { DARK_THEME, type Camera, @@ -133,6 +140,31 @@ export async function createRenderer(opts: RendererOptions): Promise { + if (mode !== "torus" || wrappingCamera) return; + const wrapped = wrapCameraTorus( + { + centerX: viewport.center.x, + centerY: viewport.center.y, + scale: viewport.scaled, + }, + opts.world, + ); + if (wrapped.centerX === viewport.center.x && wrapped.centerY === viewport.center.y) { + return; + } + wrappingCamera = true; + try { + viewport.moveCenter(wrapped.centerX, wrapped.centerY); + } finally { + wrappingCamera = false; + } + }; + const applyMode = (newMode: WrapMode): void => { mode = newMode; for (let i = 0; i < copies.length; i++) { @@ -142,6 +174,7 @@ export async function createRenderer(opts: RendererOptions): Promise { viewport.off("moved", enforceCentreWhenLarger); + viewport.off("moved", wrapTorusCamera); app.destroy({ removeView: false }, { children: true }); }, }; diff --git a/ui/frontend/src/map/torus.ts b/ui/frontend/src/map/torus.ts new file mode 100644 index 0000000..3159601 --- /dev/null +++ b/ui/frontend/src/map/torus.ts @@ -0,0 +1,44 @@ +// Camera helpers for toroidal-world (torus) mode. +// +// The renderer paints the world into a 3×3 grid of copies offset by +// `(±W, 0) × (±H, 0)` — those copies are intended to cover the +// cross-edge slack while the user pans, not to be visible all at +// once. As soon as the camera centre walks outside the central +// tile's rectangle `[0, W) × [0, H)`, the user starts seeing the +// next-tile boundary; one more world width/height of pan and they +// are at the 3×3 grid edge with empty space beyond. +// +// `wrapCameraTorus` snaps the camera back into `[0, W) × [0, H)` +// modulo the world dimensions. The toroidal world looks identical +// at `(x, y)` and `(x mod W, y mod H)`, so the snap is invisible to +// the user, while keeping the 3×3 layout enough to cover infinite +// pan in any direction. + +import type { Camera, World } from "./world"; + +/** + * wrapCameraTorus returns a camera whose centre is reduced modulo + * the world dimensions so it lies within `[0, W) × [0, H)`. Camera + * scale is preserved. Negative inputs are also handled — the result + * is always non-negative. + * + * The transform is a pure mathematical no-op on the toroidal world: + * `(centerX, centerY)` and `(centerX mod W, centerY mod H)` describe + * the same toroidal point. Callers use the wrapped camera to keep + * the renderer's fixed 3×3 wrap-copy layout sufficient regardless + * of how far the user has panned. + */ +export function wrapCameraTorus(camera: Camera, world: World): Camera { + const W = world.width; + const H = world.height; + return { + centerX: positiveMod(camera.centerX, W), + centerY: positiveMod(camera.centerY, H), + scale: camera.scale, + }; +} + +function positiveMod(value: number, modulus: number): number { + const r = value % modulus; + return r < 0 ? r + modulus : r; +} diff --git a/ui/frontend/src/routes/__debug/map/+page.svelte b/ui/frontend/src/routes/__debug/map/+page.svelte index a201fcb..2719dd8 100644 --- a/ui/frontend/src/routes/__debug/map/+page.svelte +++ b/ui/frontend/src/routes/__debug/map/+page.svelte @@ -15,6 +15,7 @@ getMode(): WrapMode; setMode(mode: WrapMode): void; getCamera(): { centerX: number; centerY: number; scale: number }; + setCameraCenter(centerX: number, centerY: number): void; getViewport(): { widthPx: number; heightPx: number }; getBackend(): string; getWorldSize(): { width: number; height: number }; @@ -81,6 +82,20 @@ mode = m; }, getCamera: () => handle?.getCamera() ?? { centerX: 0, centerY: 0, scale: 1 }, + setCameraCenter: (cx, cy) => { + if (handle === null) return; + // `pixi-viewport`'s built-in plugins (drag, wheel, + // decelerate, …) emit `'moved'` themselves, but + // programmatic `moveCenter` does not. Emit it + // manually so the renderer's torus-wrap listener + // (and any future per-move callback) sees the + // change — matches the semantics of a user drag. + handle.viewport.moveCenter(cx, cy); + handle.viewport.emit("moved", { + viewport: handle.viewport, + type: "manual", + }); + }, getViewport: () => handle?.getViewport() ?? { widthPx: 0, heightPx: 0 }, getBackend: () => handle?.getBackend() ?? "", diff --git a/ui/frontend/tests/e2e/playground-map.spec.ts b/ui/frontend/tests/e2e/playground-map.spec.ts index 4b2c838..8e1702f 100644 --- a/ui/frontend/tests/e2e/playground-map.spec.ts +++ b/ui/frontend/tests/e2e/playground-map.spec.ts @@ -13,6 +13,7 @@ interface DebugMapSurface { getMode(): "torus" | "no-wrap"; setMode(mode: "torus" | "no-wrap"): void; getCamera(): { centerX: number; centerY: number; scale: number }; + setCameraCenter(centerX: number, centerY: number): void; getViewport(): { widthPx: number; heightPx: number }; getBackend(): string; getWorldSize(): { width: number; height: number }; @@ -86,6 +87,41 @@ test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); }); +test("torus mode wraps the camera back into the central tile after a long pan", async ({ + page, +}, testInfo) => { + await bootMap(page, preferenceFor(testInfo.project.name)); + expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); + const canvas = page.locator("canvas"); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) return; + + // Drive the renderer's `viewport.moveCenter` directly through the + // debug surface — far past the world rectangle on both axes. The + // `'moved'` listener installed by the renderer should snap the + // camera back into `[0, W) × [0, H)`. Going through the debug + // surface avoids the slow / flaky drag-and-decelerate path. + const { cam, world } = await page.evaluate(() => { + const m = window.__galaxyMap!; + const w = m.getWorldSize(); + // Push camera 5.4 world widths to the right and 7.25 world + // heights below — values chosen so neither ends up at a + // rounded boundary that would mask a missing wrap. + m.setCameraCenter(w.width * 5.4, w.height * 7.25); + return { cam: m.getCamera(), world: w }; + }); + const tol = 1e-3; + expect(cam.centerX).toBeGreaterThanOrEqual(-tol); + expect(cam.centerX).toBeLessThan(world.width + tol); + expect(cam.centerY).toBeGreaterThanOrEqual(-tol); + expect(cam.centerY).toBeLessThan(world.height + tol); + // The wrapped position must be congruent modulo world dimensions + // to the requested 5.4 × W / 7.25 × H. + expect(cam.centerX).toBeCloseTo(world.width * 0.4, 3); + expect(cam.centerY).toBeCloseTo(world.height * 0.25, 3); +}); + test("torus mode clamps zoom-out to minScale so the world is never smaller than the viewport", async ({ page, }, testInfo) => { diff --git a/ui/frontend/tests/map-torus.test.ts b/ui/frontend/tests/map-torus.test.ts new file mode 100644 index 0000000..0cd9db0 --- /dev/null +++ b/ui/frontend/tests/map-torus.test.ts @@ -0,0 +1,97 @@ +// Unit tests for the torus camera helper in src/map/torus.ts. +// +// `wrapCameraTorus` is the pure-math primitive the renderer uses to +// keep the camera centre inside the central tile of the 3×3 wrap +// layout. The helper guarantees three properties: the result lies +// inside `[0, W) × [0, H)`, the toroidal point is preserved (the +// transform is an additive multiple of `W` / `H`), and the camera +// scale is unchanged. + +import { describe, expect, test } from "vitest"; + +import { World } from "../src/map/world"; +import { wrapCameraTorus } from "../src/map/torus"; + +const world = new World(1000, 800); + +describe("wrapCameraTorus", () => { + test("leaves a camera inside [0, W) × [0, H) untouched", () => { + const c = wrapCameraTorus( + { centerX: 500, centerY: 400, scale: 2 }, + world, + ); + expect(c.centerX).toBe(500); + expect(c.centerY).toBe(400); + expect(c.scale).toBe(2); + }); + + test("wraps a camera one tile past the right edge back to the left", () => { + const c = wrapCameraTorus( + { centerX: 1500, centerY: 200, scale: 1 }, + world, + ); + expect(c.centerX).toBe(500); + expect(c.centerY).toBe(200); + }); + + test("wraps a camera one tile below the top edge back to the bottom", () => { + const c = wrapCameraTorus( + { centerX: 100, centerY: -300, scale: 1 }, + world, + ); + expect(c.centerX).toBe(100); + expect(c.centerY).toBe(500); + }); + + test("wraps a camera many tiles away on both axes", () => { + const c = wrapCameraTorus( + { centerX: 1000 * 5 + 123, centerY: -800 * 7 - 45, scale: 0.5 }, + world, + ); + expect(c.centerX).toBeCloseTo(123, 6); + expect(c.centerY).toBeCloseTo(800 - 45, 6); + expect(c.scale).toBe(0.5); + }); + + test("collapses the world boundary to zero (right edge wraps to left)", () => { + const c = wrapCameraTorus( + { centerX: 1000, centerY: 800, scale: 1 }, + world, + ); + // 1000 mod 1000 === 0; same for 800. The right and bottom + // world edges map to the left and top edges. + expect(c.centerX).toBe(0); + expect(c.centerY).toBe(0); + }); + + test("preserves the toroidal coordinate (delta is a world-multiple)", () => { + const before = { centerX: 1000 * 3 + 250.75, centerY: 800 * -2 + 100.25, scale: 1 }; + const after = wrapCameraTorus(before, world); + const dx = before.centerX - after.centerX; + const dy = before.centerY - after.centerY; + expect(Math.abs(dx % world.width)).toBeLessThan(1e-6); + expect(Math.abs(dy % world.height)).toBeLessThan(1e-6); + }); + + test("never returns negative coordinates", () => { + for (const camera of [ + { centerX: -1, centerY: -1, scale: 1 }, + { centerX: -0.5, centerY: -0.5, scale: 1 }, + { centerX: -world.width * 1.25, centerY: -world.height * 1.25, scale: 1 }, + ]) { + const c = wrapCameraTorus(camera, world); + expect(c.centerX).toBeGreaterThanOrEqual(0); + expect(c.centerX).toBeLessThan(world.width); + expect(c.centerY).toBeGreaterThanOrEqual(0); + expect(c.centerY).toBeLessThan(world.height); + } + }); + + test("scale field is preserved verbatim", () => { + const c = wrapCameraTorus( + { centerX: 1234, centerY: -567, scale: 0.123456 }, + world, + ); + expect(c.scale).toBe(0.123456); + }); +});