Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
11 kB · 295 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296// ---------------------------------------------------------------------------// 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<string, Clip> /** 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<Facing, number> /** 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-<dir>` and `idle-<dir>` 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<string, Clip> { 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<string, Clip> = {} 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}