ui/phase-9: PixiJS map renderer with torus and no-wrap modes
Stand up the vector map renderer in ui/frontend/src/map/ on top of PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container copies for seamless wrap; no-wrap mode pins the camera at world bounds and centres on an axis when the viewport exceeds the world along that axis. Hit-test is a brute-force pass with deterministic [-priority, distSq, kindOrder, id] ordering and torus-shortest distance, validated by hand-built unit cases. The development playground at /__debug/map exposes a window debug surface for the Playwright spec, which forces WebGPU on chromium-desktop, WebGL on webkit-desktop, and accepts the auto-picked backend on mobile projects. Algorithm spec lives in ui/docs/renderer.md, which also pins the new deprecation status of galaxy/client (the entire Fyne client module, including client/world). client/world/README.md and the Phase 9 stub in ui/PLAN.md gain matching deprecation banners. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// 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 };
|
||||
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("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();
|
||||
});
|
||||
Reference in New Issue
Block a user