// --------------------------------------------------------------------------- // Camera — drives a world container's pan/zoom in the two modes the game needs // (mirroring the wire's `#setView { follow | free }`): // // • follow — lock onto a moving target (the local player) at a FIXED world // width: a constant number of world px spans the viewport regardless of // window size, so zoom is resolution-independent. Optionally clamped to a // rect (the map bounds) so the view never spills past the edges. // • free — arbitrary pan + zoom about the cursor, for spectators panning the // whole board. // // The camera owns only the world transform (`scale`, `position`); the caller // owns the container and the render loop. Call `update(w, h)` each frame and on // resize — in follow mode it re-reads the target, so a moving player stays // centred. Engine-only: no React, no game types. // --------------------------------------------------------------------------- import type { Container } from 'pixi.js' import type { Rect } from './contract' export interface Vec { x: number y: number } /** Anything with a live world position — pass an {@link import('./entity').Entity} * to lock the camera onto it; its x/y are read fresh every `update`. */ export interface CameraTarget { readonly x: number readonly y: number } export type CameraMode = 'follow' | 'free' export interface FollowOptions { /** world px that span the viewport width (zoom derived from viewport size). * Mutually exclusive with `scale`; one of them sets the zoom. */ worldWidth?: number /** fixed world scale instead of a fixed world width. */ scale?: number /** keep the view inside this rect (e.g. `scene.bounds`); null = unclamped. */ clampTo?: Rect | null } export interface Camera { readonly mode: CameraMode /** the world scale applied at the last `update`. */ readonly scale: number /** the world point at the viewport centre at the last `update`. */ readonly center: Vec /** Lock onto a target. Re-evaluated every `update`, so the target may move. */ follow(target: CameraTarget, opts?: FollowOptions): void /** Switch to free pan/zoom. Seeds from the current view unless overridden. */ free(center?: Vec, scale?: number): void /** Free-mode pan by a screen-px delta (e.g. a drag). No-op while following. */ panByScreen(dxScreen: number, dyScreen: number): void /** Free-mode zoom by `factor` about a screen point (e.g. the wheel cursor). * No-op while following. */ zoomAtScreen(screenX: number, screenY: number, factor: number, viewportW: number, viewportH: number): void /** Map a screen point to a world point under the current transform. */ worldFromScreen(screenX: number, screenY: number): Vec /** Recompute and apply the world transform. Call each frame and on resize. */ update(viewportW: number, viewportH: number): void } const MIN_SCALE = 0.05 const MAX_SCALE = 12 export function createCamera(world: Container): Camera { let mode: CameraMode = 'free' // follow state let target: CameraTarget | null = null let followWorldWidth: number | undefined let followScale: number | undefined let clampTo: Rect | null = null // free state — the world point held at viewport centre, and the zoom let freeCenter: Vec = { x: 0, y: 0 } let freeScale = 1 // last applied (also what `center`/`scale`/`worldFromScreen` report) let appliedScale = 1 const appliedCenter: Vec = { x: 0, y: 0 } const clampScale = (s: number) => Math.max(MIN_SCALE, Math.min(MAX_SCALE, s)) return { get mode() { return mode }, get scale() { return appliedScale }, get center() { return { ...appliedCenter } }, follow(t, opts = {}) { mode = 'follow' target = t followWorldWidth = opts.worldWidth followScale = opts.scale clampTo = opts.clampTo ?? null }, free(center, scale) { mode = 'free' // seed from the current view so the switch is seamless freeCenter = center ? { ...center } : { ...appliedCenter } freeScale = clampScale(scale ?? appliedScale) }, panByScreen(dxScreen, dyScreen) { if (mode !== 'free') return freeCenter.x -= dxScreen / freeScale freeCenter.y -= dyScreen / freeScale }, zoomAtScreen(screenX, screenY, factor, viewportW, viewportH) { if (mode !== 'free') return // world point under the cursor, kept fixed across the zoom const p = this.worldFromScreen(screenX, screenY) const newScale = clampScale(freeScale * factor) freeCenter.x = p.x - (screenX - viewportW / 2) / newScale freeCenter.y = p.y - (screenY - viewportH / 2) / newScale freeScale = newScale }, worldFromScreen(screenX, screenY) { return { x: (screenX - world.position.x) / world.scale.x, y: (screenY - world.position.y) / world.scale.y, } }, update(viewportW, viewportH) { let scale: number let cx: number let cy: number if (mode === 'follow' && target) { scale = clampScale( followScale ?? (followWorldWidth ? viewportW / followWorldWidth : appliedScale), ) cx = target.x cy = target.y if (clampTo) { const halfW = viewportW / scale / 2 const halfH = viewportH / scale / 2 cx = clampAxis(cx, clampTo.x, clampTo.w, halfW) cy = clampAxis(cy, clampTo.y, clampTo.h, halfH) } } else { scale = clampScale(freeScale) cx = freeCenter.x cy = freeCenter.y } world.scale.set(scale) world.position.set(viewportW / 2 - cx * scale, viewportH / 2 - cy * scale) appliedScale = scale appliedCenter.x = cx appliedCenter.y = cy }, } } /** Clamp a centre coordinate so a half-extent view stays inside [start, start+len]; * if the view is wider than the span, centre on the span instead. */ function clampAxis(c: number, start: number, len: number, half: number): number { if (len <= half * 2) return start + len / 2 return Math.max(start + half, Math.min(start + len - half, c)) }