Files
galaxy-game/ui/frontend/src/map/no-wrap.ts
T
Ilia Denisov db415f8aa4 ui/phase-9: PixiJS map renderer with torus and no-wrap modes
Stand up the vector map renderer in ui/frontend/src/map/ on top of
PixiJS v8 + pixi-viewport@^6. Torus mode renders nine container
copies for seamless wrap; no-wrap mode pins the camera at world
bounds and centres on an axis when the viewport exceeds the world
along that axis. Hit-test is a brute-force pass with deterministic
[-priority, distSq, kindOrder, id] ordering and torus-shortest
distance, validated by hand-built unit cases.

The development playground at /__debug/map exposes a window
debug surface for the Playwright spec, which forces WebGPU on
chromium-desktop, WebGL on webkit-desktop, and accepts the
auto-picked backend on mobile projects.

Algorithm spec lives in ui/docs/renderer.md, which also pins the
new deprecation status of galaxy/client (the entire Fyne client
module, including client/world). client/world/README.md and the
Phase 9 stub in ui/PLAN.md gain matching deprecation banners.

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

74 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Camera helpers for bounded-plane (no-wrap) mode.
//
// In no-wrap mode the world is a finite rectangle [0, W) × [0, H).
// The camera must keep the visible viewport inside the world, except
// when the visible viewport is larger than the world along some axis
// — in that case the camera is centred on that axis. This is the
// semantics asserted by the tests in tests/map-no-wrap.test.ts.
import { clamp } from "./math";
import type { Camera, Viewport, World } from "./world";
// minScaleNoWrap returns the smallest camera.scale value at which the
// visible viewport fits inside the world along both axes. Below this
// scale the user would see "void" outside world bounds.
export function minScaleNoWrap(viewport: Viewport, world: World): number {
return Math.max(viewport.widthPx / world.width, viewport.heightPx / world.height);
}
// clampCameraNoWrap returns a camera whose centre is constrained so
// that the visible viewport stays within world bounds. When the
// visible viewport span exceeds world span on an axis, the camera is
// centred on that axis (independent of input centerX/centerY).
//
// The function does not modify camera.scale. Callers that want to
// also enforce minScaleNoWrap should call that separately.
export function clampCameraNoWrap(camera: Camera, viewport: Viewport, world: World): Camera {
const halfSpanX = viewport.widthPx / (2 * camera.scale);
const halfSpanY = viewport.heightPx / (2 * camera.scale);
let centerX = camera.centerX;
if (halfSpanX * 2 >= world.width) {
centerX = world.width / 2;
} else {
centerX = clamp(centerX, halfSpanX, world.width - halfSpanX);
}
let centerY = camera.centerY;
if (halfSpanY * 2 >= world.height) {
centerY = world.height / 2;
} else {
centerY = clamp(centerY, halfSpanY, world.height - halfSpanY);
}
return { centerX, centerY, scale: camera.scale };
}
// pivotZoom keeps the world point under cursor stable while changing
// camera.scale from oldScale to newScale. It returns a new camera
// with the same scale=newScale and a recomputed centre.
//
// Invariant: screenToWorld(cursorPx, returned, viewport) ===
// screenToWorld(cursorPx, { ...camera, scale: oldScale }, viewport)
// (within float64 precision, see tests/map-no-wrap.test.ts).
export function pivotZoom(
camera: Camera,
viewport: Viewport,
cursorPx: { x: number; y: number },
newScale: number,
): Camera {
const oldScale = camera.scale;
if (!(newScale > 0)) {
throw new Error(`pivotZoom: newScale must be positive, got ${newScale}`);
}
const offX = cursorPx.x - viewport.widthPx / 2;
const offY = cursorPx.y - viewport.heightPx / 2;
const worldX = camera.centerX + offX / oldScale;
const worldY = camera.centerY + offY / oldScale;
return {
centerX: worldX - offX / newScale,
centerY: worldY - offY / newScale,
scale: newScale,
};
}