Explosion_der_Spiele/scripts/util/sprite_fit.gd
copilot 1cc8aa9b9d 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>
2026-09-15 12:02:57 +02:00

265 lines
8.9 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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