// 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 here export const STATION_ID_PROPERTY = 'town.deception.stationId' // optional explicit id export 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, autoCount: Record): 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 = {} 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), } }