Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413). Co-authored-by: Cursor <cursoragent@cursor.com>
334 lines
10 KiB
GDScript
334 lines
10 KiB
GDScript
@tool
|
|
extends Node3D
|
|
class_name ModelProp
|
|
## 3D GLB/prop with optional interact. Auto-fits wild asset scales via fit_height.
|
|
## When use_scale_catalog is on, data/model_scales.json overrides fit_* for this path.
|
|
## @tool: loads + fits the GLB in the editor viewport (does not own/save the mesh child).
|
|
## Optional StaticBody3D box from fitted AABB (layer 1 = Ground) — runtime only.
|
|
|
|
signal interacted(prop: ModelProp)
|
|
|
|
@export_file("*.glb", "*.gltf") var model_path: String = "":
|
|
set(v):
|
|
if model_path == v:
|
|
return
|
|
model_path = v
|
|
if Engine.is_editor_hint() and is_node_ready():
|
|
respawn_model()
|
|
## Extra multiplier after fit (usually 1,1,1). Used for slight per-slot variation.
|
|
@export var model_scale: Vector3 = Vector3.ONE:
|
|
set(v):
|
|
model_scale = v
|
|
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
|
_apply_fit()
|
|
@export var model_rotation_degrees: Vector3 = Vector3.ZERO:
|
|
set(v):
|
|
model_rotation_degrees = v
|
|
if _model != null:
|
|
_model.rotation_degrees = model_rotation_degrees
|
|
if Engine.is_editor_hint() and is_node_ready():
|
|
_apply_fit()
|
|
## Target AABB height in meters. 0 = use model_scale only (legacy).
|
|
@export var fit_height: float = 0.0:
|
|
set(v):
|
|
fit_height = v
|
|
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
|
_apply_fit()
|
|
## Cap horizontal size after height-fit (0 = no cap).
|
|
@export var fit_max_width: float = 0.0:
|
|
set(v):
|
|
fit_max_width = v
|
|
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
|
_apply_fit()
|
|
## Sit AABB bottom on this node's y=0.
|
|
@export var ground_align: bool = true:
|
|
set(v):
|
|
ground_align = v
|
|
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
|
_apply_fit()
|
|
## Apply SpriteFit catalog entry for model_path (scale lab SSOT).
|
|
@export var use_scale_catalog: bool = true:
|
|
set(v):
|
|
use_scale_catalog = v
|
|
if Engine.is_editor_hint() and is_node_ready() and _model != null:
|
|
_apply_fit()
|
|
## Add StaticBody3D + BoxShape3D from fitted AABB (houses on; animals/interior often off).
|
|
@export var add_collision: bool = true:
|
|
set(v):
|
|
add_collision = v
|
|
if is_node_ready():
|
|
_rebuild_collision()
|
|
## Shrink each side of the collision box (meters). Keeps a little approach room for interact.
|
|
@export var collision_inset: float = 0.15:
|
|
set(v):
|
|
collision_inset = v
|
|
if is_node_ready():
|
|
_rebuild_collision()
|
|
@export var can_interact: bool = false
|
|
@export var prompt_key: String = ""
|
|
@export var dialog_key: String = ""
|
|
@export var on_success_goto: String = ""
|
|
|
|
var _model: Node3D
|
|
var _highlight_meshes: Array[MeshInstance3D] = []
|
|
var _highlight_mat: StandardMaterial3D
|
|
var _collision_body: StaticBody3D
|
|
## Fitted mesh AABB in prop-local space (after fit / ground align).
|
|
var _local_aabb: AABB = AABB()
|
|
var _has_local_aabb: bool = false
|
|
|
|
func _ready() -> void:
|
|
_highlight_mat = StandardMaterial3D.new()
|
|
_highlight_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
_highlight_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
_highlight_mat.albedo_color = Color(1.4, 1.35, 0.4, 0.35)
|
|
## Runtime only: mirror catalog into exports for play-mode inspection.
|
|
## Never do this in editor — would dirty/save fit_* into the scene.
|
|
if not Engine.is_editor_hint():
|
|
_apply_catalog_overrides()
|
|
_spawn_model()
|
|
if Engine.is_editor_hint():
|
|
return
|
|
if can_interact:
|
|
add_to_group("interactable")
|
|
|
|
|
|
func _apply_catalog_overrides() -> void:
|
|
if not use_scale_catalog or model_path == "":
|
|
return
|
|
var entry := SpriteFit.scale_entry(model_path)
|
|
if entry.is_empty():
|
|
return
|
|
if entry.has("fit_height"):
|
|
fit_height = float(entry["fit_height"])
|
|
if entry.has("fit_max_width"):
|
|
fit_max_width = float(entry["fit_max_width"])
|
|
|
|
|
|
func _resolved_fit_height() -> float:
|
|
if use_scale_catalog and model_path != "":
|
|
var entry := SpriteFit.scale_entry(model_path)
|
|
if entry.has("fit_height"):
|
|
return float(entry["fit_height"])
|
|
return fit_height
|
|
|
|
|
|
func _resolved_fit_max_width() -> float:
|
|
if use_scale_catalog and model_path != "":
|
|
var entry := SpriteFit.scale_entry(model_path)
|
|
if entry.has("fit_max_width"):
|
|
return float(entry["fit_max_width"])
|
|
return fit_max_width
|
|
|
|
|
|
func set_fit_params(height_m: float, max_width_m: float, reapply: bool = true) -> void:
|
|
fit_height = height_m
|
|
fit_max_width = max_width_m
|
|
if reapply:
|
|
_apply_fit()
|
|
|
|
|
|
func respawn_model() -> void:
|
|
_clear_model()
|
|
if not Engine.is_editor_hint():
|
|
_apply_catalog_overrides()
|
|
_spawn_model()
|
|
|
|
|
|
func _clear_model() -> void:
|
|
_clear_collision()
|
|
if is_instance_valid(_model):
|
|
remove_child(_model)
|
|
_model.free()
|
|
_model = null
|
|
_highlight_meshes.clear()
|
|
_has_local_aabb = false
|
|
|
|
|
|
func _spawn_model() -> void:
|
|
_clear_model()
|
|
if model_path == "" or not ResourceLoader.exists(model_path):
|
|
if model_path != "":
|
|
push_warning("ModelProp missing model: %s" % model_path)
|
|
return
|
|
var packed: PackedScene = load(model_path)
|
|
if packed == null:
|
|
return
|
|
_model = packed.instantiate() as Node3D
|
|
if _model == null:
|
|
return
|
|
_model.rotation_degrees = model_rotation_degrees
|
|
_model.scale = Vector3.ONE
|
|
_model.position = Vector3.ZERO
|
|
add_child(_model)
|
|
## Preview mesh must not be saved into the edited scene.
|
|
_model.owner = null
|
|
_collect_meshes(_model)
|
|
_apply_fit()
|
|
|
|
func _collect_meshes(n: Node) -> void:
|
|
if n is MeshInstance3D:
|
|
_highlight_meshes.append(n as MeshInstance3D)
|
|
for c in n.get_children():
|
|
_collect_meshes(c)
|
|
|
|
func _xform_to_self(n: Node3D) -> Transform3D:
|
|
## Local transform chain — avoids get_tree()/global_transform when not needed.
|
|
var xf := Transform3D.IDENTITY
|
|
var cur: Node = n
|
|
while cur != null and cur != self:
|
|
if cur is Node3D:
|
|
xf = (cur as Node3D).transform * xf
|
|
cur = cur.get_parent()
|
|
return xf
|
|
|
|
func _aabb_in_self_space() -> AABB:
|
|
var merged := AABB()
|
|
var first := true
|
|
for mi in _highlight_meshes:
|
|
if not is_instance_valid(mi) or mi.mesh == null:
|
|
continue
|
|
var piece: AABB = _xform_to_self(mi) * mi.get_aabb()
|
|
if first:
|
|
merged = piece
|
|
first = false
|
|
else:
|
|
merged = merged.merge(piece)
|
|
return merged
|
|
|
|
func _apply_fit() -> void:
|
|
if _model == null or _highlight_meshes.is_empty():
|
|
return
|
|
# Measure unscaled (rotation already applied).
|
|
_model.scale = Vector3.ONE
|
|
_model.position = Vector3.ZERO
|
|
var aabb := _aabb_in_self_space()
|
|
var size := aabb.size
|
|
## Prefer Y as height; if the mesh is sideways (Y << longest axis), use longest.
|
|
var measure := size.y
|
|
var longest := maxf(size.x, maxf(size.y, size.z))
|
|
if measure <= 0.0001 or measure < longest * 0.55:
|
|
measure = longest
|
|
if measure <= 0.0001:
|
|
_model.scale = model_scale
|
|
_cache_local_aabb()
|
|
_rebuild_collision()
|
|
return
|
|
var s := 1.0
|
|
var use_h := _resolved_fit_height()
|
|
var use_w := _resolved_fit_max_width()
|
|
if use_h > 0.0:
|
|
s = use_h / measure
|
|
if use_w > 0.0:
|
|
var horiz := maxf(size.x, size.z) * s
|
|
## Also cap the longest axis so sideways models cannot become giants.
|
|
var long_after := longest * s
|
|
if horiz > use_w:
|
|
s *= use_w / horiz
|
|
long_after = longest * s
|
|
if long_after > use_h * 1.35 and use_h > 0.0:
|
|
s *= (use_h * 1.35) / maxf(long_after, 0.0001)
|
|
_model.scale = model_scale * s
|
|
if ground_align:
|
|
aabb = _aabb_in_self_space()
|
|
_model.position.y -= aabb.position.y
|
|
_cache_local_aabb()
|
|
_rebuild_collision()
|
|
|
|
func _cache_local_aabb() -> void:
|
|
if _highlight_meshes.is_empty():
|
|
_has_local_aabb = false
|
|
return
|
|
_local_aabb = _aabb_in_self_space()
|
|
_has_local_aabb = _local_aabb.size.length() > 0.0001
|
|
|
|
func _clear_collision() -> void:
|
|
if is_instance_valid(_collision_body):
|
|
remove_child(_collision_body)
|
|
_collision_body.free()
|
|
_collision_body = null
|
|
|
|
func _rebuild_collision() -> void:
|
|
_clear_collision()
|
|
## Runtime only — avoid editor physics / dirtying scenes with collision children.
|
|
if Engine.is_editor_hint() or not add_collision or not _has_local_aabb:
|
|
return
|
|
var aabb := _local_aabb
|
|
var inset := maxf(collision_inset, 0.0)
|
|
var size := Vector3(
|
|
maxf(aabb.size.x - inset * 2.0, 0.05),
|
|
maxf(aabb.size.y - inset, 0.05),
|
|
maxf(aabb.size.z - inset * 2.0, 0.05)
|
|
)
|
|
## Keep box sitting on ground (y=0): only inset sides + top, not bottom.
|
|
var center := Vector3(
|
|
aabb.position.x + aabb.size.x * 0.5,
|
|
size.y * 0.5,
|
|
aabb.position.z + aabb.size.z * 0.5
|
|
)
|
|
var body := StaticBody3D.new()
|
|
body.name = "Collision"
|
|
body.collision_layer = 1 ## same as Ground — player mask is 1
|
|
body.collision_mask = 0
|
|
var shape_node := CollisionShape3D.new()
|
|
var box := BoxShape3D.new()
|
|
box.size = size
|
|
shape_node.shape = box
|
|
shape_node.position = center
|
|
body.add_child(shape_node)
|
|
add_child(body)
|
|
body.owner = null
|
|
_collision_body = body
|
|
|
|
func get_interact_distance(from_global: Vector3) -> float:
|
|
## Distance to nearest point on fitted AABB so large house boxes stay reachable.
|
|
if not _has_local_aabb:
|
|
return global_position.distance_to(from_global)
|
|
var local := to_local(from_global)
|
|
var min_p := _local_aabb.position
|
|
var max_p := _local_aabb.position + _local_aabb.size
|
|
var closest := Vector3(
|
|
clampf(local.x, min_p.x, max_p.x),
|
|
clampf(local.y, min_p.y, max_p.y),
|
|
clampf(local.z, min_p.z, max_p.z)
|
|
)
|
|
return from_global.distance_to(to_global(closest))
|
|
|
|
func on_interact(_player: Node) -> void:
|
|
if Engine.is_editor_hint() or not can_interact:
|
|
return
|
|
interacted.emit(self)
|
|
var ui := get_node_or_null("/root/UIRoot")
|
|
if dialog_key != "" and on_success_goto == "":
|
|
if ui and ui.has_method("show_dialog_key"):
|
|
ui.show_dialog_key(dialog_key)
|
|
if on_success_goto != "":
|
|
if dialog_key != "" and ui and ui.has_method("show_dialog_key"):
|
|
ui.show_dialog_key(dialog_key, 1.0, false)
|
|
GameState.goto_scene(on_success_goto)
|
|
|
|
func get_interact_prompt() -> String:
|
|
if prompt_key != "":
|
|
return Locale.tr_key(prompt_key)
|
|
if on_success_goto != "":
|
|
return Locale.tr_key("prompt_press_button")
|
|
return Locale.tr_key("prompt_use")
|
|
|
|
func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void:
|
|
## MeshInstance3D has no modulate — use material_overlay tint instead.
|
|
if Engine.is_editor_hint() or _highlight_mat == null:
|
|
return
|
|
_highlight_mat.albedo_color = Color(color.r, color.g, color.b, 0.35)
|
|
for mi in _highlight_meshes:
|
|
if not is_instance_valid(mi):
|
|
continue
|
|
mi.material_overlay = _highlight_mat if enabled else null
|
|
|
|
|
|
func _get_configuration_warnings() -> PackedStringArray:
|
|
var warnings: PackedStringArray = []
|
|
if model_path == "":
|
|
warnings.append("ModelProp: set model_path to a .glb/.gltf")
|
|
elif not ResourceLoader.exists(model_path):
|
|
warnings.append("ModelProp: model_path does not exist: %s" % model_path)
|
|
return warnings
|