Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
13 kB · 318 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319// ---------------------------------------------------------------------------// Scene builder — the central rendering seam.//// `buildScene` turns a baked `MapExport` (+ its already-loaded atlas texture)// into Pixi drawables inside a caller-owned `world` container, and returns a// `MapScene`: the colliders, the painted bounds, and a stable handle per object// / area / marker / canvas. Those handles ARE the per-object / per-area// extension points — a consuming game iterates them and attaches behaviour keyed// off each node's `properties`, with no knowledge of how the scene was baked.//// The Sandbox renders a *MapExport*: the editor's offset-grid Wang resolution is// baked into a per-map sprite sheet, so here we only blit atlas pointers — no// tiling logic. Objects stay live (animation + depth); collision is collapsed to// world-px boxes.//// `buildScene` is synchronous (the atlas is loaded by the caller, e.g. via// `loadTexture`), does no store/React work, and subscribes to nothing. The// caller owns `world` — its scale, `sortableChildren`, and the camera. It also// owns when to rebuild: call `scene.destroy()` then `buildScene` again.// ---------------------------------------------------------------------------
import { AnimatedSprite, Container, Rectangle, Sprite, Texture } from 'pixi.js'import { type AreaExport, type CanvasExport, type DepthAxis, type MapExport, type MarkerExport, type ObjectDefExport, type ObjectExport, type Property, type Rect, projectDepth,} from './contract'import { depthKeyOffset, playOrder, resolveTransform, transformRect } from './transform'import { objectAnimationFrameOf, objectAnimationModeOf, objectAnimationOnceOf, objectAnimationPlayingOf, objectAnimationReverseOf,} from './properties'import { makeAtlasSlicer } from './textures'
// depth bands well beyond any object/player projection, for ground/sky canvasesconst GROUND_Z = -1e9const SKY_Z = 1e9
/** A solid box in the scene's collision world. Unlike a bare `Rectangle` it can * be toggled off at runtime — an opened gate/door deactivates its boxes so * collision queries pass through, without rebuilding the scene. `owner` is the * placed object the box came from, or null for a map-level collision node. */export interface Collider { rect: Rectangle /** when false, {@link rectOverlapsAny} ignores this box. */ active: boolean owner: ObjectInstance | null}
/** A placed object's live handle: its export node, the (shared) definition it * draws from, and the Pixi sprite rendering it. Game code attaches behaviour by * reading `properties` (= `node.properties`) and driving `sprite` — or, for a * gate/door, toggling `colliders` via {@link setObjectSolid} and animating with * {@link playObject} / {@link stopObject}. */export interface ObjectInstance { node: ObjectExport def: ObjectDefExport sprite: AnimatedSprite properties: Property[] /** this object's own collision boxes (a subset of {@link MapScene.colliders}), * so its solidity can be toggled independently. Empty when the def has none. */ colliders: Collider[]}
/** A trigger region's handle. `contains` tests a world point (cell px) against * the additive union of its boxes. Collision-flagged areas are NOT here — they * are baked to `collision` nodes and folded into {@link MapScene.colliders}. */export interface AreaInstance { node: AreaExport rects: Rect[] properties: Property[] contains: (x: number, y: number) => boolean}
/** A meaning-carrying point's handle (spawn, NPC placement, waypoint, …). */export interface MarkerInstance { node: MarkerExport x: number y: number properties: Property[]}
/** A painted layer's handle: the export node and the container holding its blits. */export interface CanvasInstance { node: CanvasExport container: Container}
export interface MapScene { /** canonical cell size (px) — the atlas bake size and the renderer's zoom. */ readonly cell: { w: number; h: number } readonly depthAxis: DepthAxis /** painted world-px extent of all canvas sprites, or null when empty. */ readonly bounds: Rect | null /** solid boxes (cell px) from collision nodes + object collision footprints. * Each carries an `active` flag (see {@link Collider}) so gates/doors can be * opened at runtime; pass this list to {@link rectOverlapsAny}. */ readonly colliders: Collider[] readonly objects: ObjectInstance[] readonly areas: AreaInstance[] readonly markers: MarkerInstance[] readonly canvases: CanvasInstance[] /** the map's backdrop (null = none); exposed for a renderer to draw. */ readonly background: MapExport['background'] /** project a world point to its depth scalar, for sorting live entities. */ depthOf: (x: number, y: number) => number /** remove + destroy every drawable this scene added to `world`, and free the * atlas + interned sub-textures. Call before building the next scene. */ destroy: () => void}
/** A canvas layer's scalar depth band (objects/entities interleave around it): * back sits behind, front in front, dynamic joins the pool at its far edge. */function bandZ(node: { sort: 'ground' | 'sky' | 'dynamic'; depth: number }): number { if (node.sort === 'sky') return SKY_Z if (node.sort === 'dynamic') return node.depth return GROUND_Z}
/** Whether a world point lies inside any of the (cell px) boxes. */function pointInRects(rects: Rect[], x: number, y: number): boolean { return rects.some((r) => x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h)}
/** * Build all drawables for `exp` into `world`, returning the scene model. `atlas` * is the decoded baked sheet (or null for a colour-only / failed bake — sprites * that need art are then skipped). */export function buildScene(world: Container, exp: MapExport, atlas: Texture | null): MapScene { const depthAxis = exp.depthAxis const slicer = makeAtlasSlicer(atlas, exp.sheet.entries) const texFor = slicer.texFor
// drawables we own and tear down wholesale on destroy() const built: Container[] = [] const colliders: Collider[] = [] const objects: ObjectInstance[] = [] const areas: AreaInstance[] = [] const markers: MarkerInstance[] = [] const canvases: CanvasInstance[] = []
const defById = new Map(exp.objectDefs.map((d) => [d.id, d]))
exp.nodes.forEach((node, index) => { if (node.kind === 'canvas') { const layer = new Container() // composite members carry a pre-resolved unit key; otherwise sort by the // band, with panel order breaking ties (later layer draws on top) layer.zIndex = node.sortKey ?? bandZ(node) + index for (const sp of node.sprites) { const tex = texFor(sp.atlas) if (!tex) continue const s = new Sprite(tex) s.position.set(sp.x, sp.y) layer.addChild(s) } // animated materials: cycle the baked per-frame tiles, same clock model as // animated objects. for (const sp of node.animSprites) { const texes = sp.frames.map(texFor).filter((t): t is Texture => !!t) if (texes.length === 0) continue const s = new AnimatedSprite(texes) s.position.set(sp.x, sp.y) if (texes.length > 1) { s.animationSpeed = 1000 / 60 / Math.max(16, sp.frameDurationMs) s.play() } layer.addChild(s) } world.addChild(layer) built.push(layer) canvases.push({ node, container: layer }) } else if (node.kind === 'object') { const def = defById.get(node.defId) if (!def) return const texes = def.frames.map(texFor).filter((t): t is Texture => !!t) if (texes.length === 0) return const t = resolveTransform(node) // Build the playback sequence: the def's mode sets the cycle shape // (pingpong is expanded forward+back since Pixi has no native ping-pong), // then per-instance `reverse` flips it. const reverse = objectAnimationReverseOf(node.properties) const seq = playOrder(texes, objectAnimationModeOf(def.properties)) if (reverse) seq.reverse() const spr = new AnimatedSprite(seq) // Anchor the sprite at the object's origin (its feet) so Pixi scales and // rotates about that point — matching the editor's anchor pivot. spr.anchor.set( def.size.w ? def.origin.x / def.size.w : 0, def.size.h ? def.origin.y / def.size.h : 0, ) spr.position.set(node.pos.x, node.pos.y) spr.rotation = (t.rotation * Math.PI) / 2 spr.scale.set(t.scale * (t.flipX ? -1 : 1), t.scale * (t.flipY ? -1 : 1)) // sort by the transformed footprint's far edge (anchor-relative // origin/size), so a rotated/flipped object occludes by where its art now // lies, not by the fixed anchor. const dOff = depthKeyOffset( { x: -def.origin.x, y: -def.origin.y, w: def.size.w, h: def.size.h }, t, depthAxis, ) spr.zIndex = node.sortKey ?? projectDepth(depthAxis, node.pos.x, node.pos.y) + dOff if (texes.length > 1 && objectAnimationPlayingOf(node.properties)) { spr.animationSpeed = 1000 / 60 / Math.max(16, def.frameDurationMs) // `once` plays a single pass and holds the final frame; continuous loops. spr.loop = !objectAnimationOnceOf(node.properties) spr.play() } else { // stopped: hold the chosen base frame. The forward `seq` begins with the // base frames in order, so a reversed `seq` puts base frame f at index // (len-1-f). const held = Math.min(objectAnimationFrameOf(node.properties), texes.length - 1) spr.gotoAndStop(reverse ? seq.length - 1 - held : held) } world.addChild(spr) built.push(spr) const instance: ObjectInstance = { node, def, sprite: spr, properties: node.properties, colliders: [] } objects.push(instance) for (const b of def.collision) { // collision box relative to the anchor, then transformed the same way as // the sprite (exact AABB — rotations are 90° multiples). const r = transformRect({ x: b.x - def.origin.x, y: b.y - def.origin.y, w: b.w, h: b.h }, t) const c: Collider = { rect: new Rectangle(node.pos.x + r.x, node.pos.y + r.y, r.w, r.h), active: true, owner: instance, } instance.colliders.push(c) colliders.push(c) } } else if (node.kind === 'collision') { colliders.push({ rect: new Rectangle(node.rect.x, node.rect.y, node.rect.w, node.rect.h), active: true, owner: null, }) } else if (node.kind === 'area') { areas.push({ node, rects: node.rects, properties: node.properties, contains: (x, y) => pointInRects(node.rects, x, y), }) } else if (node.kind === 'marker') { markers.push({ node, x: node.x, y: node.y, properties: node.properties }) } })
let destroyed = false return { cell: exp.cell, depthAxis, bounds: exp.bounds, colliders, objects, areas, markers, canvases, background: exp.background, depthOf: (x, y) => projectDepth(depthAxis, x, y), destroy() { if (destroyed) return destroyed = true for (const c of built) { world.removeChild(c) c.destroy({ children: true }) } built.length = 0 slicer.destroy() atlas?.destroy(true) }, }}
// ---------------------------------------------------------------------------// Runtime object control — for gates/doors and other state-changing props.//// The data is all present in the MapExport (an object carries its own animation// frames AND collision boxes), so no export change is needed: a gate is just a// placed object with >1 frame and a collision box, tagged via its `properties`// bag with `town.deception.autoGate` (see `objectAutoGateOf` in properties.ts).// These helpers drive that object at runtime.// ---------------------------------------------------------------------------
/** Toggle whether a placed object blocks movement (e.g. open/close a gate). Pure * runtime state — flips the `active` flag on the object's colliders so later * {@link rectOverlapsAny} queries pass through (open) or stop (closed). No-op * for an object the def gave no collision boxes. */export function setObjectSolid(obj: ObjectInstance, solid: boolean): void { for (const c of obj.colliders) c.active = solid}
/** Play a placed object's animation — e.g. a gate swinging open. `once` holds the * final frame after a single pass (the open/closed end state); the default loops * (a flickering torch). Frames come from the object's def, in its authored play * order. */export function playObject(obj: ObjectInstance, { once = false }: { once?: boolean } = {}): void { obj.sprite.loop = !once obj.sprite.gotoAndPlay(0)}
/** Stop a placed object's animation and hold a frame (default the first — its * base/closed pose). */export function stopObject(obj: ObjectInstance, frame = 0): void { obj.sprite.gotoAndStop(Math.min(frame, obj.sprite.textures.length - 1))}