OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / prefab_catalogue.gd

282 lines · gd ref:
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()