ui: plan 01-27 done #1
@@ -33,6 +33,8 @@ export {
|
||||
pivotZoom,
|
||||
} from "./no-wrap";
|
||||
|
||||
export { wrapCameraTorus } from "./torus";
|
||||
|
||||
export { hitTest, type Hit } from "./hit-test";
|
||||
|
||||
export {
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
// Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance
|
||||
// configured for the active wrap mode. Torus mode renders nine
|
||||
// container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the
|
||||
// user a seamless toroidal world. 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.
|
||||
// user a seamless toroidal world while panning past the edge — and
|
||||
// keeps the camera centre snapped into the central tile via a
|
||||
// `moved` listener so the fixed 3×3 layout is sufficient for any
|
||||
// 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
|
||||
// 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 { minScaleNoWrap } from "./no-wrap";
|
||||
import { wrapCameraTorus } from "./torus";
|
||||
import {
|
||||
DARK_THEME,
|
||||
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 => {
|
||||
mode = newMode;
|
||||
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-zoom");
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
const minScale = minScaleNoWrap(
|
||||
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
||||
opts.world,
|
||||
@@ -158,9 +191,16 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
viewport.clamp({ direction: "all" });
|
||||
viewport.on("moved", 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);
|
||||
@@ -195,6 +235,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
},
|
||||
dispose: () => {
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
app.destroy({ removeView: false }, { children: true });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
setMode(mode: WrapMode): 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 };
|
||||
@@ -81,6 +82,20 @@
|
||||
mode = m;
|
||||
},
|
||||
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: () =>
|
||||
handle?.getViewport() ?? { widthPx: 0, heightPx: 0 },
|
||||
getBackend: () => handle?.getBackend() ?? "",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user