Files
galaxy-game/ui/frontend/src/routes/__debug/map/+page.svelte
T
Ilia Denisov 164f23fbed ui/map-renderer: pin synthetic moved-event type to a real literal
`MovedEvent.type` in pixi-viewport@6 is a closed union of built-in
plugin names; the prior `"manual"` value tripped svelte-check.
`"animate"` is the closest semantic match for a programmatic move
and the renderer's listeners read only `viewport`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 23:46:24 +02:00

217 lines
5.6 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);
// `MovedEvent.type` is a closed literal union over the
// built-in plugin names; `"animate"` is the closest
// match for a programmatic move and the renderer's
// listeners read only `viewport`.
handle.viewport.emit("moved", {
viewport: handle.viewport,
type: "animate",
});
},
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>