extends RefCounted
class_name PrefabCatalogue
## WHAT MAY BE CREATED — the allow-list behind object.create (hyper-games #28).
##
## Godot's CreateDialog searches all of ClassDB: can_instantiate(t) &&
## !is_virtual(t) && is_parent_class(t, base) (create_dialog.cpp:186-190), then
## ClassDB::instantiate(type_name). That is right for Godot and wrong for us, for
## a reason that has nothing to do with taste.
##
## A CREATE RECORD IS AN INSTRUCTION ANOTHER PEER EXECUTES. Our creates are
## journalled and replayed on every machine in the room, so `type` arrives from
## someone else and gets handed to a loader. Open ClassDB search would make that
## "instantiate any class this build has, named by a peer" — a capability, not a
## convenience. Godot has one user and a local file and correctly does not care.
##
## So the set is EXPLICIT and lives here. An explicit table beats scanning a
## directory for two reasons: it can point at scenes where they already are
## (the teleporter has lived in scenes/teleporter since long before this), and it
## is a list you can read and audit rather than a property of what happens to be
## on disk.
##
## THE ID IS DURABLE. It goes in the journal, so renaming one orphans every
## object created with it — those records will not resolve and will be counted as
## skipped rather than silently dropped. Add ids freely; do not rename them.
##
## Identical on every peer because it ships in the .pck. A peer running an older
## build simply does not know a newer id, which is the same situation as any
## other record it cannot apply — reported, not guessed at.
const PREFABS := {
"light.point": "res://scenes/prefabs/point_light.tscn",
"light.spot": "res://scenes/prefabs/spot_light.tscn",
"marker": "res://scenes/prefabs/marker.tscn",
# The block a builder bot stacks: 10 x 10 x 20 with chamfered edges. One
# ArrayMesh shared by every instance -- see bevelled_block.gd.
"block.bevelled": "res://scenes/prefabs/bevelled_block.tscn",
"teleporter": "res://scenes/teleporter/teleporter.tscn",
# Where players appear (group "spawn_points", read by game.gd). A map's own
# Marker3D spawn points are adopted as this type (map_dc_level._adopt_scene_objects).
"spawn_point": "res://scenes/prefabs/spawn_point.tscn",
# A runtime .glb (ModelCache): its bytes are named by the `asset` prop, not by
# this table — the table only allows the CONTAINER, whose loader checks the file.
"model": "res://scenes/prefabs/model.tscn",
# A BOT (bot MVP, 2026-10-04): a body + an allow-listed brain (BrainCatalogue).
# Its properties are DYNAMIC — the chosen brain's own PARAMS join the base ones.
"bot": "res://scenes/prefabs/bot.tscn",
# A SPAWNER (creatures → bots, #114 phase 4): keeps roaming creature bots near the
# players, run by the room authority. What a room adds for ambient creatures.
"spawner": "res://scenes/prefabs/spawner.tscn",
}
## The bot's base properties; the brain's PARAMS are appended as "p.<id>"
## (bot_props). "to": "bot" = applied through the bot object's own set_bot_prop.
const BOT_BODIES := ["marker", "crawler", "serpent", "hopper"]
const BOT_BASE := [
# Default ROAM (user 2026-10-13): a placed creature wanders around home, as the old
# ambient creatures did. Needs no navmesh, unlike pathwalker.
{"id": "brain", "label": "Brain", "kind": "enum", "to": "bot", "default": "roam",
"options": [], "option_labels": []}, # filled from BrainCatalogue (bot_props)
{"id": "body", "label": "Body", "kind": "enum", "to": "bot", "default": "crawler",
"options": BOT_BODIES, "option_labels": ["Marker", "Crawler", "Serpent", "Hopper"]},
{"id": "name", "label": "Name", "kind": "string", "to": "bot", "default": "", "max_len": 32},
]
## The bot's specs for its CURRENT brain (`current` = its effective props so far).
static func bot_props(current: Dictionary = {}) -> Array:
var out: Array = []
for s: Dictionary in BOT_BASE:
var d := s.duplicate(true)
if d["id"] == "brain":
d["options"] = BrainCatalogue.ids()
var labels: Array = []
for b: String in BrainCatalogue.ids():
labels.append(BrainCatalogue.label_of(b))
d["option_labels"] = labels
out.append(d)
var brain := String(current.get("brain", BOT_BASE[0]["default"]))
for p: Variant in BrainCatalogue.params_of(brain):
var d2 := (p as Dictionary).duplicate(true)
d2["param"] = String(d2["id"])
d2["id"] = "p." + String(d2["id"])
d2["to"] = "bot"
out.append(d2)
return out
## WHAT MAY BE SET — the allow-list behind object.set_prop (#41 §7 item 5).
##
## Same argument as PREFABS, applied twice: a set_prop record names a PROPERTY and
## carries a VALUE, and both arrive from another peer. Handing either to `set()`
## would be "write any property of any node this build has" — `script` included.
## So each prefab lists what may be set, and nothing else is ever applied.
##
## Each spec:
## id durable, goes in the journal — add freely, never rename (same rule as ids)
## label what the Object panel and History call it
## kind float | color | bool | vec3 | string | hash (64 hex) | enum (one of
## `options`) — the value's shape on the wire
## panel false = not offered in the Object panel (set by a tool, e.g. a model's asset)
## to "prop": node.set_indexed(path, value); "tint": a per-colour material on
## the MeshInstance3D at `mesh` (shared by every object of that colour, so
## a stack of same-coloured blocks still batches)
## default the value an object has before any record touches it. It MUST equal the
## prefab's own value: the fold resets to it on every pass, so a mismatch
## would change every existing object the first time it folded. Gated
## (scene_objects_gate "default matches the prefab").
## min/max floats are CLAMPED to this; a peer's value is not trusted
## max_len strings are cut to this
##
## Values on the wire are plain JSON (LevelPersistence: VALUES ARE JSON-SAFE): a
## colour or a vec3 is an array of 3 floats.
const PROPS := {
"light.point": [
{"id": "color", "label": "Colour", "kind": "color", "to": "prop", "path": "light_color",
"default": [1.0, 0.92, 0.78]},
{"id": "energy", "label": "Brightness", "kind": "float", "to": "prop", "path": "light_energy",
"default": 2.0, "min": 0.0, "max": 16.0, "step": 0.1},
{"id": "range", "label": "Range", "kind": "float", "to": "prop", "path": "omni_range",
"default": 8.0, "min": 0.5, "max": 128.0, "step": 0.5, "unit": "m"},
{"id": "shadows", "label": "Shadows", "kind": "bool", "to": "prop", "path": "shadow_enabled",
"default": true},
],
"light.spot": [
{"id": "color", "label": "Colour", "kind": "color", "to": "prop", "path": "light_color",
"default": [1.0, 0.95, 0.85]},
{"id": "energy", "label": "Brightness", "kind": "float", "to": "prop", "path": "light_energy",
"default": 3.0, "min": 0.0, "max": 16.0, "step": 0.1},
{"id": "range", "label": "Range", "kind": "float", "to": "prop", "path": "spot_range",
"default": 12.0, "min": 0.5, "max": 128.0, "step": 0.5, "unit": "m"},
{"id": "shadows", "label": "Shadows", "kind": "bool", "to": "prop", "path": "shadow_enabled",
"default": true},
{"id": "angle", "label": "Cone angle", "kind": "float", "to": "prop", "path": "spot_angle",
"default": 35.0, "min": 1.0, "max": 89.0, "step": 1.0, "unit": "°"},
],
"block.bevelled": [
{"id": "tint", "label": "Colour", "kind": "color", "to": "tint", "mesh": "Mesh",
"default": [0.72, 0.68, 0.60]},
],
"marker": [
{"id": "tint", "label": "Colour", "kind": "color", "to": "tint", "mesh": ".",
"default": [1.0, 0.7, 0.2]},
],
# target_map is deliberately ABSENT: it is a scene path, and a peer setting it
# would aim a teleporter (and everyone who walks into it) at any scene in the pck.
"teleporter": [
{"id": "label", "label": "Label", "kind": "string", "to": "prop", "path": "label_text",
"default": "", "max_len": 64},
{"id": "destination", "label": "Destination", "kind": "vec3", "to": "prop", "path": "destination",
"default": [0.0, 0.0, 0.0], "min": -100000.0, "max": 100000.0, "unit": "m"},
],
# A runtime model. `asset` is the .glb's CONTENT HASH (exact bytes — user,
# 2026-10-04), set at import and not offered in the panel ("panel": false): a hash
# is not something to type. Scale is uniform and baked into collision points.
# Defaults = spawner_object.gd's own values (the fold resets to them).
"spawner": [
{"id": "crawlers", "label": "Crawlers", "kind": "int", "to": "prop", "path": "crawlers",
"default": 6, "min": 0, "max": 64},
{"id": "hoppers", "label": "Hoppers", "kind": "int", "to": "prop", "path": "hoppers",
"default": 4, "min": 0, "max": 64},
{"id": "serpents", "label": "Serpents", "kind": "int", "to": "prop", "path": "serpents",
"default": 2, "min": 0, "max": 64},
{"id": "radius", "label": "Spawn distance", "kind": "float", "to": "prop", "path": "spawn_radius",
"default": 40.0, "min": 5.0, "max": 500.0, "step": 1.0, "unit": "m"},
{"id": "despawn", "label": "Remove beyond", "kind": "float", "to": "prop", "path": "despawn_radius",
"default": 80.0, "min": 10.0, "max": 1000.0, "step": 1.0, "unit": "m"},
{"id": "interval", "label": "Seconds per spawn", "kind": "float", "to": "prop", "path": "interval",
"default": 2.0, "min": 0.2, "max": 60.0, "step": 0.1},
],
"model": [
{"id": "asset", "label": "Asset", "kind": "hash", "to": "prop", "path": "asset",
"default": "", "panel": false},
{"id": "name", "label": "Name", "kind": "string", "to": "prop", "path": "model_name",
"default": "", "max_len": 64},
{"id": "collision", "label": "Collision", "kind": "enum", "to": "prop", "path": "collision",
"default": "auto", "options": ["auto", "convex", "trimesh", "decompose", "named", "none"],
"option_labels": ["Automatic", "Single hull", "Exact mesh", "Decomposed", "Named -col meshes", "None"]},
{"id": "scale", "label": "Scale", "kind": "float", "to": "prop", "path": "model_scale",
# "max" is the SLIDER's range only: open_max keeps a larger value (#109,
# "scale ... should be unbounded"; gate_unbounded_scale).
"default": 1.0, "min": 0.01, "max": 100.0, "open_max": true, "step": 0.01, "exp": true},
],
}
## The settable properties of a prefab, in display order ([] when it has none).
## `current` matters only for prefabs whose specs depend on their own values (the
## bot: its brain decides which settings exist).
static func props_of(id: String, current: Dictionary = {}) -> Array:
if id == "bot":
return bot_props(current)
return PROPS.get(id, [])
## One property's spec, or {} when that prefab does not expose it.
static func prop_spec(id: String, prop: String, current: Dictionary = {}) -> Dictionary:
for s: Dictionary in props_of(id, current):
if String(s["id"]) == prop:
return s
return {}
## A wire value made safe for `spec`, or null when it is the wrong shape. Clamps
## numbers, cuts strings — a peer's value is applied only after this.
static func sanitize(spec: Dictionary, value: Variant) -> Variant:
match String(spec.get("kind", "")):
"float":
if not (value is float or value is int):
return null
var f := float(value)
if is_nan(f):
return null
return clampf(f, float(spec.get("min", -INF)),
INF if bool(spec.get("open_max", false)) else float(spec.get("max", INF)))
"int":
if not (value is float or value is int) or is_nan(float(value)):
return null
return clampi(roundi(float(value)), int(spec.get("min", -2147483648)), int(spec.get("max", 2147483647)))
"bool":
return bool(value) if value is bool else null
"color", "vec3":
if not (value is Array) or (value as Array).size() != 3:
return null
var lo := 0.0 if spec["kind"] == "color" else float(spec.get("min", -INF))
var hi := 1.0 if spec["kind"] == "color" else float(spec.get("max", INF))
var out: Array = []
for x: Variant in value:
if not (x is float or x is int) or is_nan(float(x)):
return null
out.append(clampf(float(x), lo, hi))
return out
"hash":
# 64 lower-case hex, nothing else: it becomes a file name under user://content.
if not (value is String) or String(value).length() != 64:
return null
for c in String(value):
if not ((c >= "0" and c <= "9") or (c >= "a" and c <= "f")):
return null
return value
"enum":
return value if value is String and (spec.get("options", []) as Array).has(value) else null
"string":
if not (value is String):
return null
# Control characters out (a label is one line on a signpost), then the cap.
var s := ""
for ch in String(value):
if ch.unicode_at(0) >= 32:
s += ch
return s.substr(0, int(spec.get("max_len", 64)))
return null
static func ids() -> Array[String]:
var out: Array[String] = []
for k: String in PREFABS:
out.append(k)
out.sort() # stable order, so the picker does not shuffle
return out
static func has(id: String) -> bool:
return PREFABS.has(id)
static func path_of(id: String) -> String:
return String(PREFABS.get(id, ""))
## Instantiate, or null. NEVER loads a path that is not in the table — that check
## is the whole point of the table, so it is here rather than at the call site
## where one caller can forget it.
static func instantiate(id: String) -> Node:
if not has(id):
push_warning("[PrefabCatalogue] unknown prefab id '%s' — refusing to load" % id)
return null
var packed: PackedScene = load(path_of(id)) as PackedScene
if packed == null:
push_warning("[PrefabCatalogue] '%s' does not load (%s)" % [id, path_of(id)])
return null
return packed.instantiate()