Add village 3D wiring, voice lines, and 1.74 m world scale.
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>
This commit is contained in:
parent
1d9e0a6532
commit
1cc8aa9b9d
507 changed files with 12991 additions and 579 deletions
334
scripts/combat/model_prop.gd
Normal file
334
scripts/combat/model_prop.gd
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
@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
|
||||
Loading…
Add table
Add a link
Reference in a new issue