Files
galaxy-game/ui/frontend/tests/e2e/playground-map.spec.ts
T
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

226 lines
8.5 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.
// Phase 9 end-to-end checks for the map renderer playground.
//
// Each Playwright project exercises a different rendering backend:
// chromium-desktop forces WebGPU, webkit-desktop forces WebGL, mobile
// projects pick their default. The window.__galaxyMap surface
// (defined in src/routes/__debug/map/+page.svelte) lets the spec
// read the camera and viewport state without poking Pixi internals.
import { expect, test, type Page } from "@playwright/test";
interface DebugMapSurface {
ready: true;
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 };
hitAt(x: number, y: number): number | null;
}
declare global {
interface Window {
__galaxyMap?: DebugMapSurface;
}
}
function preferenceFor(projectName: string): "webgpu" | "webgl" | null {
if (projectName === "chromium-desktop") return "webgpu";
if (projectName === "webkit-desktop") return "webgl";
return null;
}
async function bootMap(page: Page, preference: "webgpu" | "webgl" | null): Promise<void> {
const url = preference !== null ? `/__debug/map?renderer=${preference}` : "/__debug/map";
await page.goto(url);
await page.waitForFunction(() => window.__galaxyMap?.ready === true, undefined, {
timeout: 15_000,
});
await expect(page.getByTestId("backend")).toBeVisible();
}
test("map mounts in the requested backend and reports it via data-backend", async ({
page,
}, testInfo) => {
const pref = preferenceFor(testInfo.project.name);
await bootMap(page, pref);
const backend = await page.getByTestId("backend").getAttribute("data-backend");
expect(backend).not.toBeNull();
if (pref === null) {
// Mobile projects auto-pick; just assert a real backend was chosen.
expect(["webgl", "webgpu", "canvas"]).toContain(backend);
} else {
// The renderer should honour the requested preference unless the
// runner lacks a working WebGPU adapter, in which case Pixi
// falls back to WebGL. Both are acceptable.
expect(["webgl", "webgpu"]).toContain(backend);
}
});
test("wheel zoom-in increases camera scale", async ({ page }, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
const before = await page.evaluate(() => window.__galaxyMap!.getCamera());
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.move(cx, cy);
for (let i = 0; i < 5; i++) {
await page.mouse.wheel(0, -120);
await page.waitForTimeout(40);
}
await page.waitForTimeout(100);
const after = await page.evaluate(() => window.__galaxyMap!.getCamera());
expect(after.scale).toBeGreaterThan(before.scale);
});
test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus");
await page.getByTestId("mode-toggle").click();
expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("no-wrap");
await page.getByTestId("mode-toggle").click();
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) => {
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;
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
await page.mouse.move(cx, cy);
// Wheel out aggressively. Without the torus-mode clampZoom the
// camera scale would drop below `minScale = max(viewport/world)`,
// exposing the 3×3 grid of wrap copies. With the fix in place the
// scale floor is never crossed.
for (let i = 0; i < 15; i++) {
await page.mouse.wheel(0, 600);
await page.waitForTimeout(40);
}
await page.waitForTimeout(200);
const snapshot = await page.evaluate(() => ({
cam: window.__galaxyMap!.getCamera(),
vp: window.__galaxyMap!.getViewport(),
world: window.__galaxyMap!.getWorldSize(),
}));
const minScale = Math.max(
snapshot.vp.widthPx / snapshot.world.width,
snapshot.vp.heightPx / snapshot.world.height,
);
const tol = 1e-3;
expect(snapshot.cam.scale).toBeGreaterThanOrEqual(minScale - tol);
});
test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({
page,
}, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
await page.evaluate(() => window.__galaxyMap!.setMode("no-wrap"));
await page.waitForTimeout(50);
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
// Drag right-to-left across most of the canvas so the camera
// would, without clamp, push past the right edge of the world.
const startX = box.x + box.width * 0.85;
const endX = box.x + box.width * 0.15;
const y = box.y + box.height / 2;
await page.mouse.move(startX, y);
await page.mouse.down();
for (let step = 1; step <= 20; step++) {
const x = startX + ((endX - startX) * step) / 20;
await page.mouse.move(x, y);
}
await page.mouse.up();
await page.waitForTimeout(200);
const { cam, vp, world } = await page.evaluate(() => ({
cam: window.__galaxyMap!.getCamera(),
vp: window.__galaxyMap!.getViewport(),
world: window.__galaxyMap!.getWorldSize(),
}));
const halfSpanX = vp.widthPx / (2 * cam.scale);
const tol = 1; // tolerance in world units; clamp is applied in pixels
expect(cam.centerX).toBeGreaterThanOrEqual(halfSpanX - tol);
expect(cam.centerX).toBeLessThanOrEqual(world.width - halfSpanX + tol);
});
test("hitAt returns a primitive id when the cursor is over the world centre", async ({
page,
}, testInfo) => {
await bootMap(page, preferenceFor(testInfo.project.name));
const canvas = page.locator("canvas");
const box = await canvas.boundingBox();
expect(box).not.toBeNull();
if (box === null) return;
const cx = Math.round(box.width / 2);
const cy = Math.round(box.height / 2);
// The fixture world is dense (~950 stars in 4000×4000). Anywhere
// within the canvas should land near at least one primitive.
// We sweep a small grid around the centre to find any hit; the
// goal is to confirm the hit-test plumbing works against the
// live renderer, not to assert a specific id.
const found = await page.evaluate(
({ cx, cy }) => {
const m = window.__galaxyMap!;
for (let dy = -40; dy <= 40; dy += 8) {
for (let dx = -40; dx <= 40; dx += 8) {
const id = m.hitAt(cx + dx, cy + dy);
if (id !== null) return id;
}
}
return null;
},
{ cx, cy },
);
expect(found).not.toBeNull();
});