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
472
scripts/dev/scale_lab.gd
Normal file
472
scripts/dev/scale_lab.gd
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
extends Node3D
|
||||
## Editor scale lab: tune ModelProp fit_* vs measuring wall + 1.74 m player.
|
||||
## Open scenes/dev/scale_lab.tscn — Save writes data/model_scales.json (runtime SSOT).
|
||||
|
||||
const MODELS_ROOT := "res://assets/3DModelle"
|
||||
const MODEL_PROP_SCENE := "res://scenes/prefabs/model_prop.tscn"
|
||||
const WALL_MAX_M := 4.0
|
||||
const PREVIEW_POS := Vector3(0.0, 0.0, 0.0)
|
||||
const PLAYER_REF_POS := Vector3(-1.6, 0.0, 0.6)
|
||||
|
||||
var _models: Array[Dictionary] = [] ## {key, path, label}
|
||||
var _selected: int = -1
|
||||
var _preview: ModelProp
|
||||
var _working: Dictionary = {} ## key -> {fit_height, fit_max_width, notes?}
|
||||
var _status_label: Label
|
||||
var _height_spin: SpinBox
|
||||
var _width_spin: SpinBox
|
||||
var _list: ItemList
|
||||
var _updating_ui: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_build_environment()
|
||||
_build_measuring_wall()
|
||||
_build_player_reference()
|
||||
_build_ui()
|
||||
_load_working_from_catalog()
|
||||
_scan_models()
|
||||
_populate_list()
|
||||
if not _models.is_empty():
|
||||
_list.select(0)
|
||||
_select_index(0)
|
||||
_update_camera()
|
||||
|
||||
|
||||
func _build_environment() -> void:
|
||||
var env := Environment.new()
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = Color(0.55, 0.72, 0.88)
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.85, 0.88, 0.92)
|
||||
env.ambient_light_energy = 0.65
|
||||
$WorldEnvironment.environment = env
|
||||
|
||||
|
||||
func _build_measuring_wall() -> void:
|
||||
var wall_root := Node3D.new()
|
||||
wall_root.name = "MeasuringWall"
|
||||
wall_root.position = Vector3(0.0, 0.0, -2.2)
|
||||
add_child(wall_root)
|
||||
|
||||
## Back panel
|
||||
var panel := MeshInstance3D.new()
|
||||
var box := BoxMesh.new()
|
||||
box.size = Vector3(6.0, WALL_MAX_M + 0.4, 0.08)
|
||||
panel.mesh = box
|
||||
panel.position = Vector3(0.0, (WALL_MAX_M + 0.4) * 0.5, 0.0)
|
||||
var panel_mat := StandardMaterial3D.new()
|
||||
panel_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
panel_mat.albedo_color = Color(0.92, 0.92, 0.88)
|
||||
panel.material_override = panel_mat
|
||||
wall_root.add_child(panel)
|
||||
|
||||
## Tick marks every 0.1 m; stronger at 0.5 / 1.0
|
||||
for i in range(int(WALL_MAX_M * 10.0) + 1):
|
||||
var y := float(i) * 0.1
|
||||
var major := (i % 10) == 0
|
||||
var mid := (i % 5) == 0
|
||||
var tick := MeshInstance3D.new()
|
||||
var tbox := BoxMesh.new()
|
||||
var len := 0.35 if major else (0.22 if mid else 0.12)
|
||||
var thick := 0.025 if major else (0.018 if mid else 0.01)
|
||||
tbox.size = Vector3(len, thick, 0.04)
|
||||
tick.mesh = tbox
|
||||
tick.position = Vector3(-2.6 + len * 0.5, y, 0.06)
|
||||
var tmat := StandardMaterial3D.new()
|
||||
tmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
if absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001:
|
||||
tmat.albedo_color = Color(0.85, 0.15, 0.1)
|
||||
tbox.size = Vector3(0.55, 0.035, 0.05)
|
||||
tick.position.x = -2.6 + 0.275
|
||||
elif major:
|
||||
tmat.albedo_color = Color(0.12, 0.12, 0.14)
|
||||
else:
|
||||
tmat.albedo_color = Color(0.35, 0.35, 0.38)
|
||||
tick.material_override = tmat
|
||||
wall_root.add_child(tick)
|
||||
|
||||
if major or absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001:
|
||||
var label := Label3D.new()
|
||||
if absf(y - SpriteFit.WORLD_PLAYER_HEIGHT) < 0.001:
|
||||
label.text = "1.74 m player"
|
||||
label.modulate = Color(0.9, 0.2, 0.15)
|
||||
else:
|
||||
label.text = "%.0f m" % y
|
||||
label.modulate = Color(0.1, 0.1, 0.12)
|
||||
label.font_size = 48
|
||||
label.pixel_size = 0.0045
|
||||
label.position = Vector3(-2.95, y, 0.08)
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_DISABLED
|
||||
wall_root.add_child(label)
|
||||
|
||||
## Floor grid strip in front of wall
|
||||
var floor_grid := MeshInstance3D.new()
|
||||
var fbox := BoxMesh.new()
|
||||
fbox.size = Vector3(6.0, 0.02, 4.0)
|
||||
floor_grid.mesh = fbox
|
||||
floor_grid.position = Vector3(0.0, 0.01, 0.0)
|
||||
var fmat := StandardMaterial3D.new()
|
||||
fmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
fmat.albedo_color = Color(0.45, 0.5, 0.48)
|
||||
floor_grid.material_override = fmat
|
||||
add_child(floor_grid)
|
||||
|
||||
|
||||
func _build_player_reference() -> void:
|
||||
var root := Node3D.new()
|
||||
root.name = "PlayerReference"
|
||||
root.position = PLAYER_REF_POS
|
||||
add_child(root)
|
||||
|
||||
var capsule := MeshInstance3D.new()
|
||||
var mesh := CapsuleMesh.new()
|
||||
mesh.radius = 0.22
|
||||
mesh.height = SpriteFit.WORLD_PLAYER_HEIGHT
|
||||
capsule.mesh = mesh
|
||||
capsule.position = Vector3(0.0, SpriteFit.WORLD_PLAYER_HEIGHT * 0.5, 0.0)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = Color(0.2, 0.45, 0.95, 0.55)
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
capsule.material_override = mat
|
||||
root.add_child(capsule)
|
||||
|
||||
var tag := Label3D.new()
|
||||
tag.text = "Player 1.74 m"
|
||||
tag.font_size = 42
|
||||
tag.pixel_size = 0.004
|
||||
tag.position = Vector3(0.0, SpriteFit.WORLD_PLAYER_HEIGHT + 0.15, 0.0)
|
||||
tag.modulate = Color(0.15, 0.35, 0.9)
|
||||
root.add_child(tag)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
layer.name = "LabUI"
|
||||
layer.layer = 80
|
||||
add_child(layer)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
panel.offset_left = 12
|
||||
panel.offset_top = 12
|
||||
panel.offset_right = 360
|
||||
panel.offset_bottom = 520
|
||||
layer.add_child(panel)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 10)
|
||||
margin.add_theme_constant_override("margin_right", 10)
|
||||
margin.add_theme_constant_override("margin_top", 8)
|
||||
margin.add_theme_constant_override("margin_bottom", 8)
|
||||
panel.add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 6)
|
||||
margin.add_child(vbox)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Scale Lab"
|
||||
title.add_theme_font_size_override("font_size", 20)
|
||||
vbox.add_child(title)
|
||||
|
||||
var hint := Label.new()
|
||||
hint.text = "Wall ticks = meters. Red = player 1.74 m.\nSave → data/model_scales.json"
|
||||
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
vbox.add_child(hint)
|
||||
|
||||
_list = ItemList.new()
|
||||
_list.custom_minimum_size = Vector2(0, 220)
|
||||
_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_list.item_selected.connect(_on_list_selected)
|
||||
vbox.add_child(_list)
|
||||
|
||||
vbox.add_child(_make_spin_row("fit_height (m)", 0.01, 20.0, 0.01, true))
|
||||
vbox.add_child(_make_spin_row("fit_max_width (m)", 0.0, 30.0, 0.05, false))
|
||||
|
||||
var buttons := HBoxContainer.new()
|
||||
buttons.add_theme_constant_override("separation", 6)
|
||||
vbox.add_child(buttons)
|
||||
|
||||
var save_btn := Button.new()
|
||||
save_btn.text = "Save"
|
||||
save_btn.pressed.connect(_on_save)
|
||||
buttons.add_child(save_btn)
|
||||
|
||||
var reset_btn := Button.new()
|
||||
reset_btn.text = "Reset defaults"
|
||||
reset_btn.pressed.connect(_on_reset)
|
||||
buttons.add_child(reset_btn)
|
||||
|
||||
var reload_btn := Button.new()
|
||||
reload_btn.text = "Reload JSON"
|
||||
reload_btn.pressed.connect(_on_reload)
|
||||
buttons.add_child(reload_btn)
|
||||
|
||||
_status_label = Label.new()
|
||||
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_status_label.text = "Ready."
|
||||
vbox.add_child(_status_label)
|
||||
|
||||
var cam_hint := Label.new()
|
||||
cam_hint.text = "Orbit: RMB drag · Zoom: wheel · WASD move cam"
|
||||
cam_hint.modulate = Color(0.7, 0.7, 0.75)
|
||||
vbox.add_child(cam_hint)
|
||||
|
||||
|
||||
func _make_spin_row(label_text: String, min_v: float, max_v: float, step: float, is_height: bool) -> Control:
|
||||
var row := VBoxContainer.new()
|
||||
var lab := Label.new()
|
||||
lab.text = label_text
|
||||
row.add_child(lab)
|
||||
var spin := SpinBox.new()
|
||||
spin.min_value = min_v
|
||||
spin.max_value = max_v
|
||||
spin.step = step
|
||||
spin.allow_greater = true
|
||||
spin.allow_lesser = false
|
||||
if is_height:
|
||||
spin.value_changed.connect(_on_height_changed)
|
||||
_height_spin = spin
|
||||
else:
|
||||
spin.value_changed.connect(_on_width_changed)
|
||||
_width_spin = spin
|
||||
row.add_child(spin)
|
||||
return row
|
||||
|
||||
|
||||
func _load_working_from_catalog() -> void:
|
||||
SpriteFit.reload_scale_catalog()
|
||||
_working.clear()
|
||||
## Seed from defaults, then overlay file.
|
||||
var defaults: Dictionary = SpriteFit.default_scale_catalog().get("scales", {})
|
||||
for key in defaults.keys():
|
||||
_working[str(key)] = (defaults[key] as Dictionary).duplicate(true)
|
||||
if FileAccess.file_exists(SpriteFit.SCALE_CATALOG_PATH):
|
||||
var file := FileAccess.open(SpriteFit.SCALE_CATALOG_PATH, FileAccess.READ)
|
||||
if file:
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var scales: Variant = (parsed as Dictionary).get("scales", {})
|
||||
if typeof(scales) == TYPE_DICTIONARY:
|
||||
for key in (scales as Dictionary).keys():
|
||||
var entry: Variant = scales[key]
|
||||
if typeof(entry) == TYPE_DICTIONARY:
|
||||
_working[str(key)] = (entry as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func _scan_models() -> void:
|
||||
_models.clear()
|
||||
for category in ["animals", "houses", "props"]:
|
||||
var cat_dir := "%s/%s" % [MODELS_ROOT, category]
|
||||
var dir := DirAccess.open(cat_dir)
|
||||
if dir == null:
|
||||
continue
|
||||
dir.list_dir_begin()
|
||||
var folder := dir.get_next()
|
||||
while folder != "":
|
||||
if dir.current_is_dir() and not folder.begins_with("."):
|
||||
var sub := "%s/%s" % [cat_dir, folder]
|
||||
var subdir := DirAccess.open(sub)
|
||||
if subdir:
|
||||
subdir.list_dir_begin()
|
||||
var fname := subdir.get_next()
|
||||
while fname != "":
|
||||
if not subdir.current_is_dir() and fname.to_lower().ends_with(".glb"):
|
||||
var path := "%s/%s" % [sub, fname]
|
||||
var key := "%s/%s" % [category, folder]
|
||||
_models.append({
|
||||
"key": key,
|
||||
"path": path,
|
||||
"label": "%s (%s)" % [key, fname],
|
||||
})
|
||||
## Ensure working entry exists.
|
||||
if not _working.has(key):
|
||||
_working[key] = {
|
||||
"fit_height": SpriteFit.fit_height_for(path),
|
||||
"fit_max_width": SpriteFit.fit_max_width_for(path),
|
||||
}
|
||||
break
|
||||
fname = subdir.get_next()
|
||||
folder = dir.get_next()
|
||||
_models.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a["key"]) < str(b["key"]))
|
||||
|
||||
|
||||
func _populate_list() -> void:
|
||||
_list.clear()
|
||||
for m in _models:
|
||||
_list.add_item(str(m["label"]))
|
||||
|
||||
|
||||
func _on_list_selected(index: int) -> void:
|
||||
_select_index(index)
|
||||
|
||||
|
||||
func _select_index(index: int) -> void:
|
||||
if index < 0 or index >= _models.size():
|
||||
return
|
||||
_selected = index
|
||||
var m: Dictionary = _models[index]
|
||||
var key: String = m["key"]
|
||||
var path: String = m["path"]
|
||||
var entry: Dictionary = _working.get(key, {
|
||||
"fit_height": SpriteFit.fit_height_for(path),
|
||||
"fit_max_width": SpriteFit.fit_max_width_for(path),
|
||||
})
|
||||
_updating_ui = true
|
||||
_height_spin.value = float(entry.get("fit_height", 1.0))
|
||||
_width_spin.value = float(entry.get("fit_max_width", 1.0))
|
||||
_updating_ui = false
|
||||
_spawn_preview(path, float(_height_spin.value), float(_width_spin.value))
|
||||
_status_label.text = "Selected %s" % key
|
||||
|
||||
|
||||
func _spawn_preview(path: String, fit_h: float, fit_w: float) -> void:
|
||||
if is_instance_valid(_preview):
|
||||
_preview.queue_free()
|
||||
_preview = null
|
||||
var packed: PackedScene = load(MODEL_PROP_SCENE)
|
||||
if packed == null:
|
||||
return
|
||||
_preview = packed.instantiate() as ModelProp
|
||||
if _preview == null:
|
||||
return
|
||||
_preview.name = "PreviewProp"
|
||||
_preview.use_scale_catalog = false
|
||||
_preview.add_collision = false
|
||||
_preview.model_path = path
|
||||
_preview.fit_height = fit_h
|
||||
_preview.fit_max_width = fit_w
|
||||
_preview.ground_align = true
|
||||
_preview.can_interact = false
|
||||
add_child(_preview)
|
||||
_preview.global_position = PREVIEW_POS
|
||||
|
||||
|
||||
func _on_height_changed(v: float) -> void:
|
||||
if _updating_ui or _selected < 0:
|
||||
return
|
||||
_commit_working(v, float(_width_spin.value))
|
||||
|
||||
|
||||
func _on_width_changed(v: float) -> void:
|
||||
if _updating_ui or _selected < 0:
|
||||
return
|
||||
_commit_working(float(_height_spin.value), v)
|
||||
|
||||
|
||||
func _commit_working(fit_h: float, fit_w: float) -> void:
|
||||
var key: String = _models[_selected]["key"]
|
||||
var entry: Dictionary = _working.get(key, {}).duplicate(true)
|
||||
entry["fit_height"] = fit_h
|
||||
entry["fit_max_width"] = fit_w
|
||||
_working[key] = entry
|
||||
if is_instance_valid(_preview):
|
||||
_preview.set_fit_params(fit_h, fit_w, true)
|
||||
|
||||
|
||||
func _on_save() -> void:
|
||||
var doc := {
|
||||
"_meta": {
|
||||
"player_height_m": SpriteFit.WORLD_PLAYER_HEIGHT,
|
||||
"notes": "Edited by scenes/dev/scale_lab.tscn. Keys = category/folder under assets/3DModelle/.",
|
||||
},
|
||||
"scales": _working.duplicate(true),
|
||||
}
|
||||
var err := SpriteFit.save_scale_catalog(doc)
|
||||
if err == OK:
|
||||
_status_label.text = "Saved %s (%d entries)." % [SpriteFit.SCALE_CATALOG_PATH, _working.size()]
|
||||
else:
|
||||
_status_label.text = "Save FAILED (err %s). Run from editor so res:// is writable." % err
|
||||
|
||||
|
||||
func _on_reset() -> void:
|
||||
var defaults: Dictionary = SpriteFit.default_scale_catalog().get("scales", {})
|
||||
_working.clear()
|
||||
for key in defaults.keys():
|
||||
_working[str(key)] = (defaults[key] as Dictionary).duplicate(true)
|
||||
## Keep any extra scanned models with current SpriteFit fallbacks.
|
||||
for m in _models:
|
||||
var key: String = m["key"]
|
||||
if not _working.has(key):
|
||||
_working[key] = {
|
||||
"fit_height": SpriteFit.fit_height_for(str(m["path"])),
|
||||
"fit_max_width": SpriteFit.fit_max_width_for(str(m["path"])),
|
||||
}
|
||||
if _selected >= 0:
|
||||
_select_index(_selected)
|
||||
_status_label.text = "Reset to built-in defaults (not saved yet)."
|
||||
|
||||
|
||||
func _on_reload() -> void:
|
||||
_load_working_from_catalog()
|
||||
if _selected >= 0:
|
||||
_select_index(_selected)
|
||||
_status_label.text = "Reloaded from JSON."
|
||||
|
||||
|
||||
## Simple free camera (WASD + RMB look + wheel).
|
||||
var _yaw: float = 0.35
|
||||
var _pitch: float = -0.25
|
||||
var _cam_dist: float = 5.5
|
||||
var _cam_focus := Vector3(0.0, 1.0, 0.0)
|
||||
var _rmb_down: bool = false
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.button_index == MOUSE_BUTTON_RIGHT:
|
||||
_rmb_down = mb.pressed
|
||||
elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
_cam_dist = maxf(1.5, _cam_dist * 0.9)
|
||||
_update_camera()
|
||||
elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
_cam_dist = minf(18.0, _cam_dist * 1.1)
|
||||
_update_camera()
|
||||
elif event is InputEventMouseMotion and _rmb_down:
|
||||
var mm := event as InputEventMouseMotion
|
||||
_yaw -= mm.relative.x * 0.005
|
||||
_pitch = clampf(_pitch - mm.relative.y * 0.005, -1.2, 0.6)
|
||||
_update_camera()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var move := Vector3.ZERO
|
||||
if Input.is_key_pressed(KEY_W):
|
||||
move.z -= 1.0
|
||||
if Input.is_key_pressed(KEY_S):
|
||||
move.z += 1.0
|
||||
if Input.is_key_pressed(KEY_A):
|
||||
move.x -= 1.0
|
||||
if Input.is_key_pressed(KEY_D):
|
||||
move.x += 1.0
|
||||
if Input.is_key_pressed(KEY_Q):
|
||||
move.y -= 1.0
|
||||
if Input.is_key_pressed(KEY_E):
|
||||
move.y += 1.0
|
||||
if move != Vector3.ZERO:
|
||||
var basis := Basis.from_euler(Vector3(_pitch, _yaw, 0.0))
|
||||
_cam_focus += basis * move.normalized() * 3.0 * delta
|
||||
_update_camera()
|
||||
|
||||
|
||||
func _update_camera() -> void:
|
||||
var cam: Camera3D = $Camera3D
|
||||
if cam == null:
|
||||
return
|
||||
var offset := Vector3(
|
||||
sin(_yaw) * cos(_pitch),
|
||||
sin(_pitch),
|
||||
cos(_yaw) * cos(_pitch)
|
||||
) * _cam_dist
|
||||
## Orbit behind focus (wall at -Z).
|
||||
cam.global_position = _cam_focus + Vector3(offset.x, -offset.y + 1.2, offset.z)
|
||||
cam.look_at(_cam_focus, Vector3.UP)
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
call_deferred("_update_camera")
|
||||
Loading…
Add table
Add a link
Reference in a new issue