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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user