ui/map-renderer: wrap torus camera into the central tile on pan
Even with the zoom-out clamp from cc004f9, panning still let the
user walk the camera centre out of the central tile of the 3×3
wrap layout — they would see the wrap copies one tile out and then
empty space beyond, because the renderer paints exactly nine
copies and nothing further. The fix is the standard torus trick:
treat camera coordinates modulo world dimensions. The toroidal
world looks identical at `(x, y)` and `(x mod W, y mod H)`, so
snapping the centre back into `[0, W) × [0, H)` is invisible to
the user, and the fixed 3×3 layout is then sufficient to cover
infinite pan in any direction.
Implementation:
- `src/map/torus.ts::wrapCameraTorus` — pure helper that returns
the modulo-wrapped camera (positive remainder; scale preserved).
- `src/map/render.ts` — the torus-mode path now installs a
`'moved'` listener that runs the wrap, with a re-entry guard
because `viewport.moveCenter` itself fires the same event the
listener subscribes to. The `'moved'` event is emitted by
every `pixi-viewport` plugin that moves the camera (drag,
wheel, decelerate, snap, pinch — confirmed against the v6
source) so production drag inertia and wheel-pan both trigger
the wrap.
- `src/routes/__debug/map/+page.svelte` — adds `setCameraCenter`
to `__galaxyMap`, with an explicit `viewport.emit('moved')`
after the programmatic `moveCenter` (the v6 source does not
emit `'moved'` from `moveCenter`, only plugins do; the manual
emit matches the user-drag semantics).
Tests:
- `tests/map-torus.test.ts` — Vitest unit coverage for
`wrapCameraTorus` (in-bounds noop, one tile / many tiles past
on each axis, negative inputs never return negative, scale
preserved, right/bottom edge folds to left/top, toroidal-
congruence invariant).
- `tests/e2e/playground-map.spec.ts` — torus pan regression: push
the camera to (5.4×W, 7.25×H) through the new debug entry,
assert the centre lands in the central tile and matches the
expected `(0.4×W, 0.25×H)` modulo position. Runs across all
four Playwright projects.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,8 @@ export {
|
|||||||
pivotZoom,
|
pivotZoom,
|
||||||
} from "./no-wrap";
|
} from "./no-wrap";
|
||||||
|
|
||||||
|
export { wrapCameraTorus } from "./torus";
|
||||||
|
|
||||||
export { hitTest, type Hit } from "./hit-test";
|
export { hitTest, type Hit } from "./hit-test";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
// Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance
|
// Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance
|
||||||
// configured for the active wrap mode. Torus mode renders nine
|
// configured for the active wrap mode. Torus mode renders nine
|
||||||
// container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the
|
// container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the
|
||||||
// user a seamless toroidal world. No-wrap mode hides eight of the
|
// user a seamless toroidal world while panning past the edge — and
|
||||||
// nine copies and pins the camera with `pixi-viewport`'s `clamp`
|
// keeps the camera centre snapped into the central tile via a
|
||||||
// plugin plus a `moved` listener that recentres the camera when the
|
// `moved` listener so the fixed 3×3 layout is sufficient for any
|
||||||
// visible viewport exceeds the world along an axis.
|
// 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
|
// Hit-test is owned by ./hit-test.ts; this file only exposes the
|
||||||
// current camera and viewport so callers can run hits.
|
// 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 { hitTest, type Hit } from "./hit-test";
|
||||||
import { minScaleNoWrap } from "./no-wrap";
|
import { minScaleNoWrap } from "./no-wrap";
|
||||||
|
import { wrapCameraTorus } from "./torus";
|
||||||
import {
|
import {
|
||||||
DARK_THEME,
|
DARK_THEME,
|
||||||
type Camera,
|
type Camera,
|
||||||
@@ -133,6 +140,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reentry guard for the torus wrap handler: `viewport.moveCenter`
|
||||||
|
// fires the same `'moved'` event that triggered the wrap, so a
|
||||||
|
// naive callback would loop forever.
|
||||||
|
let wrappingCamera = false;
|
||||||
|
const wrapTorusCamera = (): void => {
|
||||||
|
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 => {
|
const applyMode = (newMode: WrapMode): void => {
|
||||||
mode = newMode;
|
mode = newMode;
|
||||||
for (let i = 0; i < copies.length; i++) {
|
for (let i = 0; i < copies.length; i++) {
|
||||||
@@ -142,6 +174,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
viewport.plugins.remove("clamp");
|
viewport.plugins.remove("clamp");
|
||||||
viewport.plugins.remove("clamp-zoom");
|
viewport.plugins.remove("clamp-zoom");
|
||||||
viewport.off("moved", enforceCentreWhenLarger);
|
viewport.off("moved", enforceCentreWhenLarger);
|
||||||
|
viewport.off("moved", wrapTorusCamera);
|
||||||
const minScale = minScaleNoWrap(
|
const minScale = minScaleNoWrap(
|
||||||
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
||||||
opts.world,
|
opts.world,
|
||||||
@@ -158,9 +191,16 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
viewport.clamp({ direction: "all" });
|
viewport.clamp({ direction: "all" });
|
||||||
viewport.on("moved", enforceCentreWhenLarger);
|
viewport.on("moved", enforceCentreWhenLarger);
|
||||||
enforceCentreWhenLarger();
|
enforceCentreWhenLarger();
|
||||||
|
} else {
|
||||||
|
// Torus mode keeps free pan: the user can drag in any
|
||||||
|
// direction indefinitely. To keep the fixed 3×3 wrap
|
||||||
|
// layout sufficient, snap the camera back into the
|
||||||
|
// `[0, W) × [0, H)` central tile whenever it walks past
|
||||||
|
// the edge — toroidal coordinates are equivalent modulo
|
||||||
|
// world dimensions, so the user sees no jump.
|
||||||
|
viewport.on("moved", wrapTorusCamera);
|
||||||
|
wrapTorusCamera();
|
||||||
}
|
}
|
||||||
// Torus mode keeps free pan (no `clamp()`); the visible wrap
|
|
||||||
// copies handle the cross-edge case naturally.
|
|
||||||
};
|
};
|
||||||
|
|
||||||
applyMode(mode);
|
applyMode(mode);
|
||||||
@@ -195,6 +235,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
},
|
},
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
viewport.off("moved", enforceCentreWhenLarger);
|
viewport.off("moved", enforceCentreWhenLarger);
|
||||||
|
viewport.off("moved", wrapTorusCamera);
|
||||||
app.destroy({ removeView: false }, { children: true });
|
app.destroy({ removeView: false }, { children: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
getMode(): WrapMode;
|
getMode(): WrapMode;
|
||||||
setMode(mode: WrapMode): void;
|
setMode(mode: WrapMode): void;
|
||||||
getCamera(): { centerX: number; centerY: number; scale: number };
|
getCamera(): { centerX: number; centerY: number; scale: number };
|
||||||
|
setCameraCenter(centerX: number, centerY: number): void;
|
||||||
getViewport(): { widthPx: number; heightPx: number };
|
getViewport(): { widthPx: number; heightPx: number };
|
||||||
getBackend(): string;
|
getBackend(): string;
|
||||||
getWorldSize(): { width: number; height: number };
|
getWorldSize(): { width: number; height: number };
|
||||||
@@ -81,6 +82,20 @@
|
|||||||
mode = m;
|
mode = m;
|
||||||
},
|
},
|
||||||
getCamera: () => handle?.getCamera() ?? { centerX: 0, centerY: 0, scale: 1 },
|
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: () =>
|
getViewport: () =>
|
||||||
handle?.getViewport() ?? { widthPx: 0, heightPx: 0 },
|
handle?.getViewport() ?? { widthPx: 0, heightPx: 0 },
|
||||||
getBackend: () => handle?.getBackend() ?? "",
|
getBackend: () => handle?.getBackend() ?? "",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface DebugMapSurface {
|
|||||||
getMode(): "torus" | "no-wrap";
|
getMode(): "torus" | "no-wrap";
|
||||||
setMode(mode: "torus" | "no-wrap"): void;
|
setMode(mode: "torus" | "no-wrap"): void;
|
||||||
getCamera(): { centerX: number; centerY: number; scale: number };
|
getCamera(): { centerX: number; centerY: number; scale: number };
|
||||||
|
setCameraCenter(centerX: number, centerY: number): void;
|
||||||
getViewport(): { widthPx: number; heightPx: number };
|
getViewport(): { widthPx: number; heightPx: number };
|
||||||
getBackend(): string;
|
getBackend(): string;
|
||||||
getWorldSize(): { width: number; height: number };
|
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");
|
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 ({
|
test("torus mode clamps zoom-out to minScale so the world is never smaller than the viewport", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user