@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