Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
6.2 kB · 178 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179// ---------------------------------------------------------------------------// 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.05const 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))}