OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / prefabs / model_object.gd

198 lines · gd ref:
extends Node3D
## A RUNTIME MODEL — the `model` prefab (PrefabCatalogue), placed by object.create like
## any other object, its four properties set by object.set_prop:
##
##   asset       content hash of the .glb (exact bytes — user, 2026-10-04)
##   model_name  what the Outliner and the placeholder call it
##   collision   auto (named meshes if the file has them, else convex) | convex |
##               trimesh | decompose | named | none (ModelCollision)
##   model_scale uniform; baked into the collision POINTS, never into the body
##
## Three looks, always one of them visible, so a model is never silently missing:
##   PLACEHOLDER  a wire box + "name… 45%" while the bytes are coming
##   MODEL        the shared scene from ModelCache, plus its collision
##   BLOCKED      a red box + the reason (failed check, unavailable, too big)
##
## The setters only mark it dirty; ONE rebuild runs at the end of the frame, because
## the fold sets all four properties back to back and building four times would make
## four bodies and parse nothing faster.

var asset: String = "":
	set(v):
		asset = v
		_mark()
var model_name: String = "":
	set(v):
		model_name = v
		_mark()
var collision: String = "auto":
	set(v):
		collision = v
		_mark()
var model_scale: float = 1.0:
	set(v):
		model_scale = v
		_mark()

var _dirty := false
var _built_key := ""
var _look := ""                      # "placeholder" | "model" | "blocked" — for gates
var _content: Node3D = null          # whichever look is up
var _label: Label3D = null


func _ready() -> void:
	var mc := _cache()
	if mc != null and not mc.model_state.is_connected(_on_model_state):
		mc.model_state.connect(_on_model_state)
	_mark()


func _mark() -> void:
	if _dirty or not is_inside_tree():
		return
	_dirty = true
	_rebuild.call_deferred()


func look() -> String:
	return _look


func _cache() -> Node:
	return get_node_or_null(^"/root/ModelCache")


## LIVE PREVIEW of a scale by `k` on top of model_scale (ScaleOperator): the built holder
## is scaled about the same base the visual already is, so nothing is rebuilt per wheel
## tick (a decomposed collision would re-run VHACD). preview_scale(1.0) undoes it; the
## confirmed set_prop rebuilds a fresh holder anyway.
func preview_scale(k: float) -> void:
	if _content == null or not is_instance_valid(_content):
		return
	var mc := _cache()
	var base: Vector3 = mc.base(asset) if _look == "model" and mc != null else Vector3.ZERO
	_content.transform = ModelCollision.scale_about(base, k)


func _on_model_state(oid: String) -> void:
	if oid != asset:
		return
	var st: Dictionary = _cache().status(oid)
	# Progress only changes the label; anything else is a different look.
	if String(st.get("state", "")) == "fetching" and _look == "placeholder" and _label != null:
		_label.text = _placeholder_text(st)
		return
	_mark()


## Build the current look. Public for gates that cannot wait for the deferred call.
func rebuild_now() -> void:
	_dirty = true
	_rebuild()


func _rebuild() -> void:
	if not _dirty:
		return
	_dirty = false
	var mc := _cache()
	var st: Dictionary = {}
	if asset != "" and mc != null:
		st = mc.request(asset)
	var state := String(st.get("state", ""))
	var key := "%s|%s|%s|%s|%s" % [asset, state, collision, String.num(model_scale, 4), model_name]
	if key == _built_key and _content != null and is_instance_valid(_content):
		return
	_built_key = key
	_clear()
	if asset == "":
		_show_box(Color(0.6, 0.6, 0.6, 0.5), "%s (no model)" % _title(), "blocked")
	elif state == "ready":
		_show_model(mc)
	elif state == "failed":
		_show_box(Color(0.9, 0.15, 0.1, 0.6), "%s — blocked: %s" % [_title(), String(st.get("reason", "?"))], "blocked")
	else:
		_show_box(Color(1.0, 0.7, 0.2, 0.35), _placeholder_text(st), "placeholder")


func _title() -> String:
	return model_name if model_name != "" else (asset.substr(0, 8) if asset != "" else "model")


func _placeholder_text(st: Dictionary) -> String:
	var total := int(st.get("total", 0))
	if String(st.get("state", "")) == "queued":
		return "%s… loading" % _title()
	if total > 0:
		return "%s… %d%%" % [_title(), int(100.0 * float(st.get("received", 0)) / float(total))]
	return "%s… waiting for a peer" % _title()


func _clear() -> void:
	if _content != null and is_instance_valid(_content):
		remove_child(_content)
		_content.queue_free()
	_content = null
	_label = null


func _show_model(mc: Node) -> void:
	var holder := Node3D.new()
	holder.name = "Model"
	var visual: Node3D = mc.instance(asset)
	visual.name = "Visual"
	# Scaled ABOUT THE BASE (user, 2026-10-04: "around the base, where it stands"), with
	# the same transform the collision points get — ModelCollision.scale_about.
	visual.transform = ModelCollision.scale_about(mc.base(asset), model_scale) * visual.transform
	holder.add_child(visual)
	var col: Dictionary = mc.shapes(asset, collision, model_scale)
	# -colonly / -convcolonly: the author's collision mesh is not meant to be SEEN.
	# Freed from THIS copy only — the cached scene is shared.
	for p: Variant in col.get("hide", []):
		var n := visual.get_node_or_null(p as NodePath)
		if n != null:
			n.get_parent().remove_child(n)
			n.queue_free()
	var shapes: Array = col.get("shapes", [])
	if not shapes.is_empty():
		var body := StaticBody3D.new()
		body.name = "Body"
		# Layer 2 is the grapple's. map_core adds it to every StaticBody3D at map load;
		# a model built later would otherwise be the one thing the hook passes through.
		body.collision_layer = 1 | 2
		for s: Variant in shapes:
			var cs := CollisionShape3D.new()
			cs.shape = s as Shape3D
			body.add_child(cs)
		holder.add_child(body)
	add_child(holder)
	_content = holder
	_look = "model"


func _show_box(color: Color, text: String, look_name: String) -> void:
	var holder := Node3D.new()
	holder.name = "Box"
	var mi := MeshInstance3D.new()
	var b := BoxMesh.new()
	b.size = Vector3.ONE * maxf(0.25, model_scale)
	mi.mesh = b
	mi.position.y = b.size.y * 0.5
	var mat := StandardMaterial3D.new()
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	mat.albedo_color = color
	mi.material_override = mat
	holder.add_child(mi)
	_label = Label3D.new()
	_label.text = text
	_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	_label.no_depth_test = true
	_label.pixel_size = 0.006
	_label.position.y = b.size.y + 0.4
	holder.add_child(_label)
	add_child(holder)
	_content = holder
	_look = look_name