Files
Ilia Denisov e5dab2a43a 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>
2026-05-08 22:47:38 +02:00

98 lines
3.1 KiB
TypeScript
Raw Permalink 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.
// 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);
});
});