ui: plan 01-27 done #1
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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