// --------------------------------------------------------------------------- // Live entity layer — the moving, animated sprites the static scene has no // notion of: players, NPCs, carried items, debris. An entity is a depth-sorted // `AnimatedSprite` at a world position, switchable between named animation clips // and (optionally) gliding toward a target so discrete network position samples // render smoothly. // // This is the runtime counterpart to `buildScene`'s static handles. It stays // game-agnostic: it knows nothing of `town.deception`. The game maps a projected // player (pos / facing / moving) onto an entity — set its clip from facing, its // target from the latest position sample — and the engine renders it sorted in // with the map's objects. // // Pixi advances `AnimatedSprite` frames off the shared ticker on its own, so the // only per-frame work here is gliding + depth refresh: call `layer.update(dt)`. // --------------------------------------------------------------------------- import { AnimatedSprite, Container, Rectangle, Texture } from 'pixi.js' import { type DepthAxis, projectDepth } from './contract' /** A named animation: ordered frames + per-frame timing. */ export interface Clip { frames: Texture[] frameDurationMs: number /** loop forever (default true) or play once and hold the final frame. */ loop?: boolean } export interface EntityOptions { /** named clips; switch the active one with {@link Entity.play}. */ clips: Record /** initial clip name (must be a key of `clips`). */ clip: string /** world position (cell px) of the anchor — the entity's feet by default. */ x: number y: number /** sprite anchor (0..1). Default bottom-centre (0.5, 1): feet sit at (x,y), * matching how map objects anchor, so depth sorting lines up. */ anchor?: { x: number; y: number } /** uniform scale. Default 1. */ scale?: number /** px/sec for {@link Entity.glideTo}. 0 (default) = snap immediately. */ glideSpeed?: number } /** A live, movable, depth-sorted sprite. */ export interface Entity { readonly sprite: AnimatedSprite /** current anchor position (cell px). Treat as read-only; use the setters. */ x: number y: number /** snap to a world position and refresh depth. */ setPosition(x: number, y: number): void /** set a single target the entity eases toward in {@link EntityLayer.update}, * at `glideSpeed` px/sec. Use for smoothing network position samples. With * glideSpeed 0 this is equivalent to {@link setPosition}. Shorthand for a * one-point {@link followPath}. */ glideTo(x: number, y: number): void /** walk a polyline of waypoints (e.g. a server A* path shipped for * interpolation) at `glideSpeed`, consuming each as it is reached. Replaces * any current path/target. The waypoints should continue from the current * position. */ followPath(points: readonly { x: number; y: number }[]): void /** remaining upcoming waypoints (excludes the current position); empty when * idle. Prepend `{x,y}` to get the full forward polyline — e.g. for a gate's * path-crossing test. */ readonly path: readonly { x: number; y: number }[] /** px/sec used by glide/path; settable (e.g. from the round's moveSpeed). */ glideSpeed: number /** whether a glide/path traversal is still in progress. */ readonly gliding: boolean /** select the active clip by name (no-op if already current). */ play(name: string): void /** hold the current clip on a frame (default 0) and stop animating. */ stop(frame?: number): void /** the active clip name. */ readonly clip: string /** remove from the world and free the sprite. */ destroy(): void } /** Manages a pool of entities sharing one world container + depth axis. */ export interface EntityLayer { /** spawn an entity into the world's depth-sorted pool. */ spawn(opts: EntityOptions): Entity /** live entities, in spawn order. */ readonly entities: readonly Entity[] /** advance glides and refresh depth for everything that moved. Call per frame * with the elapsed milliseconds. */ update(dtMs: number): void /** destroy every entity this layer spawned. */ destroy(): void } const DEFAULT_ANCHOR = { x: 0.5, y: 1 } /** Internal: an entity plus its per-frame stepper, owned by the layer. */ interface Slot { entity: Entity step: (dtMs: number) => void } /** Create an entity layer over a (sortable) world container. The container is * usually the same one passed to {@link buildScene}; pass `scene.depthAxis` so * entities sort in with the map's objects. */ export function createEntityLayer(world: Container, depthAxis: DepthAxis): EntityLayer { const slots: Slot[] = [] function spawn(opts: EntityOptions): Entity { const anchor = opts.anchor ?? DEFAULT_ANCHOR let clipName = opts.clip const first = opts.clips[clipName] if (!first) throw new Error(`entity clip "${clipName}" not in clips`) const sprite = new AnimatedSprite(first.frames) sprite.anchor.set(anchor.x, anchor.y) sprite.scale.set(opts.scale ?? 1) sprite.position.set(opts.x, opts.y) sprite.zIndex = projectDepth(depthAxis, opts.x, opts.y) applyClip(sprite, first) world.addChild(sprite) // glide state: gx/gy is the true (sub-pixel) position; `queue` is the // remaining waypoints to walk (empty when settled). let gx = opts.x let gy = opts.y let queue: { x: number; y: number }[] = [] const place = (x: number, y: number) => { gx = x gy = y entity.x = x entity.y = y sprite.position.set(Math.round(x), Math.round(y)) sprite.zIndex = projectDepth(depthAxis, x, y) } const entity: Entity = { sprite, x: opts.x, y: opts.y, glideSpeed: opts.glideSpeed ?? 0, get gliding() { return queue.length > 0 }, get path() { return queue }, get clip() { return clipName }, setPosition(x, y) { queue = [] place(x, y) }, glideTo(x, y) { this.followPath([{ x, y }]) }, followPath(points) { if (this.glideSpeed <= 0) { const last = points[points.length - 1] if (last) this.setPosition(last.x, last.y) return } queue = points.map((p) => ({ x: p.x, y: p.y })) }, play(name) { if (name === clipName) return const clip = opts.clips[name] if (!clip) throw new Error(`entity clip "${name}" not in clips`) clipName = name sprite.textures = clip.frames applyClip(sprite, clip) }, stop(frame = 0) { sprite.gotoAndStop(Math.min(frame, sprite.textures.length - 1)) }, destroy() { const i = slots.findIndex((s) => s.entity === entity) if (i >= 0) slots.splice(i, 1) world.removeChild(sprite) sprite.destroy() }, } const step = (dtMs: number) => { // walk the waypoint queue, spending this frame's distance budget across as // many waypoints as it reaches (so dense paths don't stall a waypoint/frame). let budget = (entity.glideSpeed * dtMs) / 1000 while (budget > 0 && queue.length > 0) { const wp = queue[0]! const dx = wp.x - gx const dy = wp.y - gy const dist = Math.hypot(dx, dy) if (dist <= budget || dist === 0) { place(wp.x, wp.y) queue.shift() budget -= dist } else { place(gx + (dx / dist) * budget, gy + (dy / dist) * budget) budget = 0 } } } slots.push({ entity, step }) return entity } return { spawn, get entities() { return slots.map((s) => s.entity) }, update(dtMs) { for (const s of slots) s.step(dtMs) }, destroy() { for (const s of [...slots]) s.entity.destroy() }, } } /** Start a clip playing (or hold its single frame). */ function applyClip(sprite: AnimatedSprite, clip: Clip): void { if (clip.frames.length > 1) { sprite.animationSpeed = 1000 / 60 / Math.max(16, clip.frameDurationMs) sprite.loop = clip.loop ?? true sprite.gotoAndPlay(0) } else { sprite.gotoAndStop(0) } } // --------------------------------------------------------------------------- // Sprite-sheet slicing — build clips from a grid sheet (e.g. the 48×48, // 3-col × 4-row directional walk sheet used by `actor.rpg.sprite`). // --------------------------------------------------------------------------- /** Cut a frame rectangle (col,row in a fixed grid) out of a sheet texture. */ export function sheetFrame( sheet: Texture, col: number, row: number, frameW: number, frameH: number, ): Texture { // a sub-texture shares the sheet's source with its own frame rect (pixi v8). return new Texture({ source: sheet.source, frame: new Rectangle(col * frameW, row * frameH, frameW, frameH), }) } export type Facing = 'south' | 'west' | 'east' | 'north' export interface WalkSheetLayout { frameW: number frameH: number /** number of walk columns per direction row (e.g. 3). */ cols: number /** sheet row index for each facing. */ rows: Record /** column used as the rest/idle pose. Default the middle column. */ restCol?: number /** per-frame duration of the walk cycle (ms). Default 120. */ frameDurationMs?: number } /** * Build the 8 standard actor clips — `walk-` and `idle-` for each of * south/west/east/north — from a directional walk sheet. The walk cycle steps * cols `0,1,…,n-1` then back to the rest column, so a 3-column sheet animates * `0,1,2,1`; idle holds the rest column. Game code plays * `` `${moving ? 'walk' : 'idle'}-${facing}` ``. */ export function walkSheetClips(sheet: Texture, layout: WalkSheetLayout): Record { const { frameW, frameH, cols } = layout const rest = layout.restCol ?? Math.floor(cols / 2) const dur = layout.frameDurationMs ?? 120 const order = cols <= 1 ? [0] : Array.from({ length: cols }, (_, i) => i).concat([rest]) const clips: Record = {} for (const dir of Object.keys(layout.rows) as Facing[]) { const row = layout.rows[dir] clips[`walk-${dir}`] = { frames: order.map((c) => sheetFrame(sheet, c, row, frameW, frameH)), frameDurationMs: dur, } clips[`idle-${dir}`] = { frames: [sheetFrame(sheet, rest, row, frameW, frameH)], frameDurationMs: dur, } } return clips }