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
265
scripts/util/sprite_fit.gd
Normal file
265
scripts/util/sprite_fit.gd
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue