extends Node3D
class_name SceneObjects
## AUTHORED SCENE OBJECTS — hyper-games #28, the durable half.
##
## Brushes have been journalled since the level-mesh pivot; scene objects never
## were. Every light, teleporter and door in a map comes from the .MAP IMPORT, so
## the editor could move the world but not populate it — there was no way to say
## "put a light here" and have it still be there tomorrow, let alone on someone
## else's machine.
##
## ---------------------------------------------------------------------------
## WHAT GODOT DOES, AND THE ONE LINE THAT DOES NOT TRANSLATE
##
## SceneTreeDock::_do_create (scene_tree_dock.cpp:3048) is four lines:
##
## String new_name = p_parent->validate_child_name(child);
## undo_redo->add_do_method(p_parent, "add_child", child, true);
## undo_redo->add_do_method(child, "set_owner", edited_scene);
## undo_redo->add_undo_method(p_parent, "remove_child", child);
##
## `set_owner` IS the persistence. Godot saves by walking the live tree into a
## PackedScene, and a node with no owner is invisible to that walk — which is why
## a node added at runtime vanishes and one added in the editor does not.
##
## We cannot do that. There is no scene file to write: the durable state is an
## append-only journal replayed on every peer (LevelPersistence -> GameState ->
## the room's StateBase). So the analogue of `set_owner` is THE CREATE IS
## JOURNALLED, and the analogue of PackedScene is this fold. Nothing here saves;
## it rebuilds.
##
## Which makes it the same shape as brushes, deliberately:
##
## IDENTITY the create's CRDT dot names the object — exactly DCBrush::key,
## and exactly what #28 §4 says objects were missing. An id or a
## node path would name something different on another peer.
## UNDO the record's `undone` flag, not an inverse op. Same as the brush
## journal: there is no durable delete path, so nothing is erased.
## REPLAY is NOT once-only. state_changed fires on bulk load AND on every
## live update, including your own writes coming back confirmed, so
## a create that was not idempotent would grow an object per round
## trip. Keyed by dot, applying twice is a no-op.
##
## ---------------------------------------------------------------------------
## SCOPE: object.create only, on purpose.
##
## Undo already works without object.remove, because undo is the journal flag.
## object.transform and object.set_prop are the obvious next two and are NOT here
## — they are part of the record-kind set #22 §5 item 5 wants decided together,
## and this one goes first to set the pattern rather than to pre-empt them.
## Consequently a created object is placed where you put it and does not move.
const KIND_CREATE := "object.create"
const KIND_TRANSFORM := "object.transform"
const KIND_SET_PROP := "object.set_prop"
## DELETE AS A RECORD (like brush.remove): a live remove takes its target out of the
## fold; undoing the remove brings the object back with everything it had. Nothing is
## erased — the journal has no durable delete, and does not need one.
const KIND_REMOVE := "object.remove"
## REPARENTING (demo sprint §3, user 2026-10-04): {target, parent ("" = top level), xform}.
## `xform` is the child's transform IN THE NEW PARENT'S SPACE, computed by the writer so
## nothing jumps. After it, the child's object.transform records are parent-local too, so
## the fold stays "the last record wins" and an undo falls back to an earlier parent AND
## the transform written in that parent's space. A cycle, a missing parent or a BOT as a
## child (its brain moves its body in world space) is refused. Removing a parent removes
## its children (Godot's rule); one undo brings them all back.
const KIND_PARENT := "object.parent"
## Prefabs that may not be a child (they may still be a parent).
const NEVER_CHILD := ["bot"]
## dot -> the node built from that record. The registry is what makes replay
## idempotent and what a future object.transform would resolve against.
var _by_dot: Dictionary = {}
var _last: Dictionary = {}
## dot -> {prop id: sanitized wire value} from the LAST fold — only what records
## set, not the defaults. Rebuilt every fold, like _by_dot's membership.
var _props: Dictionary = {}
## dot -> parent dot ("" = top level) from the LAST fold.
var _parent_of: Dictionary = {}
## BASE OBJECTS (2026-10-05): a level map's own teleporters and spawn points, ADOPTED
## rather than created — dot "scene:<Name>" -> {type, xform, props, node}. They exist
## without an object.create, are reset to their AUTHORED transform and props on every
## fold (the brush store's base layer, for objects), and object.transform / set_prop /
## remove / parent target them like any object. A removed one is DETACHED, never freed,
## so undoing the remove puts the SAME node back.
var _base: Dictionary = {}
## Adopt a scene node as a BASE object. It moves under this node keeping its place; its
## current transform and its catalogue props, read FROM THE NODE, become its base — so a
## teleporter keeps the label its .tscn gave it, not the catalogue's default.
## Call before the first fold (map_dc_level._adopt_scene_objects).
func add_base(dot: String, type_id: String, node: Node3D) -> void:
var g := node.global_transform
if node.get_parent() != self:
if node.get_parent() != null:
node.get_parent().remove_child(node)
add_child(node)
node.global_transform = g
node.set_meta("object_dot", dot)
node.set_meta("object_type", type_id)
var bp := {}
for s: Dictionary in PrefabCatalogue.props_of(type_id, {}):
var v: Variant = read_prop(node, s)
if v != null:
bp[String(s["id"])] = v
_base[dot] = {"type": type_id, "xform": node.transform, "props": bp, "node": node}
_by_dot[dot] = node
func is_base(dot: String) -> bool:
return _base.has(dot)
## Is this a record we own? LevelPersistence asks, so brush records and object
## records can share one journal without either consumer seeing the other's and
## reporting it as a disagreement.
static func handles(kind: String) -> bool:
return kind.begins_with("object.")
## Every live object, as {dot, type, node} — what the outliner lists. Store order
## is insertion order, which is fold order, which is (seq, author): stable, and
## the same on every peer.
func entries() -> Array:
var out: Array = []
for dot: String in _by_dot:
var n: Node = _by_dot[dot]
if n == null or not is_instance_valid(n):
continue
out.append({"dot": dot, "type": String(n.get_meta("object_type", "")), "node": n,
"parent": String(_parent_of.get(dot, ""))})
return out
## The parent's dot, or "" for a top-level object.
func parent_of(dot: String) -> String:
return String(_parent_of.get(dot, ""))
## A node's transform in SCENE space (relative to this SceneObjects node) — what the
## tools work in. For a top-level object it IS its transform.
func scene_xform(node: Node3D) -> Transform3D:
var t := node.transform
var p := node.get_parent()
while p != null and p != self and p is Node3D:
t = (p as Node3D).transform * t
p = p.get_parent()
return t
## The transform a child of `parent_dot` needs for scene-space `t` ("" = top level).
func local_for(parent_dot: String, t: Transform3D) -> Transform3D:
var pn := node_for(parent_dot) as Node3D if parent_dot != "" else null
return t if pn == null else scene_xform(pn).affine_inverse() * t
## True if `anc` is `dot` or one of its ancestors (by the last fold).
func is_ancestor(anc: String, dot: String) -> bool:
var d := dot
var guard := 0
while d != "" and guard < 4096:
if d == anc:
return true
d = String(_parent_of.get(d, ""))
guard += 1
return false
## Rebuild from the journal. Same contract as DCLevel.apply_journal: hand it every
## live record, get back {applied, skipped, undone, records}.
##
## REBUILT, NOT PATCHED. A fold is the whole state, so an object whose record has
## gone (undone, or authored against another level) must disappear — and working
## out which ones those are by diffing is how the tree and the journal drift.
## Cheap because authored objects are tens, not thousands.
func apply_journal(records: Array) -> Dictionary:
# rejected_props: property writes refused by the catalogue (not exposed by that
# prefab, or the wrong shape). Counted apart from `skipped`, which is per RECORD:
# a record with one bad prop and one good one still applies the good one.
var stats := {"applied": 0, "skipped": 0, "undone": 0, "records": 0, "rejected_props": 0}
var wanted: Dictionary = {}
var ordered: Array = []
# dot -> {prop: sanitized value}. Filled in (seq, author) order, so a later write
# to the same prop simply overwrites — per-PROPERTY last-write-wins.
var set_props: Dictionary = {}
# Dots removed so far in (seq, author) order. A record after the remove that names
# one has nothing to act on and is counted skipped, like a move of a missing object.
var removed: Dictionary = {}
# dot -> parent dot, in (seq, author) order: a create starts at the top level.
var parents: Dictionary = {}
# BASE OBJECTS are wanted without a create, back in the tree if a remove had
# detached them, at their authored transform — the records below then move them.
for bdot: String in _base:
var b: Dictionary = _base[bdot]
var bn := b["node"] as Node3D
if bn == null or not is_instance_valid(bn):
continue
if not bn.is_inside_tree() and bn.get_parent() == null:
add_child(bn)
bn.transform = b["xform"]
_by_dot[bdot] = bn
wanted[bdot] = true
parents[bdot] = ""
for r: Dictionary in records:
if not handles(String(r.get("kind", ""))):
continue
stats["records"] = int(stats["records"]) + 1
if bool(r.get("undone", false)):
stats["undone"] = int(stats["undone"]) + 1
continue
ordered.append(r)
# (seq, author) — the same total order the brush fold uses. Arrival order in a
# last-write-wins view means nothing.
ordered.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if int(a.get("seq", 0)) != int(b.get("seq", 0)):
return int(a.get("seq", 0)) < int(b.get("seq", 0))
return String(a.get("author", "")) < String(b.get("author", "")))
for r: Dictionary in ordered:
# The dot is (author, seq) — built here rather than carried, so it is the
# same construction dc_journal::Record::dot() uses and the two cannot
# drift into naming the same record differently.
var dot := "%s:%d" % [String(r.get("author", "")), int(r.get("seq", -1))]
if String(r.get("author", "")) == "":
stats["skipped"] = int(stats["skipped"]) + 1
continue
match String(r.get("kind", "")):
KIND_CREATE:
wanted[dot] = true
parents[dot] = ""
var node: Node = _by_dot.get(dot, null)
if node != null and is_instance_valid(node):
# ALREADY BUILT — replay is not once-only, so the node stays and
# only its transform is re-applied. That re-application is not
# redundant: it is what puts an object BACK when the
# object.transform that moved it has just been undone. Without
# it the create is skipped, no transform record applies, and the
# node silently keeps the position the undone record gave it.
_place(node, r)
else:
node = _spawn(r, dot)
if node == null:
# NOT silent. A record naming a prefab this build does not
# have is the version-skew case, and it is information: the
# object is missing here and present elsewhere.
stats["skipped"] = int(stats["skipped"]) + 1
continue
_by_dot[dot] = node
stats["applied"] = int(stats["applied"]) + 1
KIND_TRANSFORM:
# ABSOLUTE, not a delta. The fold replays the whole journal every
# time, so a relative move would compound on each re-fold — the
# same reason DCLevel rewinds to base planes rather than
# accumulating. Absolute means the LAST transform in (seq, author)
# order simply wins, and undoing one falls back to the one before
# it with no inverse to compute.
var target := String(r.get("target", ""))
var n: Node = _by_dot.get(target, null)
if n == null or not is_instance_valid(n) or removed.has(target):
# The target is gone or not yet built — an undone create, or a
# prefab this build lacks. Counted, because a move with nothing
# to move means the journal and this peer disagree.
stats["skipped"] = int(stats["skipped"]) + 1
continue
_place(n, r)
stats["applied"] = int(stats["applied"]) + 1
KIND_SET_PROP:
var tgt := String(r.get("target", ""))
var on: Node = _by_dot.get(tgt, null)
var raw: Variant = r.get("props", {})
if on == null or not is_instance_valid(on) or not (raw is Dictionary) or removed.has(tgt):
stats["skipped"] = int(stats["skipped"]) + 1
continue
var type_id := String(on.get_meta("object_type", ""))
var took := 0
# "brain" FIRST: a bot's other settings are specced BY its brain, so a
# record that changes both must resolve the brain before them.
var keys: Array = (raw as Dictionary).keys()
if keys.has("brain"):
keys.erase("brain")
keys.push_front("brain")
for k: Variant in keys:
# THE ALLOW-LIST. The name and the value both came from a peer;
# anything the catalogue does not list for THIS prefab is refused,
# and a value of the wrong shape is refused rather than coerced.
var spec: Dictionary = PrefabCatalogue.prop_spec(type_id, String(k), set_props.get(tgt, {}))
var clean: Variant = PrefabCatalogue.sanitize(spec, raw[k]) if not spec.is_empty() else null
if clean == null:
stats["rejected_props"] = int(stats["rejected_props"]) + 1
continue
if not set_props.has(tgt):
set_props[tgt] = {}
(set_props[tgt] as Dictionary)[String(k)] = clean
took += 1
if took == 0:
stats["skipped"] = int(stats["skipped"]) + 1
else:
stats["applied"] = int(stats["applied"]) + 1
KIND_REMOVE:
var rt := String(r.get("target", ""))
if not wanted.has(rt) or removed.has(rt):
stats["skipped"] = int(stats["skipped"]) + 1
continue
# ITS CHILDREN GO WITH IT (Godot's rule), by the parents as of this record.
for d: String in wanted.keys():
if _chain_has(parents, d, rt):
removed[d] = true
wanted.erase(d) # freed by the "no longer wanted" sweep below
stats["applied"] = int(stats["applied"]) + 1
KIND_PARENT:
var ct := String(r.get("target", ""))
var np := String(r.get("parent", ""))
var cn: Node = _by_dot.get(ct, null)
var ok_parent := np == "" or (wanted.has(np) and not removed.has(np))
if cn == null or not is_instance_valid(cn) or not wanted.has(ct) or not ok_parent \
or (np != "" and NEVER_CHILD.has(String(cn.get_meta("object_type", "")))) \
or (np != "" and _chain_has(parents, np, ct)):
# Missing / removed either end, a bot as a child, or a CYCLE (the new
# parent is the target or below it) — refused, the tree stays a tree.
stats["skipped"] = int(stats["skipped"]) + 1
continue
parents[ct] = np
_place(cn, r) # parent-local, like every later transform
stats["applied"] = int(stats["applied"]) + 1
_:
stats["skipped"] = int(stats["skipped"]) + 1
# THE HIERARCHY, BEFORE the sweep: a wanted child of a node about to be freed (its
# parent's create was undone) must be out from under it first. Only nodes whose parent
# CHANGED move — a bot leaving the tree detaches its brain. Two passes (out to the top
# level, then in) so a swap of who-parents-whom never asks Godot for a cycle.
_parent_of = {}
for dot: String in wanted:
var pd := String(parents.get(dot, ""))
_parent_of[dot] = pd if wanted.has(pd) else ""
var moving: Array = []
for dot: String in wanted:
var n: Node = _by_dot.get(dot, null)
if n == null or not is_instance_valid(n):
continue
var want_p: Node = self if String(_parent_of[dot]) == "" else _by_dot.get(_parent_of[dot], null)
if want_p == null or not is_instance_valid(want_p):
want_p = self
_parent_of[dot] = ""
if n.get_parent() != want_p:
moving.append([n, want_p])
for m: Array in moving:
if (m[0] as Node).get_parent() != self:
(m[0] as Node).reparent(self, false)
for m: Array in moving:
if m[1] != self:
(m[0] as Node).reparent(m[1] as Node, false)
# A REMOVED BASE object is DETACHED, not freed — FIRST, before anything is freed,
# so a base node parented under a journal object that is going away is not freed
# with it. Detached = out of the tree: a teleporter stops triggering, a spawn point
# leaves the spawn_points group. The next fold without the remove re-attaches it.
for dot: String in _by_dot.keys():
if wanted.has(dot) or not _base.has(dot):
continue
var bn: Node = _by_dot[dot]
if bn != null and is_instance_valid(bn) and bn.get_parent() != null:
bn.get_parent().remove_child(bn)
_by_dot.erase(dot)
# Anything else no longer wanted was undone or belongs to another level.
for dot: String in _by_dot.keys():
if wanted.has(dot):
continue
var n: Node = _by_dot[dot]
if n != null and is_instance_valid(n):
n.queue_free()
_by_dot.erase(dot)
# PROPERTIES, last: every object gets its prefab DEFAULT for each property, then
# whatever the journal set. Resetting on every fold is the props version of
# _place re-applying the create's transform — it is what makes an undone
# set_prop fall back to the write before it, or to the default, with no inverse.
_props = set_props
for dot: String in _by_dot:
var n: Node = _by_dot[dot]
if n == null or not is_instance_valid(n):
continue
var mine: Dictionary = set_props.get(dot, {})
# A base object falls back to its AUTHORED value, not the catalogue default.
var based: Dictionary = (_base.get(dot, {}) as Dictionary).get("props", {})
for s: Dictionary in PrefabCatalogue.props_of(String(n.get_meta("object_type", "")), mine):
var sid := String(s["id"])
apply_prop(n, s, mine.get(sid, based.get(sid, s["default"])))
# A bot gathers its settings and (re)starts once per fold, not once per prop.
if n.has_method("props_applied"):
n.props_applied()
_last = stats
return stats
## True if walking up from `dot` through `parents` reaches `anc` (dot itself counts).
static func _chain_has(parents: Dictionary, dot: String, anc: String) -> bool:
var d := dot
var guard := 0
while d != "" and guard < 4096:
if d == anc:
return true
d = String(parents.get(d, ""))
guard += 1
return false
func _spawn(r: Dictionary, dot: String) -> Node:
var type_id := String(r.get("type", ""))
var node: Node = PrefabCatalogue.instantiate(type_id)
if node == null:
return null
# The name is cosmetic and MUST NOT be identity — Godot's validate_child_name
# makes it unique per parent, which is exactly the kind of local, ordering
# dependent value that names different things on different peers. The dot is
# the identity; the name just has to be readable.
node.name = "%s_%s" % [type_id.replace(".", "_"), dot.replace(":", "_")]
node.set_meta("object_dot", dot)
node.set_meta("object_type", type_id)
add_child(node)
_place(node, r)
return node
## Put a node where a record says. Shared by create and transform so the two
## cannot disagree about what `xform` means.
static func _place(node: Node, r: Dictionary) -> void:
if node is Node3D and r.has("xform"):
(node as Node3D).transform = r["xform"] as Transform3D
## The dot that names this node, or "" — the durable handle anything outliving a
## single call must hold.
static func dot_of(node: Node) -> String:
return String(node.get_meta("object_dot", "")) if node != null else ""
# ---- properties (object.set_prop) -------------------------------------------
#
# Values travel in WIRE FORM — float, bool, String, or a 3-float array for a colour
# or a vec3 — and only become engine types at the node. The catalogue's spec says
# which, so there is one conversion each way and the panel, the fold and the gate
# all compare the same shape.
## Materials per (base material, colour). Every block of one colour shares ONE
## material, so a tinted stack still batches — the same argument as the block's
## shared mesh. Static: it outlives any one fold or map.
static var _tint_cache: Dictionary = {}
## A node's CURRENT value for `spec`, in wire form.
static func read_prop(node: Node, spec: Dictionary) -> Variant:
if String(spec.get("to", "")) == "bot":
return node.get_bot_prop(String(spec["id"])) if node.has_method("get_bot_prop") else null
if String(spec.get("to", "")) == "tint":
var mi := node.get_node_or_null(NodePath(String(spec["mesh"]))) as MeshInstance3D
var m := mi.material_override as StandardMaterial3D if mi != null else null
if m == null:
return null
return [m.albedo_color.r, m.albedo_color.g, m.albedo_color.b]
var v: Variant = node.get_indexed(NodePath(String(spec["path"])))
if v is Color:
return [(v as Color).r, (v as Color).g, (v as Color).b]
if v is Vector3:
return [(v as Vector3).x, (v as Vector3).y, (v as Vector3).z]
return v
## Wire values equal, floats approximately (a colour read back from a Color is
## float32, the spec's is float64).
static func same_value(a: Variant, b: Variant) -> bool:
if a is Array and b is Array:
if (a as Array).size() != (b as Array).size():
return false
for i in (a as Array).size():
if not same_value(a[i], b[i]):
return false
return true
if (a is float or a is int) and (b is float or b is int):
return is_equal_approx(float(a), float(b))
return typeof(a) == typeof(b) and a == b
## Put a SANITIZED wire value on the node.
static func apply_prop(node: Node, spec: Dictionary, value: Variant) -> void:
var kind := String(spec.get("kind", ""))
if String(spec.get("to", "")) == "bot":
if node.has_method("set_bot_prop"):
node.set_bot_prop(String(spec["id"]), value)
return
if String(spec.get("to", "")) == "tint":
var mi := node.get_node_or_null(NodePath(String(spec["mesh"]))) as MeshInstance3D
if mi == null:
return
# The prefab's own material is remembered the first time, so going back to
# the default restores THAT instance (the block's shared one) rather than a
# look-alike copy.
if not mi.has_meta("tint_base"):
mi.set_meta("tint_base", mi.material_override)
var base := mi.get_meta("tint_base") as StandardMaterial3D
if base == null:
return
if same_value(value, spec["default"]):
mi.material_override = base
return
var c := Color(float(value[0]), float(value[1]), float(value[2]))
var key := "%d:%s" % [base.get_instance_id(), c.to_html(false)]
var m: StandardMaterial3D = _tint_cache.get(key, null)
if m == null:
m = base.duplicate() as StandardMaterial3D
m.albedo_color = c
_tint_cache[key] = m
mi.material_override = m
return
var v: Variant = value
if kind == "color":
v = Color(float(value[0]), float(value[1]), float(value[2]))
elif kind == "vec3":
v = Vector3(float(value[0]), float(value[1]), float(value[2]))
node.set_indexed(NodePath(String(spec["path"])), v)
## The effective properties of an object, {id: wire value}: the prefab defaults
## overlaid with what the journal set. What the Object panel shows.
func props(dot: String) -> Dictionary:
var n := node_for(dot)
if n == null:
return {}
var out: Dictionary = {}
var set_here: Dictionary = _props.get(dot, {})
var based: Dictionary = (_base.get(dot, {}) as Dictionary).get("props", {})
for s: Dictionary in PrefabCatalogue.props_of(String(n.get_meta("object_type", "")), set_here):
out[String(s["id"])] = based.get(String(s["id"]), s["default"])
for k: String in set_here:
out[k] = set_here[k]
return out
# ---- picking ---------------------------------------------------------------
## An object's WORLD bounds: every VisualInstance3D under it, merged. Lights and
## markers have no mesh worth the name (an OmniLight3D's AABB is its whole range), so
## a light — or anything with no visual — is a small box at its origin, the same
## stand-in MoveOperator outlines.
static func bounds_of(node: Node3D) -> AABB:
if not (node is Light3D):
var box := AABB()
var any := false
for vi in node.find_children("*", "VisualInstance3D", true, false) + ([node] if node is VisualInstance3D else []):
if vi is Light3D or not (vi as Node3D).is_visible_in_tree():
continue
# A CHILD OBJECT is its own pick target: a parent's box that swallowed its
# children would make every child unclickable (the nearer box wins).
if _under_child_object(vi as Node, node):
continue
var a: AABB = (vi as Node3D).global_transform * (vi as VisualInstance3D).get_aabb()
if a.size.length() < 1e-4:
continue
box = a if not any else box.merge(a)
any = true
if any:
return box
return AABB(node.global_position - Vector3.ONE * 0.35, Vector3.ONE * 0.7)
## True if `n` sits inside another object node below `root` (or is one).
static func _under_child_object(n: Node, root: Node) -> bool:
var p := n
while p != null and p != root:
if p.has_meta("object_dot"):
return true
p = p.get_parent()
return false
## The nearest object whose bounds the ray enters within `max_dist`:
## {dot, dist, point}, or {} when it misses them all. Tens of objects, so a linear
## scan; no physics, because lights and markers have no collider to hit.
func pick(from: Vector3, dir: Vector3, max_dist: float = 50.0, only_type: String = "") -> Dictionary:
var best := {}
var d := dir.normalized()
for dot: String in _by_dot:
var n := _by_dot[dot] as Node3D
if n == null or not is_instance_valid(n) or not n.is_visible_in_tree():
continue
if only_type != "" and String(n.get_meta("object_type", "")) != only_type:
continue
var t := ray_aabb(from, d, bounds_of(n))
if t >= 0.0 and t <= max_dist and (best.is_empty() or t < float(best["dist"])):
best = {"dot": dot, "dist": t, "point": from + d * t}
return best
## Slab test: distance along a unit ray to where it enters `box` (0 when it starts
## inside), or -1 when it misses.
static func ray_aabb(from: Vector3, d: Vector3, box: AABB) -> float:
var tmin := -INF
var tmax := INF
for i in 3:
if absf(d[i]) < 1e-9:
if from[i] < box.position[i] or from[i] > box.end[i]:
return -1.0
continue
var t1 := (box.position[i] - from[i]) / d[i]
var t2 := (box.end[i] - from[i]) / d[i]
tmin = maxf(tmin, minf(t1, t2))
tmax = minf(tmax, maxf(t1, t2))
if tmax < maxf(tmin, 0.0):
return -1.0
return maxf(tmin, 0.0)
func node_for(dot: String) -> Node:
var n: Variant = _by_dot.get(dot, null)
return n as Node if n != null and is_instance_valid(n) else null
func count() -> int:
return _by_dot.size()
func last_stats() -> Dictionary:
return _last
# ---- the durable transform encoding ----------------------------------------
#
# 12 FLOATS, NOT A Transform3D. Every other value in this journal is a plain
# number (`pos`, `half`, `n`), and an engine type in a durable record is one more
# thing that has to keep surviving var_to_str across builds. It also lives HERE
# rather than in LevelPersistence because this is what consumes it — and because
# LevelPersistence reaches for the GameState autoload, which makes it
# unreachable from a headless script gate. A codec nothing can test is a codec
# nobody checks.
## 12 floats -> Transform3D. Anything malformed becomes the IDENTITY rather than
## a partial basis: half a decoded transform puts an object somewhere nobody
## chose, which is worse than putting it at the origin where it is obvious.
static func xform_from(a: Array) -> Transform3D:
if a.size() != 12:
return Transform3D.IDENTITY
return Transform3D(
Basis(Vector3(float(a[0]), float(a[1]), float(a[2])),
Vector3(float(a[3]), float(a[4]), float(a[5])),
Vector3(float(a[6]), float(a[7]), float(a[8]))),
Vector3(float(a[9]), float(a[10]), float(a[11])))
## Transform3D -> 12 floats, the shape record_at should be handed.
static func xform_to(t: Transform3D) -> Array:
return [t.basis.x.x, t.basis.x.y, t.basis.x.z,
t.basis.y.x, t.basis.y.y, t.basis.y.z,
t.basis.z.x, t.basis.z.y, t.basis.z.z,
t.origin.x, t.origin.y, t.origin.z]