Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
7.8 kB · 200 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201// ---------------------------------------------------------------------------// NPC accounts — the client-side presentation layer for decorative NPCs (PLAN §9).//// The engine/wire/server never know NPCs exist; the map only authors, on a marker// or area node, one-or-more `town.deception.npc` properties whose value is an NPC// account DID (+ optional weight — see `npcEntriesOf` in render/properties.ts).// Each such account self-describes: `actor.rpg.sprite/self` (the walk sheet) +// `town.deception.npc/self` (behaviour). There is no central catalog — we resolve// each DID directly and cache the result, so a herd of the same DID shares one// loaded sheet.//// `buildNpc()` turns a loaded account + a placement (position, stable seed, on-map// cell size, leash bounds) into a ready `NpcDef` + seeded `rng` for render/npc.ts's// `spawnNpc`/`NpcGroup`. Presentation only — none of this crosses the WebSocket.// ---------------------------------------------------------------------------
import { mulberry32, type NpcDef, type NpcWeights, type Rect, type NpcEntry } from '../../render/index'import { loadSpriteSheet, selfSpriteUri, type LoadedSprite } from './sprite-import'import { parseAtUri, resolvePds } from './map-import'
/** Behaviour read from a `town.deception.npc/self` record (all fields optional; * absent ones fall back to the render engine's defaults). */interface NpcBehaviour { wanderRadius: number speed: number yieldRadius: number weights: NpcWeights idleMs: [number, number] spinMs: [number, number] /** on-map height in eighths-of-a-cell, overriding the sprite's own value. */ sizeEighths?: number /** an emoji the NPC occasionally emotes above its head. */ emoji?: string}
/** A fully-loaded NPC account: its walk-sheet clips + parsed behaviour. */interface LoadedNpc { sheet: LoadedSprite behaviour: NpcBehaviour}
/** What a built NPC needs to enter the world: its def plus a seeded motion rng. */export interface BuiltNpc { def: NpcDef /** seeded so every viewer animates this placement identically (and stably). */ rng: () => number}
interface NpcRecordValue { wanderRadius?: number speed?: number yieldRadius?: number idleWeight?: number spinWeight?: number wanderWeight?: number idleMinMs?: number idleMaxMs?: number spinMinMs?: number spinMaxMs?: number sizeEighths?: number emoji?: string [k: string]: unknown}
/** A non-negative int field, or `fallback` when absent/unparseable. */function intOr(v: number | undefined, fallback: number): number { return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback}
/** Order a [min,max] pair so randRange never gets an inverted range. */function range(min: number, max: number): [number, number] { return min <= max ? [min, max] : [max, min]}
function toBehaviour(rec: NpcRecordValue): NpcBehaviour { return { wanderRadius: intOr(rec.wanderRadius, 64), speed: intOr(rec.speed, 45), yieldRadius: intOr(rec.yieldRadius, 56), weights: { idle: intOr(rec.idleWeight, 3), spin: intOr(rec.spinWeight, 1), wander: intOr(rec.wanderWeight, 2), }, idleMs: range(intOr(rec.idleMinMs, 800), intOr(rec.idleMaxMs, 2600)), spinMs: range(intOr(rec.spinMinMs, 700), intOr(rec.spinMaxMs, 1800)), ...(typeof rec.sizeEighths === 'number' && rec.sizeEighths > 0 ? { sizeEighths: rec.sizeEighths } : {}), ...(typeof rec.emoji === 'string' && rec.emoji ? { emoji: rec.emoji } : {}), }}
async function fetchNpcRecord(did: string): Promise<NpcRecordValue> { const pds = await resolvePds(did) const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}` + `&collection=town.deception.npc&rkey=self` const res = await fetch(url) if (!res.ok) throw new Error(`town.deception.npc/self getRecord failed (${res.status}) for ${did}`) return ((await res.json()) as { value: NpcRecordValue }).value}
// One load per DID, shared across every placement drawn from it (clip textures are// immutable views, safe to reuse). A failed load is cached as a rejection so we// don't re-hammer a dead account on each placement.const accountCache = new Map<string, Promise<LoadedNpc>>()
/** Load (and cache) an NPC account by DID: its walk sheet + behaviour. Rejects if * the sprite or npc record can't be fetched (the caller skips that placement). */export function loadNpcAccount(did: string): Promise<LoadedNpc> { let p = accountCache.get(did) if (!p) { p = (async () => { const [sheet, rec] = await Promise.all([loadSpriteSheet(selfSpriteUri(did)), fetchNpcRecord(did)]) return { sheet, behaviour: toBehaviour(rec) } })() p.catch(() => accountCache.delete(did)) accountCache.set(did, p) } return p}
/** Build a ready NPC for a placement. `area` (an area crowd's leash bounds) wins * when given; otherwise the NPC leashes to a `wanderOverride`- or behaviour-radius * box around (`x`,`y`) — the point-marker case. */export function buildNpc( loaded: LoadedNpc, opts: { x: number; y: number; seed: number; cellH: number; area?: Rect; wanderOverride?: number },): BuiltNpc { const { sheet, behaviour } = loaded const eighths = behaviour.sizeEighths ?? sheet.cellHeightEighths const scale = eighths && sheet.frameHeight > 0 ? ((eighths / 8) * opts.cellH) / sheet.frameHeight : 1 const radius = opts.wanderOverride ?? behaviour.wanderRadius const area: Rect = opts.area ?? { x: opts.x - radius, y: opts.y - radius, w: radius * 2, h: radius * 2, } const def: NpcDef = { clips: sheet.clips, weights: behaviour.weights, area, speed: behaviour.speed, yieldRadius: behaviour.yieldRadius, idleMs: behaviour.idleMs, spinMs: behaviour.spinMs, scale, ...(behaviour.emoji ? { emoji: behaviour.emoji } : {}), } return { def, rng: mulberry32(opts.seed ^ 0x9e3779b9) }}
/** Pick one entry's DID by weight, deterministically from `seed`. For a marker that * lists several critters: a stable per-placement choice so a row of markers varies. */export function weightedPick(entries: NpcEntry[], seed: number): string | null { const total = entries.reduce((s, e) => s + e.weight, 0) if (total <= 0) return null let roll = mulberry32(seed)() * total for (const e of entries) { roll -= e.weight if (roll < 0) return e.did } return entries[entries.length - 1]!.did}
/** Union bounding box of an area's rects — the leash a scattered crowd roams. */export function bboxOf(rects: Rect[]): Rect | null { if (rects.length === 0) return null let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const r of rects) { minX = Math.min(minX, r.x) minY = Math.min(minY, r.y) maxX = Math.max(maxX, r.x + r.w) maxY = Math.max(maxY, r.y + r.h) } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }}
/** A point inside an area's rects, drawn from `rng` (area-weighted rect choice then * a uniform point within it), so a crowd scatters proportionally across the boxes. */export function scatterPoint(rects: Rect[], rng: () => number): { x: number; y: number } | null { if (rects.length === 0) return null const total = rects.reduce((s, r) => s + r.w * r.h, 0) let roll = rng() * total let pick = rects[0]! for (const r of rects) { roll -= r.w * r.h pick = r if (roll < 0) break } return { x: pick.x + rng() * pick.w, y: pick.y + rng() * pick.h }}
/** Stable 32-bit seed from a node id (+ optional index), so every viewer derives the * same DID choice + scatter + motion for a placement (and it's stable across reloads). */export function seedFromId(id: string): number { let h = 0 for (let i = 0; i < id.length; i++) h = (Math.imul(h, 31) + id.charCodeAt(i)) >>> 0 return h}