Explosion_der_Spiele/scripts/combat/model_prop.gd

335 lines
10 KiB
GDScript3
Raw Normal View History

@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