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>
265 lines
8.9 KiB
GDScript
265 lines
8.9 KiB
GDScript
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
|