ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
6 changed files with 241 additions and 6 deletions
Showing only changes of commit e5dab2a43a - Show all commits
+2
View File
@@ -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 {
+47 -6
View File
@@ -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 });
}, },
}; };
+44
View File
@@ -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) => {
+97
View File
@@ -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);
});
});