Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
17 kB · 407 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408// Reads collision geometry out of a `world.tilemap.map` record (authored in tilemap.world) and// turns it into a navigation grid for pathfinding.//// Collision in that format is an `areaNode` carrying the well-known property// `world.tilemap.collision = "1"`; its shape is the additive union of pixel `boxes`. There is no// separate collision node kind. We only need a minimal structural subset of the record here.
import { buildNav } from './nav.js'import type { NavGrid, Rect, StationSeed, Vec } from './types.js'
export const COLLISION_PROPERTY = 'world.tilemap.collision'export const COLLISION_VALUE = '1'
// Station authoring (capability flags). An `areaNode` becomes a station by carrying any of these// well-known properties — combine freely (e.g. a restaurant table that both collects dirty dishes// and accepts clean ones). The map authors resource AT-URIs (town.deception.resource records),// never display strings, and never slot counts (slots come from player count at room creation).// Each resource value is the in-play resource id on the wire; its name/sprite are resolved// client-side from the referenced record. A property may carry several resources either as a// comma-separated value or by repeating the same property key (both are merged).export const COLLECT_PROPERTY = 'town.deception.collect' // collectable resource AT-URI(s)export const DEPOSIT_PROPERTY = 'town.deception.deposit' // depositable resource AT-URI(s)export const SCOREBOARD_PROPERTY = 'town.deception.scoreboard' // resource AT-URI(s) to show (empty/absent → not a scoreboard; enumerate every resource to display)export const MEETING_PROPERTY = 'town.deception.meeting' // "1" → can call a vote hereexport const STATION_ID_PROPERTY = 'town.deception.stationId' // optional explicit idexport const STATION_NAME_PROPERTY = 'town.deception.stationName' // optional human label, e.g. "Chicken coop"
// Spawn region, same convention as collision: an `areaNode` tagged `town.deception.spawn` = "1".// Players are placed inside it during the grace period and at round start. Optional — callers fall// back to a default spawn when the map authors none.export const SPAWN_PROPERTY = 'town.deception.spawn'export const SPAWN_VALUE = '1'
// Decorative NPCs (PLAN §9, pure set dressing): a node tagged `town.deception.npc` places an NPC,// resolved entirely client-side — the engine/server never animate or simulate NPCs.export const NPC_PROPERTY = 'town.deception.npc'
// Auto-gates: an object tagged `town.deception.autoGate` = "1" is a door/gate that opens when a// player paths into it (the client animates it open and de-solidifies its colliders). It must NOT// block server pathfinding, so its collision boxes are dropped from the nav grid.export const AUTO_GATE_PROPERTY = 'town.deception.autoGate'
/** * All resource values authored for `key` on a node, in first-seen order: every property with that * key (the same key may repeat) comma-split, trimmed, de-duplicated, non-empty. So both * `collect = "at://a,at://b"` and two separate `collect` properties yield `["at://a","at://b"]`. */function multiValues(node: MapNode, key: string): string[] { const out: string[] = [] for (const p of node.properties ?? []) { if (p.key !== key) continue for (const part of p.value.split(',')) { const v = part.trim() if (v && !out.includes(v)) out.push(v) } } return out}
/** Final path segment (rkey) of an AT-URI, used to label an auto-generated station id readably. */function rkeyOf(uri: string): string { const seg = uri.split('/').pop() return seg && seg.length ? seg : uri}
const isFlag = (value: string | undefined): boolean => value === '1' || value === 'true'
/** A rectangle in map pixels (top-left origin, y-down). */export interface Box { x: number y: number w: number h: number}
interface Property { key: string value: string}
/** Structural subset of a scene node — enough to find collision areas and recurse groups. */interface MapNode { $type?: string hidden?: boolean properties?: Property[] /** present on areaNode */ boxes?: Box[] /** present on groupNode */ children?: MapNode[]}
/** Structural subset of a `world.tilemap.map` record. */export interface MapRecord { grid: { tileWidth: number; tileHeight: number } nodes?: MapNode[]}
function propOf(node: MapNode, key: string): string | undefined { return node.properties?.find((p) => p.key === key)?.value}
/** Union bounding box of a set of boxes (assumes non-empty). */function unionBox(boxes: Box[]): Rect { let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity for (const b of boxes) { minX = Math.min(minX, b.x) minY = Math.min(minY, b.y) maxX = Math.max(maxX, b.x + b.w) maxY = Math.max(maxY, b.y + b.h) } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }}
function hasProp(node: MapNode, key: string, value: string): boolean { return node.properties?.some((p) => p.key === key && p.value === value) ?? false}
function isCollisionArea(node: MapNode): boolean { return Array.isArray(node.boxes) && hasProp(node, COLLISION_PROPERTY, COLLISION_VALUE)}
/** All collision boxes in the map (map px), recursing into groups and skipping hidden nodes. */export function collisionBoxes(map: MapRecord): Box[] { const out: Box[] = [] const walk = (nodes: MapNode[] | undefined): void => { for (const node of nodes ?? []) { if (node.hidden) continue if (isCollisionArea(node)) out.push(...(node.boxes ?? [])) if (node.children) walk(node.children) } } walk(map.nodes) return out}
/** Union bounds of a set of rects, with optional padding; null when empty. */function boundsOf(rects: Rect[], pad: number): Rect | null { if (rects.length === 0) return null let minX = Infinity let minY = Infinity let maxX = -Infinity let 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 - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 }}
export interface NavFromMapOptions { /** nav cell size (world px). Defaults to a third of the tile (the editor's subcell grid). */ cellSize?: number /** expand collision rects by this many px (≈ player radius). Default 0. */ inflate?: number /** explicit grid extent (world px). Defaults to the collision bounds plus padding. */ bounds?: Rect}
/** Build a navigation grid directly from a map record. */export function navFromMap(map: MapRecord, opts: NavFromMapOptions = {}): NavGrid { const cellSize = opts.cellSize ?? Math.max(1, Math.round(map.grid.tileWidth / 3)) const boxes = collisionBoxes(map) const bounds = opts.bounds ?? boundsOf(boxes, cellSize * 4) ?? { x: 0, y: 0, w: cellSize, h: cellSize, } return buildNav(boxes, { cellSize, bounds, inflate: opts.inflate ?? 0 })}
// --- Baked-export collision (the map record's `render` blob) ------------------// The editor bakes the authored scene into a self-contained MapExport (the same// data the renderer consumes). The bake is a *better* collision source than the// authored nodes: it flattens collision areas into standalone `collision` nodes// AND resolves every placed object's per-def collision boxes into world space —// whereas an authored `objectNode` only references its def by tileset/objectId,// so collisionBoxes() above can't see object colliders at all. We declare the// minimal structural subset we read here (same approach as MapRecord), so the// engine stays free of any render/editor type dependency.
interface ExportRect { x: number y: number w: number h: number}interface ExportObjectDef { id: string /** anchor offset from the art's top-left (cell px); object collision is relative to this. */ origin: { x: number; y: number } collision: ExportRect[]}interface ExportNode { kind: string /** object node: which def supplies the art + collision, and its placement/transform. */ defId?: string pos?: { x: number; y: number } scale?: number rotation?: number flipX?: boolean flipY?: boolean /** per-instance flags (e.g. `town.deception.autoGate`). */ properties?: Property[] /** collision node: a standalone solid box (already in world px). */ rect?: ExportRect}/** Structural subset of a baked `MapExport` (record.render `data` blob). */export interface MapExportSubset { objectDefs?: ExportObjectDef[] nodes?: ExportNode[] /** painted world-px extent; used as the nav grid bounds when present. */ bounds?: Rect | null}
/** Apply scale + flip then a clockwise quarter-turn to a collision box expressed relative to the * art top-left, anchored at `origin`, returning its world-relative AABB. Exact for 90°-multiple * rotations/flips/uniform scale — mirrors render/transform.ts, kept local so the engine needs no * render dependency. */function transformColliderBox( b: ExportRect, origin: { x: number; y: number }, scale: number, rotation: number, flipX: boolean, flipY: boolean,): Box { const rot = ((rotation % 4) + 4) % 4 const point = (x: number, y: number): { x: number; y: number } => { const px = x * scale * (flipX ? -1 : 1) const py = y * scale * (flipY ? -1 : 1) switch (rot) { case 1: return { x: -py, y: px } // 90° CW case 2: return { x: -px, y: -py } // 180° case 3: return { x: py, y: -px } // 270° CW default: return { x: px, y: py } } } const x0 = b.x - origin.x const y0 = b.y - origin.y const corners = [point(x0, y0), point(x0 + b.w, y0), point(x0, y0 + b.h), point(x0 + b.w, y0 + b.h)] let minX = Infinity let minY = Infinity let maxX = -Infinity let maxY = -Infinity for (const c of corners) { minX = Math.min(minX, c.x) minY = Math.min(minY, c.y) maxX = Math.max(maxX, c.x) maxY = Math.max(maxY, c.y) } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }}
/** All solid collision boxes of a baked map export, in world px: every standalone `collision` node, * plus each placed `object`'s def-collision boxes transformed into world space. Trigger `area` * nodes are not collision (they carry behaviour via properties), matching the renderer. */export function collisionBoxesFromExport(exp: MapExportSubset): Box[] { const defs = new Map((exp.objectDefs ?? []).map((d) => [d.id, d])) const out: Box[] = [] for (const node of exp.nodes ?? []) { if (node.kind === 'collision' && node.rect) { out.push({ ...node.rect }) } else if (node.kind === 'object' && node.pos && node.defId) { // Auto-gates open when walked into, so they must not block pathfinding — drop their colliders. if (node.properties?.some((p) => p.key === AUTO_GATE_PROPERTY && isFlag(p.value))) continue const def = defs.get(node.defId) if (!def) continue const scale = node.scale && node.scale > 0 ? node.scale : 1 for (const b of def.collision) { const r = transformColliderBox(b, def.origin, scale, node.rotation ?? 0, !!node.flipX, !!node.flipY) out.push({ x: node.pos.x + r.x, y: node.pos.y + r.y, w: r.w, h: r.h }) } } } return out}
/** Build a navigation grid from a baked map export's collision (object colliders + collision nodes), * preferring the export's painted bounds as the grid extent. The preferred nav source for any map * that carries a baked render; navFromMap (authored areas only) is the fallback for unbaked maps. */export function navFromExport(exp: MapExportSubset, tileWidth: number, opts: NavFromMapOptions = {}): NavGrid { const cellSize = opts.cellSize ?? Math.max(1, Math.round(tileWidth / 3)) const boxes = collisionBoxesFromExport(exp) const bounds = opts.bounds ?? exp.bounds ?? boundsOf(boxes, cellSize * 4) ?? { x: 0, y: 0, w: cellSize, h: cellSize } return buildNav(boxes, { cellSize, bounds, inflate: opts.inflate ?? 0 })}
export interface StationsFromMapOptions { /** slot count assigned to every parsed station. Slots are derived from player count at room * creation (never authored on the map), so the caller passes the balance value here. Default 3. */ slotCount?: number}
/** Auto id for a station with no explicit `town.deception.stationId`, disambiguated per base label. * Resource values are AT-URIs, so the label uses the referenced record's rkey for readability. */function autoStationId(seed: Omit<StationSeed, 'id' | 'pos' | 'area' | 'slotCount'>, autoCount: Record<string, number>): string { const base = seed.collect.length ? `res-${rkeyOf(seed.collect[0]!)}` : seed.deposit.length ? `dep-${rkeyOf(seed.deposit[0]!)}` : seed.scoreboard.length ? 'scoreboard' : 'meeting' const n = (autoCount[base] = (autoCount[base] ?? 0) + 1) return n === 1 ? base : `${base}-${n}`}
/** * Extract station seeds authored on the map. A station is any `areaNode` carrying at least one * capability flag (`town.deception.collect` / `deposit` / `scoreboard` resource AT-URIs, or * `meeting` = "1"). Its footprint is the union of its boxes and its position the footprint centre. * Slot count is the caller's player-count value, not authored. Returns [] when the map authors no * stations. */export function stationsFromMap(map: MapRecord, opts: StationsFromMapOptions = {}): StationSeed[] { const slotCount = opts.slotCount ?? 3 const out: StationSeed[] = [] const autoCount: Record<string, number> = {} const walk = (nodes: MapNode[] | undefined): void => { for (const node of nodes ?? []) { if (node.hidden) continue if (node.boxes && node.boxes.length > 0) { const collect = multiValues(node, COLLECT_PROPERTY) const deposit = multiValues(node, DEPOSIT_PROPERTY) const scoreboard = multiValues(node, SCOREBOARD_PROPERTY) const meeting = isFlag(propOf(node, MEETING_PROPERTY)) if (collect.length || deposit.length || scoreboard.length || meeting) { const area = unionBox(node.boxes) const pos: Vec = { x: area.x + area.w / 2, y: area.y + area.h / 2 } const caps = { collect, deposit, scoreboard, meeting } const id = propOf(node, STATION_ID_PROPERTY) ?? autoStationId(caps, autoCount) const name = propOf(node, STATION_NAME_PROPERTY) out.push({ id, pos, area, slotCount, ...caps, ...(name ? { name } : {}) }) } } if (node.children) walk(node.children) } } walk(map.nodes) return out}
/** * The spawn region authored on the map: the union of all `town.deception.spawn` areas' boxes (map * px), or null when none is authored (callers then fall back to a default spawn). Recurses groups * and skips hidden nodes, like collision. */export function spawnFromMap(map: MapRecord): Rect | null { const boxes: Box[] = [] const walk = (nodes: MapNode[] | undefined): void => { for (const node of nodes ?? []) { if (node.hidden) continue if (Array.isArray(node.boxes) && hasProp(node, SPAWN_PROPERTY, SPAWN_VALUE)) boxes.push(...node.boxes) if (node.children) walk(node.children) } } walk(map.nodes) return boxes.length ? unionBox(boxes) : null}
/** * The distinct decorative-NPC values a map references via `town.deception.npc` markers, in * first-seen order. Recurses groups and skips hidden nodes (a hidden marker places nothing). Pure * presentation metadata — the engine never simulates NPCs. Returns [] when the map authors no NPCs. */export function npcKeysFromMap(map: MapRecord): string[] { const out: string[] = [] const walk = (nodes: MapNode[] | undefined): void => { for (const node of nodes ?? []) { if (node.hidden) continue const key = propOf(node, NPC_PROPERTY)?.trim() if (key && !out.includes(key)) out.push(key) if (node.children) walk(node.children) } } walk(map.nodes) return out}
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v))
/** * A deterministic point inside `area` for the `index`-th spawner, packed into a grid with ~`spacing` * px between players and kept a margin off the edges so footprints don't clip the boundary. Overflow * (more players than cells) is clamped back inside rather than spilling out. */export function spawnPointIn(area: Rect, index: number, spacing = 24): Vec { const margin = Math.min(spacing / 2, area.w / 2, area.h / 2) const cols = Math.max(1, Math.floor((area.w - margin * 2) / spacing) + 1) const col = index % cols const row = Math.floor(index / cols) return { x: clamp(area.x + margin + col * spacing, area.x + margin, area.x + area.w - margin), y: clamp(area.y + margin + row * spacing, area.y + margin, area.y + area.h - margin), }}