// --------------------------------------------------------------------------- // Property readers — render-time interpretation of a node's `properties` bag. // // These are pure functions over `Property[]`; the engine uses them to decide // object animation playback. They are the engine's own self-contained copies of // the reserved-key readers that also live (for the editor) in `lexicon/map.ts` // and `store/editorStore.ts`. The reserved keys are part of the data contract, // so a separate game repo carries this file with the engine. The lexicon JSON // (`lexicons/world.tilemap.map.json`) remains the wire source of truth for the // key names. // --------------------------------------------------------------------------- import type { Property } from './contract' /** An object's animation play mode. `'loop'` cycles 0→n→0; `'pingpong'` plays * forward then backward without repeating the endpoints. */ export type AnimationPlayMode = 'loop' | 'pingpong' /** An object's animation play mode (on a definition's property bag). */ export const OBJECT_ANIMATION_MODE_PROPERTY = 'world.tilemap.animationMode' /** Per-instance animation state, carried on a placed object's property bag so * the object node itself stays a pure transform. */ export const ANIMATION_PLAYING_PROPERTY = 'world.tilemap.animationPlaying' export const ANIMATION_FRAME_PROPERTY = 'world.tilemap.animationFrame' export const ANIMATION_ONCE_PROPERTY = 'world.tilemap.animationOnce' export const ANIMATION_REVERSE_PROPERTY = 'world.tilemap.animationReverse' /** Marks a placed object as a cosmetic auto-door / auto-gate: it animates open * when a player paths through it and closes once they pass (see * `render/gate.ts`). Set on the instance's property bag so the same object def * can be placed as plain decoration elsewhere. Absent = not a gate. A * game-logic concern, so it lives in the `town.deception.*` namespace rather * than the generic `world.tilemap.*` one. */ export const OBJECT_AUTO_GATE_PROPERTY = 'town.deception.autoGate' /** Marks a node (a point marker, or an AREA for a scattered crowd) as a decorative * NPC spawn (PLAN §9): set dressing the client animates per-viewer, never on the * wire. The value is an NPC account **DID** (its repo holds `actor.rpg.sprite/self` * + `town.deception.npc/self`), optionally followed by a whitespace-separated * integer **weight** = exact count, e.g. `did:plc:abc 3`. A node may carry MORE * THAN ONE such property (one per critter) to define a distribution: an area then * spawns `sum(weights)` NPCs in those proportions; a marker spawns a single NPC * (weighted pick). Absent = a plain node. Game-logic-adjacent presentation, so it * lives in the `town.deception.*` namespace like the auto-gate flag rather than * the generic `world.tilemap.*` one. */ export const NPC_TYPE_PROPERTY = 'town.deception.npc' /** Optional per-marker override of an NPC's wander leash radius (px), letting one * placement roam wider/tighter than its account default. Ignored for area crowds * (those leash to the area's bounds). */ export const NPC_WANDER_PROPERTY = 'town.deception.npcWander' /** First value for `key` in a property list, or undefined when absent. */ function getProperty(props: Property[] | undefined, key: string): string | undefined { return props?.find((p) => p.key === key)?.value } /** Every value for `key` in a property list (a node may repeat a key). */ function getAllProperties(props: Property[] | undefined, key: string): string[] { return props?.filter((p) => p.key === key).map((p) => p.value) ?? [] } /** One member of a node's NPC distribution: an account DID and how many to place. */ export interface NpcEntry { did: string /** exact count for an area crowd; the relative weight for a marker's single pick. */ weight: number } /** An object definition's animation play mode: the {@link OBJECT_ANIMATION_MODE_PROPERTY} * value, defaulting to `'loop'` for any unset/unrecognised value. */ export function objectAnimationModeOf(props: Property[] | undefined): AnimationPlayMode { return getProperty(props, OBJECT_ANIMATION_MODE_PROPERTY) === 'pingpong' ? 'pingpong' : 'loop' } /** Whether a placed object plays its animation; stopped only when an explicit * `'0'`/`'false'` flag is present (so absent = playing). */ export function objectAnimationPlayingOf(props: Property[] | undefined): boolean { const v = getProperty(props, ANIMATION_PLAYING_PROPERTY) return v !== '0' && v !== 'false' } /** The frame a stopped object holds: the {@link ANIMATION_FRAME_PROPERTY} value * as a non-negative integer, defaulting to 0 when unset/unparseable. */ export function objectAnimationFrameOf(props: Property[] | undefined): number { const raw = getProperty(props, ANIMATION_FRAME_PROPERTY) if (raw === undefined) return 0 const n = Number(raw) return Number.isFinite(n) ? Math.max(0, Math.round(n)) : 0 } /** Whether a placed object plays its cycle once then holds (vs. loops forever). * True only when an explicit `'1'`/`'true'` flag is present (absent = looping). */ export function objectAnimationOnceOf(props: Property[] | undefined): boolean { const v = getProperty(props, ANIMATION_ONCE_PROPERTY) return v === '1' || v === 'true' } /** Whether a placed object plays its cycle backwards (vs. forwards). True only * when an explicit `'1'`/`'true'` flag is present (absent = forwards). */ export function objectAnimationReverseOf(props: Property[] | undefined): boolean { const v = getProperty(props, ANIMATION_REVERSE_PROPERTY) return v === '1' || v === 'true' } /** Whether a placed object is an auto-door / auto-gate: true only when an * explicit `'1'`/`'true'` {@link OBJECT_AUTO_GATE_PROPERTY} flag is present * (absent = a plain object). */ export function objectAutoGateOf(props: Property[] | undefined): boolean { const v = getProperty(props, OBJECT_AUTO_GATE_PROPERTY) return v === '1' || v === 'true' } /** A node's NPC distribution: each {@link NPC_TYPE_PROPERTY} value parsed into an * account DID + weight (`` or ` `; weight defaults to 1, floored * at 1). Entries with a non-DID first token are dropped. Empty when the node is not * an NPC node. The client resolves each DID to its sprite + behaviour; the render * engine itself never spawns NPCs. */ export function npcEntriesOf(props: Property[] | undefined): NpcEntry[] { const out: NpcEntry[] = [] for (const raw of getAllProperties(props, NPC_TYPE_PROPERTY)) { const [did, weightTok] = raw.trim().split(/\s+/, 2) if (!did || !did.startsWith('did:')) continue const n = weightTok === undefined ? 1 : Number(weightTok) const weight = Number.isFinite(n) && n >= 1 ? Math.round(n) : 1 out.push({ did, weight }) } return out } /** A marker's per-placement wander-radius override (px), or undefined when unset/ * unparseable — in which case the NPC type's catalog default applies. */ export function npcWanderRadiusOf(props: Property[] | undefined): number | undefined { const raw = getProperty(props, NPC_WANDER_PROPERTY) if (raw === undefined) return undefined const n = Number(raw) return Number.isFinite(n) && n >= 0 ? n : undefined }