Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
6.3 kB · 143 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144// ---------------------------------------------------------------------------// Placed-object transforms: a uniform scale, a 90°-multiple rotation, and// horizontal/vertical flips, all applied about the object's anchor (its feet// origin at the placed (x,y)). Because every rotation is a multiple of 90°,// each transform maps an axis-aligned rect to an axis-aligned rect — so bounds// and collision stay EXACT, with no AABB padding.//// Shared by the editor canvas (MapEditor) and the runtime (Sandbox) so both// agree on geometry. The canonical order is: scale → flip → rotate. The canvas// `ctx` transform and the Pixi sprite transform are composed to match.// ---------------------------------------------------------------------------
import type { Rect } from './contract'
/** A resolved object transform (concrete, no optionals). */export interface ObjTransform { /** uniform scale multiplier (1 = unchanged, must be > 0). */ scale: number /** clockwise quarter-turns: 0 | 1 | 2 | 3 (= 0° / 90° / 180° / 270°). */ rotation: number flipX: boolean flipY: boolean}
/** The transform fields a node may carry; all optional for backward compat. */export interface ObjTransformFields { scale?: number rotation?: number flipX?: boolean flipY?: boolean}
/** Per-instance playback flags layered on top of the definition's play mode. */export interface AnimPlayback { /** play the cycle backwards. */ reverse?: boolean /** play the cycle a single time and hold the final frame, instead of looping. */ once?: boolean}
/** Frame index for a clock-driven object animation. `frameCount` is the total * number of frames (base + extras). `'loop'` cycles 0→n→0; `'pingpong'` plays * forward then backward without repeating the endpoints (n frames → period * 2(n-1): e.g. 3 frames → 0,1,2,1). `reverse` plays that cycle backwards; `once` * plays it a single time then holds the last frame. */export function animFrameIndex( elapsedMs: number, frameDurationMs: number, frameCount: number, playMode: 'loop' | 'pingpong', { reverse = false, once = false }: AnimPlayback = {},): number { if (frameCount <= 1) return 0 // one forward cycle of frame indices, in play order (pingpong included)… const indices = Array.from({ length: frameCount }, (_, i) => i) const seq = playOrder(indices, playMode) if (reverse) seq.reverse() const step = Math.max(0, Math.floor(elapsedMs / Math.max(16, frameDurationMs))) // indices are provably in-bounds (clamp / modulo); assert for noUncheckedIndexedAccess. if (once) return seq[Math.min(step, seq.length - 1)]! return seq[((step % seq.length) + seq.length) % seq.length]!}
/** Expand a per-frame sequence into the order a `'pingpong'` object plays them: * forward then backward, without repeating the endpoints (so a normal loop over * the result ping-pongs). `'loop'` returns the frames unchanged. Used by the * Pixi runtime, which has no native ping-pong. */export function playOrder<T>(frames: T[], playMode: 'loop' | 'pingpong'): T[] { if (playMode !== 'pingpong' || frames.length <= 2) return frames.slice() return frames.concat(frames.slice(1, -1).reverse())}
/** Resolve a node's (possibly absent / invalid) transform fields to concrete * values. Absent or non-positive scale falls back to 1; rotation normalises * into 0..3. */export function resolveTransform(o: ObjTransformFields): ObjTransform { const scale = o.scale && o.scale > 0 ? o.scale : 1 const rotation = ((((o.rotation ?? 0) | 0) % 4) + 4) % 4 return { scale, rotation, flipX: !!o.flipX, flipY: !!o.flipY }}
/** Whether a transform leaves geometry unchanged (lets callers take a fast path * and skip the ctx/sprite transform entirely). */export function isIdentity(t: ObjTransform): boolean { return t.scale === 1 && t.rotation === 0 && !t.flipX && !t.flipY}
/** Apply scale + flip, then a clockwise quarter-turn rotation, to a point given * RELATIVE to the anchor (y-down, so positive rotation is clockwise). Returns * the point still relative to the anchor. */export function transformPoint(x: number, y: number, t: ObjTransform): { x: number; y: number } { const px = x * t.scale * (t.flipX ? -1 : 1) const py = y * t.scale * (t.flipY ? -1 : 1) switch (t.rotation) { case 1: return { x: -py, y: px } // 90° CW case 2: return { x: -px, y: -py } // 180° case 3: return { x: py, y: -px } // 270° CW default: return { x: px, y: py } }}
/** Depth-sort adjustment for a transformed object. * * Depth sorts by the *far edge* of a footprint — its maximum coordinate along * the depth axis (larger = nearer the viewer). An object is placed by its * origin/anchor, which historically doubled as that far edge for upright * sprites. Under anchor-pivot the anchor never moves, so this returns how far * the transform shifts the footprint's far edge relative to the *untransformed* * footprint. Add it to the anchor's depth coordinate to get a transform-aware * sort key that equals the anchor exactly for the identity transform (existing * maps sort unchanged) and tracks the object's visible near edge as it * rotates/flips. `local` is the untransformed footprint, anchor-relative. */export function depthKeyOffset(local: Rect, t: ObjTransform, axis: 'x' | 'y'): number { const a = transformRect(local, t) return axis === 'y' ? a.y + a.h - (local.y + local.h) : a.x + a.w - (local.x + local.w)}
/** Transform an axis-aligned rect (expressed relative to the anchor) and return * its AABB, also relative to the anchor. Exact for 90°-multiple rotations, * flips, and uniform scale. */export function transformRect(r: Rect, t: ObjTransform): Rect { const corners = [ transformPoint(r.x, r.y, t), transformPoint(r.x + r.w, r.y, t), transformPoint(r.x, r.y + r.h, t), transformPoint(r.x + r.w, r.y + r.h, t), ] let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity for (const c of corners) { if (c.x < minX) minX = c.x if (c.y < minY) minY = c.y if (c.x > maxX) maxX = c.x if (c.y > maxY) maxY = c.y } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }}