// --------------------------------------------------------------------------- // 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))) 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(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) } /** Resolve an object's collision boxes to world-px AABBs: each box (relative to * the art's top-left) is re-expressed relative to the anchor, transformed the * same way as the sprite, then offset by the world position. Exact for * 90°-multiple rotations, flips, and uniform scale, so the result is * deterministic and host-agnostic. * * Shared so the baker (which folds this into `ObjectExport.collision`), the Pixi * runtime, and any external consumer all produce identical geometry — render and * collision can never drift. */ export function resolveCollision( origin: { x: number; y: number }, boxes: Rect[], t: ObjTransform, pos: { x: number; y: number }, ): Rect[] { return boxes.map((b) => { const r = transformRect({ x: b.x - origin.x, y: b.y - origin.y, w: b.w, h: b.h }, t) return { x: pos.x + r.x, y: pos.y + r.y, w: r.w, h: r.h } }) } /** 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 } }