// --------------------------------------------------------------------------- // Cosmetic auto-gates — a door/gate that animates open when a sprite *paths // through* it and closes once it has passed, purely for flavour. It is NOT an // obstacle: the nav grid keeps gate cells permanently passable, so A* paths // never change and nothing desyncs. (If gates ever need to actually block — "the // saboteur left it open" as evidence — that's the server-authoritative, // dynamic-nav escalation; `setObjectSolid` in scene.ts is the runtime collision // hook for that future.) // // Trigger = PATH CROSSING, not proximity. The gate opens only when a supplied // path crosses its doorway segment within a short look-ahead, so: // • a player whose A* route goes through the doorway opens it (just in time), // • a player walking *past* it (a route that never crosses) does NOT, // • NPCs never open it — the caller simply doesn't pass their paths. // Backends that ship A* paths down for interpolation feed those straight in. // // Openness (0 closed → 1 open) is scrubbed across the object's animation frames // (not Pixi play/reverse), so it's fully interruptible. Author the gate object // as a plain forward animation: frame 0 closed, last frame open. // --------------------------------------------------------------------------- import type { ObjectInstance } from './scene' interface Point { x: number y: number } export interface GateOptions { /** the doorway segment a path must cross to open. When omitted, a segment of * length `span` is built across the object's anchor along `axis`. */ threshold?: readonly [Point, Point] /** doorway width in px (only used when `threshold` is omitted). Default 48. */ span?: number /** auto-doorway orientation: `'x'` = a horizontal bar crossed by vertical * movement, `'y'` = a vertical bar. Default `'x'`. */ axis?: 'x' | 'y' /** doorway centre when `threshold` is omitted. Default the object's anchor. */ at?: Point /** open when the crossing is within this much path-distance ahead (px), so the * gate is open by the time the sprite arrives. Default 110. */ lookahead?: number /** full open (or close) sweep time, ms. Default 220. */ sweepMs?: number /** stay open this long after the crossing leaves the path, ms — covers the * sprite's body still clearing the doorway. Default 350. */ lingerMs?: number } export interface Gate { readonly object: ObjectInstance /** 0 = fully closed (frame 0), 1 = fully open (last frame). */ readonly openness: number /** drive the gate from forward polylines. Pass ONLY the paths that may open it * (player paths) — each polyline is `[current pos, ...remaining waypoints]`, * i.e. build it as `[{ x: e.x, y: e.y }, ...e.path]`. Omit NPC paths and they * never open it. */ update(dtMs: number, paths: readonly (readonly Point[])[]): void } /** Make a placed animated object behave as a path-triggered auto-gate. The * object stays non-colliding (nav owns passability); this only scrubs frames. */ export function createGate(object: ObjectInstance, opts: GateOptions = {}): Gate { const span = opts.span ?? 48 const at = opts.at ?? { x: object.node.pos.x, y: object.node.pos.y } const axis = opts.axis ?? 'x' const threshold: readonly [Point, Point] = opts.threshold ?? (axis === 'x' ? [{ x: at.x - span / 2, y: at.y }, { x: at.x + span / 2, y: at.y }] : [{ x: at.x, y: at.y - span / 2 }, { x: at.x, y: at.y + span / 2 }]) const lookahead = opts.lookahead ?? 110 const sweepMs = Math.max(1, opts.sweepMs ?? 220) const lingerMs = opts.lingerMs ?? 350 const lastFrame = Math.max(0, object.sprite.textures.length - 1) let openness = 0 let linger = 0 object.sprite.gotoAndStop(0) // start closed; we own the frame from here return { object, get openness() { return openness }, update(dtMs, paths) { const wantOpen = paths.some((p) => pathCrosses(p, threshold[0], threshold[1], lookahead)) linger = wantOpen ? lingerMs : Math.max(0, linger - dtMs) const target = wantOpen || linger > 0 ? 1 : 0 const delta = dtMs / sweepMs openness = target > openness ? Math.min(target, openness + delta) : Math.max(target, openness - delta) object.sprite.gotoAndStop(Math.round(openness * lastFrame)) }, } } /** Does the forward polyline cross segment a–b within `lookahead` px of its * start? Walks segments accumulating path-distance and bails once the start of * a segment is already beyond the look-ahead. */ function pathCrosses(path: readonly Point[], a: Point, b: Point, lookahead: number): boolean { let dist = 0 for (let i = 0; i + 1 < path.length; i++) { if (dist > lookahead) return false const p = path[i]! const q = path[i + 1]! if (segmentsCross(p, q, a, b)) return true dist += Math.hypot(q.x - p.x, q.y - p.y) } return false } /** Proper intersection of segments p1p2 and p3p4 (touching/collinear = false). */ function segmentsCross(p1: Point, p2: Point, p3: Point, p4: Point): boolean { const side = (a: Point, b: Point, c: Point) => Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) return side(p3, p4, p1) !== side(p3, p4, p2) && side(p1, p2, p3) !== side(p1, p2, p4) }