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:
copilot 2026-09-15 12:02:57 +02:00
parent 1d9e0a6532
commit 1cc8aa9b9d
507 changed files with 12991 additions and 579 deletions

265
scripts/util/sprite_fit.gd Normal file
View file

@ -0,0 +1,265 @@
extends Object
class_name SpriteFit
## Resolution-independent sprite / world sizing.
## Canonical adult = player 1.74 m; other world sizes are realistic relative to that.
## Runtime GLB sizes: prefer data/model_scales.json (scale lab SSOT), else built-in tables.
const SCALE_CATALOG_PATH := "res://data/model_scales.json"
const MODELS_ROOT := "res://assets/3DModelle/"
## --- World (meters) ---
const WORLD_PLAYER_HEIGHT := 1.74
const WORLD_CHARACTER_HEIGHT := 1.74 ## NPCs ≈ adult
const WORLD_BOSS_HEIGHT := 2.15 ## slightly taller than player
const WORLD_PROP_HEIGHT := 0.85
const WORLD_PICKUP_HEIGHT := 0.55
const WORLD_HOUSE_HEIGHT := 8.5 ## village houses ~5× player (1.74 m)
const WORLD_HOUSE_MAX_WIDTH := 12.0
const WORLD_MAX_WIDTH := 2.2
const WORLD_TABLE_HEIGHT := 0.75
const WORLD_BUTTON_HEIGHT := 0.05
const WORLD_COW_HEIGHT := 1.50 ## withers ≈ adult cow
const WORLD_DOOR_HEIGHT := 2.05 ## indoor floating door
const WORLD_CAMERA_EYE := 1.62 ## ~eye level for 1.74 m
## Screen (2D Sprite2D / UI), pixels on the longest side
const UI_RAIN_PX := 48.0
const UI_ICON_PX := 40.0
const UI_POPUP_PX := 192.0
## Village critters (standing height in meters), keyed by animals/<folder>/
## Fallback when catalog missing a key.
const ANIMAL_HEIGHTS := {
"cat": 0.28,
"chimp": 0.85, ## adult common chimp ~ standing
"dog": 0.62,
"giraffe": 3.40,
"snail": 0.16,
"spider": 0.40,
"cow": 1.50,
"gorilla": 1.55,
}
## Horizontal cap after height-fit (keeps odd AABBs from exploding sideways/up).
const ANIMAL_MAX_WIDTH := {
"cat": 0.55,
"chimp": 0.80,
"dog": 1.10,
"giraffe": 2.80,
"snail": 0.35,
"spider": 0.70,
"cow": 2.40,
"gorilla": 1.40,
}
static var _catalog_scales: Dictionary = {}
static var _catalog_loaded: bool = false
static func reload_scale_catalog() -> void:
_catalog_loaded = false
_catalog_scales.clear()
ensure_scale_catalog()
static func ensure_scale_catalog() -> void:
if _catalog_loaded:
return
_catalog_loaded = true
_catalog_scales.clear()
if not FileAccess.file_exists(SCALE_CATALOG_PATH):
return
var file := FileAccess.open(SCALE_CATALOG_PATH, FileAccess.READ)
if file == null:
push_warning("SpriteFit: cannot read %s" % SCALE_CATALOG_PATH)
return
var parsed: Variant = JSON.parse_string(file.get_as_text())
if typeof(parsed) != TYPE_DICTIONARY:
push_warning("SpriteFit: invalid JSON in %s" % SCALE_CATALOG_PATH)
return
var scales: Variant = (parsed as Dictionary).get("scales", {})
if typeof(scales) != TYPE_DICTIONARY:
return
for key in (scales as Dictionary).keys():
var entry: Variant = scales[key]
if typeof(entry) != TYPE_DICTIONARY:
continue
_catalog_scales[str(key)] = (entry as Dictionary).duplicate(true)
static func default_scale_catalog() -> Dictionary:
## Built-in defaults matching ANIMAL_* / WORLD_* (for lab Reset).
var scales := {
"animals/cat": {"fit_height": 0.28, "fit_max_width": 0.55, "notes": "standing cat"},
"animals/chimp": {"fit_height": 0.85, "fit_max_width": 0.80, "notes": "adult common chimp standing"},
"animals/cow": {"fit_height": WORLD_COW_HEIGHT, "fit_max_width": 2.4, "notes": "withers ≈ adult cow"},
"animals/dog": {"fit_height": 0.62, "fit_max_width": 1.10},
"animals/giraffe": {"fit_height": 3.40, "fit_max_width": 2.80},
"animals/snail": {"fit_height": 0.16, "fit_max_width": 0.35},
"animals/spider": {"fit_height": 0.40, "fit_max_width": 0.70},
"houses/broken": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
"houses/house": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
"houses/house_b": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
"houses/house_c": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH},
"houses/massour": {"fit_height": WORLD_HOUSE_HEIGHT, "fit_max_width": WORLD_HOUSE_MAX_WIDTH, "notes": "masseur house"},
"props/button": {"fit_height": WORLD_BUTTON_HEIGHT, "fit_max_width": 0.10},
"props/table": {"fit_height": WORLD_TABLE_HEIGHT, "fit_max_width": 1.40},
}
return {
"_meta": {
"player_height_m": WORLD_PLAYER_HEIGHT,
"notes": "Edited by scenes/dev/scale_lab.tscn. Keys = category/folder under assets/3DModelle/.",
},
"scales": scales,
}
static func save_scale_catalog(doc: Dictionary) -> Error:
var text := JSON.stringify(doc, "\t")
var abs_path := ProjectSettings.globalize_path(SCALE_CATALOG_PATH)
var file := FileAccess.open(abs_path, FileAccess.WRITE)
if file == null:
## Fallback: try res:// path (editor often allows it).
file = FileAccess.open(SCALE_CATALOG_PATH, FileAccess.WRITE)
if file == null:
push_error("SpriteFit: cannot write %s (err %s)" % [SCALE_CATALOG_PATH, FileAccess.get_open_error()])
return FileAccess.get_open_error()
file.store_string(text)
file.store_string("\n")
_catalog_scales.clear()
var scales: Variant = doc.get("scales", {})
if typeof(scales) == TYPE_DICTIONARY:
for key in (scales as Dictionary).keys():
var entry: Variant = scales[key]
if typeof(entry) == TYPE_DICTIONARY:
_catalog_scales[str(key)] = (entry as Dictionary).duplicate(true)
_catalog_loaded = true
return OK
static func catalog_key_for_path(model_path: String) -> String:
## res://assets/3DModelle/animals/chimp/chimp.glb → animals/chimp
var p := model_path.replace("\\", "/")
var root := MODELS_ROOT
if p.begins_with(root):
p = p.substr(root.length())
var parts := p.split("/")
if parts.size() >= 2:
return "%s/%s" % [parts[0], parts[1]]
return p.get_basename()
static func scale_entry(model_path: String) -> Dictionary:
ensure_scale_catalog()
var key := catalog_key_for_path(model_path)
if _catalog_scales.has(key):
return (_catalog_scales[key] as Dictionary).duplicate(true)
return {}
static func fit_height_for(model_path: String, fallback: float = -1.0) -> float:
var entry := scale_entry(model_path)
if entry.has("fit_height"):
return float(entry["fit_height"])
var folder := _animal_folder(model_path)
if folder != "" and ANIMAL_HEIGHTS.has(folder):
return float(ANIMAL_HEIGHTS[folder])
if _is_house_path(model_path):
return WORLD_HOUSE_HEIGHT
if fallback >= 0.0:
return fallback
return WORLD_PROP_HEIGHT
static func fit_max_width_for(model_path: String, fallback: float = -1.0) -> float:
var entry := scale_entry(model_path)
if entry.has("fit_max_width"):
return float(entry["fit_max_width"])
var folder := _animal_folder(model_path)
if folder != "" and ANIMAL_MAX_WIDTH.has(folder):
return float(ANIMAL_MAX_WIDTH[folder])
if _is_house_path(model_path):
return WORLD_HOUSE_MAX_WIDTH
if fallback >= 0.0:
return fallback
return WORLD_MAX_WIDTH
static func _is_house_path(model_path: String) -> bool:
return model_path.replace("\\", "/").find("/houses/") >= 0
static func _animal_folder(model_path: String) -> String:
var p := model_path.to_lower().replace("\\", "/")
var marker := "/animals/"
var i := p.find(marker)
if i < 0:
return ""
var rest := p.substr(i + marker.length())
var slash := rest.find("/")
if slash <= 0:
return ""
return rest.substr(0, slash)
static func animal_height(model_path: String, fallback: float = 0.70) -> float:
return fit_height_for(model_path, fallback)
static func animal_max_width(model_path: String, fallback: float = 1.2) -> float:
return fit_max_width_for(model_path, fallback)
static func texture_dims(tex: Texture2D) -> Vector2:
if tex == null:
return Vector2.ONE
var w := float(tex.get_width())
var h := float(tex.get_height())
return Vector2(maxf(w, 1.0), maxf(h, 1.0))
## Uniform scale so the texture's longest side equals target_long_side_px.
static func scale_to_long_side(tex: Texture2D, target_long_side_px: float) -> float:
var dims := texture_dims(tex)
var longest := maxf(dims.x, dims.y)
if longest <= 0.0 or target_long_side_px <= 0.0:
return 1.0
return target_long_side_px / longest
static func apply_sprite2d(sprite: Sprite2D, target_long_side_px: float = UI_RAIN_PX) -> void:
if sprite == null:
return
var s := scale_to_long_side(sprite.texture, target_long_side_px)
sprite.scale = Vector2(s, s)
## World-space QuadMesh size (width, height) fitted to target height, capped by max_width.
static func world_quad_size(
tex: Texture2D,
target_height: float = WORLD_CHARACTER_HEIGHT,
max_width: float = WORLD_MAX_WIDTH
) -> Vector2:
if tex == null:
return Vector2(target_height * 0.5, target_height)
var dims := texture_dims(tex)
var aspect := dims.x / dims.y
var h := target_height
var w := h * aspect
if w > max_width and max_width > 0.0:
w = max_width
h = w / maxf(aspect, 0.0001)
return Vector2(w, h)
## Keep a TextureRect inside a square budget (aspect preserved via expand/stretch).
static func apply_texture_rect_budget(rect: TextureRect, max_side_px: float = UI_POPUP_PX) -> void:
if rect == null:
return
var half := max_side_px * 0.5
rect.offset_left = -half
rect.offset_top = -half
rect.offset_right = half
rect.offset_bottom = half
rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED