e5dab2a43a
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>
213 lines
5.4 KiB
Svelte
213 lines
5.4 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { page } from "$app/state";
|
|
import {
|
|
createRenderer,
|
|
minScaleNoWrap,
|
|
sampleWorld,
|
|
type RendererHandle,
|
|
type RendererPreference,
|
|
type WrapMode,
|
|
} from "../../../map/index";
|
|
|
|
interface DebugMapSurface {
|
|
ready: true;
|
|
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 };
|
|
hitAt(x: number, y: number): number | null;
|
|
}
|
|
|
|
type DebugMapWindow = typeof globalThis & { __galaxyMap?: DebugMapSurface };
|
|
|
|
let canvasEl: HTMLCanvasElement | null = $state(null);
|
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
let mode: WrapMode = $state("torus");
|
|
let backend = $state("");
|
|
let initError: string | null = $state(null);
|
|
|
|
let handle: RendererHandle | null = null;
|
|
let onResize: (() => void) | null = null;
|
|
|
|
function readPreference(): RendererPreference | RendererPreference[] {
|
|
const v = page.url.searchParams.get("renderer");
|
|
if (v === "webgl") return "webgl";
|
|
if (v === "webgpu") return "webgpu";
|
|
return ["webgpu", "webgl"];
|
|
}
|
|
|
|
function describe(err: unknown): string {
|
|
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
return String(err);
|
|
}
|
|
|
|
onMount(() => {
|
|
(async () => {
|
|
if (canvasEl === null || containerEl === null) return;
|
|
const world = sampleWorld();
|
|
try {
|
|
handle = await createRenderer({
|
|
canvas: canvasEl,
|
|
world,
|
|
mode,
|
|
preference: readPreference(),
|
|
});
|
|
} catch (err) {
|
|
initError = describe(err);
|
|
return;
|
|
}
|
|
backend = handle.getBackend();
|
|
|
|
// Initial camera: world centre, zoomed slightly past the
|
|
// fits-the-viewport floor so neighbouring torus copies are
|
|
// visible too.
|
|
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
|
const minScale = minScaleNoWrap(
|
|
{ widthPx: containerEl.clientWidth, heightPx: containerEl.clientHeight },
|
|
world,
|
|
);
|
|
handle.viewport.setZoom(minScale * 1.2, true);
|
|
if (mode === "no-wrap") handle.setMode("no-wrap"); // re-clamp post zoom
|
|
|
|
const surface: DebugMapSurface = {
|
|
ready: true,
|
|
getMode: () => handle?.getMode() ?? "torus",
|
|
setMode: (m) => {
|
|
if (handle === null) return;
|
|
handle.setMode(m);
|
|
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() ?? "",
|
|
getWorldSize: () => ({ width: world.width, height: world.height }),
|
|
hitAt: (x, y) => {
|
|
if (handle === null) return null;
|
|
const hit = handle.hitAt({ x, y });
|
|
return hit?.primitive.id ?? null;
|
|
},
|
|
};
|
|
(window as DebugMapWindow).__galaxyMap = surface;
|
|
|
|
onResize = (): void => {
|
|
if (handle === null || containerEl === null) return;
|
|
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
|
|
};
|
|
window.addEventListener("resize", onResize);
|
|
})();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (onResize !== null) {
|
|
window.removeEventListener("resize", onResize);
|
|
onResize = null;
|
|
}
|
|
if (handle !== null) {
|
|
handle.dispose();
|
|
handle = null;
|
|
}
|
|
const w = window as DebugMapWindow;
|
|
if (w.__galaxyMap !== undefined) delete w.__galaxyMap;
|
|
});
|
|
|
|
function toggleMode(): void {
|
|
if (handle === null) return;
|
|
const next: WrapMode = mode === "torus" ? "no-wrap" : "torus";
|
|
handle.setMode(next);
|
|
mode = next;
|
|
}
|
|
</script>
|
|
|
|
<main>
|
|
<header>
|
|
<h1>map debug</h1>
|
|
<div class="controls">
|
|
<button type="button" data-testid="mode-toggle" onclick={toggleMode}>
|
|
mode: {mode}
|
|
</button>
|
|
<span data-testid="backend" data-backend={backend}>backend: {backend || "…"}</span>
|
|
</div>
|
|
</header>
|
|
{#if initError !== null}
|
|
<p class="error" data-testid="init-error">{initError}</p>
|
|
{/if}
|
|
<div class="canvas-wrap" bind:this={containerEl}>
|
|
<canvas bind:this={canvasEl}></canvas>
|
|
</div>
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
margin: 0;
|
|
font-family: system-ui, sans-serif;
|
|
color: #e8eaf6;
|
|
background: #0a0e1a;
|
|
}
|
|
header {
|
|
padding: 0.5rem 1rem;
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
border-bottom: 1px solid #20253a;
|
|
}
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
}
|
|
button {
|
|
padding: 0.25rem 0.75rem;
|
|
background: #1c2238;
|
|
color: #e8eaf6;
|
|
border: 1px solid #2a3150;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font: inherit;
|
|
}
|
|
button:hover {
|
|
background: #232b48;
|
|
}
|
|
.canvas-wrap {
|
|
flex: 1;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.error {
|
|
padding: 0.5rem 1rem;
|
|
background: #4a1820;
|
|
color: #ffb4b4;
|
|
}
|
|
</style>
|