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

View file

@ -1,15 +1,16 @@
extends Node
## Music / SFX / Voice + Burger-Cham every 120s on title/loading.
## Music / SFX / Voice (Piper lines under assets/audio/voice/{lang}/{dialog_key}.wav).
signal burger_cham_triggered
const BURGER_INTERVAL := 120.0
# TODO: Replace placeholder streams with real takes (see README asset list).
const MUSIC_TITLE := "res://assets/audio/placeholders/music_title.wav"
const SFX_EXPLOSION := "res://assets/audio/placeholders/sfx_explosion.wav"
const SFX_HIT := "res://assets/audio/placeholders/sfx_hit.wav"
const SFX_BURGER := "res://assets/audio/placeholders/sfx_burger.wav"
## Fallback language-picker clips if keyed Piper files missing
const VOICE_DE := "res://assets/audio/placeholders/voice_de.wav"
const VOICE_EN := "res://assets/audio/placeholders/voice_en.wav"
const VOICE_BY := "res://assets/audio/placeholders/voice_by.wav"
@ -19,6 +20,7 @@ var _sfx: AudioStreamPlayer
var _voice: AudioStreamPlayer
var _burger_timer: float = 0.0
var burger_cham_enabled: bool = true
var _last_voice_length: float = 0.0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
@ -69,6 +71,9 @@ func play_hit() -> void:
play_sfx(SFX_HIT)
func play_language_voice(lang: String) -> void:
var key := "lang_voice_%s" % lang
if play_dialog_line(key) > 0.0:
return
var path := VOICE_DE
match lang:
"en":
@ -80,11 +85,48 @@ func play_language_voice(lang: String) -> void:
return
_voice.stream = stream
_voice.play()
_last_voice_length = _stream_length(stream)
## Plays Piper line for locale key. Returns clip length in seconds (0 if missing).
func play_dialog_line(key: String) -> float:
if key == "":
return 0.0
if not (key.begins_with("dialog_") or key.begins_with("lang_voice_")):
return 0.0
var lang := "de"
if Settings:
lang = Settings.language
var path := "res://assets/audio/voice/%s/%s.wav" % [lang, key]
var stream := _load_stream(path)
if stream == null and lang != "de":
path = "res://assets/audio/voice/de/%s.wav" % key
stream = _load_stream(path)
if stream == null:
_last_voice_length = 0.0
return 0.0
_voice.stop()
_voice.stream = stream
_voice.play()
_last_voice_length = _stream_length(stream)
return _last_voice_length
func last_voice_length() -> float:
return _last_voice_length
func stop_voice() -> void:
_voice.stop()
func trigger_burger_cham() -> void:
play_sfx(SFX_BURGER)
burger_cham_triggered.emit()
func _stream_length(stream: AudioStream) -> float:
if stream == null:
return 0.0
if stream.has_method("get_length"):
return float(stream.get_length())
return 0.0
func _load_stream(path: String) -> AudioStream:
if ResourceLoader.exists(path):
return load(path)

View file

@ -11,10 +11,11 @@ const SAVE_PATH := "user://save.json"
const SCENE_PATHS := {
"title_screen": "res://scenes/title_screen.tscn",
"intro_cutscene": "res://scenes/intro_cutscene.tscn",
## Hauptwelt: Dorf + Reif-Arena + Wiese (contiguous). Legacy ids redirect here.
"village": "res://scenes/village.tscn",
"shoe_boss": "res://scenes/shoe_boss.tscn",
"meadow": "res://scenes/meadow.tscn",
"massage_studio": "res://scenes/massage_studio.tscn",
"shoe_boss": "res://scenes/village.tscn",
"meadow": "res://scenes/village.tscn",
"massage_studio": "res://scenes/village.tscn",
"catscene": "res://scenes/catscene.tscn",
"farm_path": "res://scenes/farm_path.tscn",
"farm": "res://scenes/farm.tscn",
@ -26,13 +27,11 @@ const SCENE_PATHS := {
}
## Default linear beats (branches override via goto_scene / flags).
## Village/shoe/meadow are one overworld — next story beat is Catscene.
const SCENE_ORDER: Array[String] = [
"title_screen",
"intro_cutscene",
"village",
"shoe_boss",
"meadow",
"massage_studio",
"catscene",
"farm_path",
"farm",
@ -43,6 +42,10 @@ const SCENE_ORDER: Array[String] = [
]
var current_scene_id: String = "title_screen"
## Resume target that survives title/death visits (not wiped when opening title).
var continue_scene_id: String = ""
## Optional: { "scene": String, "x": float, "y": float, "z": float }
var saved_player_pose: Dictionary = {}
var max_hp: int = 4
var hp: int = 4
var death_count: int = 0
@ -56,6 +59,8 @@ var fat_time_left: float = 0.0
var glitch_hits: int = 0
var shock_time_left: float = 0.0
const _NO_CONTINUE_SCENES := ["title_screen", "death_screen", ""]
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
load_game()
@ -105,27 +110,114 @@ func clear_glitch() -> void:
glitch_hits = 0
status_changed.emit()
func _normalize_scene_id(scene_id: String) -> String:
if scene_id in ["massage_studio", "shoe_boss", "meadow"]:
return "village"
return scene_id
func is_continue_scene(scene_id: String) -> bool:
var sid := _normalize_scene_id(scene_id)
return sid not in _NO_CONTINUE_SCENES and SCENE_PATHS.has(sid)
func has_continue_save() -> bool:
return is_continue_scene(continue_scene_id)
func get_continue_scene_id() -> String:
if not has_continue_save():
return ""
return _normalize_scene_id(continue_scene_id)
func continue_game() -> void:
var sid := get_continue_scene_id()
if sid.is_empty():
start_new_game()
return
goto_scene(sid)
func _clear_run_progress() -> void:
## Fresh run: inventory + quest flags + statuses. Keep meta (achievements/shop/settings/deaths).
hp = max_hp
glitch_hits = 0
fat_time_left = 0.0
shock_time_left = 0.0
flags.clear()
house_built = false
continue_scene_id = ""
saved_player_pose.clear()
Inventory.deserialize({"items": {}})
status_changed.emit()
hp_changed.emit(hp, max_hp)
func _capture_player_pose_if_any() -> void:
if not is_continue_scene(current_scene_id):
return
var tree := get_tree()
if tree == null:
return
var player := tree.get_first_node_in_group("player")
if player is Node3D:
var p := (player as Node3D).global_position
saved_player_pose = {
"scene": _normalize_scene_id(current_scene_id),
"x": p.x,
"y": p.y,
"z": p.z,
}
func consume_saved_player_position() -> Variant:
## Returns Vector3 to restore, or null if none / wrong scene.
if saved_player_pose.is_empty():
return null
var pose_scene := _normalize_scene_id(str(saved_player_pose.get("scene", "")))
if pose_scene != _normalize_scene_id(current_scene_id):
return null
if not saved_player_pose.has("x"):
return null
return Vector3(
float(saved_player_pose.get("x", 0.0)),
float(saved_player_pose.get("y", 0.0)),
float(saved_player_pose.get("z", 0.0)),
)
func goto_scene(scene_id: String) -> void:
## Legacy saves / links: shoe boss, meadow, massage are the village overworld.
scene_id = _normalize_scene_id(scene_id)
if not SCENE_PATHS.has(scene_id):
push_error("Unknown scene: %s" % scene_id)
return
## Leaving a gameplay scene via story/teleport: drop pose unless staying on same id.
if scene_id != current_scene_id and not saved_player_pose.is_empty():
var pose_scene := _normalize_scene_id(str(saved_player_pose.get("scene", "")))
if pose_scene != scene_id:
saved_player_pose.clear()
current_scene_id = scene_id
if is_continue_scene(scene_id):
continue_scene_id = scene_id
scene_changed.emit(scene_id)
if scene_id == "moon":
Achievements.unlock("moon_cheese")
save_game()
get_tree().change_scene_to_file(SCENE_PATHS[scene_id])
# VN dialog / pause must not leak across scenes (breaks title timers).
var tree := get_tree()
if tree == null:
tree = Engine.get_main_loop() as SceneTree
if tree == null:
push_error("goto_scene: no SceneTree")
return
if tree.paused:
tree.paused = false
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("force_close_dialog"):
ui.force_close_dialog()
tree.change_scene_to_file(SCENE_PATHS[scene_id])
func goto_next() -> void:
match current_scene_id:
"village":
goto_scene("shoe_boss")
"shoe_boss":
goto_scene("meadow")
"meadow":
goto_scene("massage_studio")
"massage_studio":
goto_scene("catscene")
"village", "shoe_boss", "meadow", "massage_studio":
## Contiguous overworld: only advance after massage — never teleport to shoe/meadow.
if get_flag("snake_massaged", false) or get_flag("bear_saved", false):
goto_scene("catscene")
## else: stay put (ProgressHouse / ExitTrigger show hints in village.gd)
"catscene":
goto_scene("farm_path")
"farm_path":
@ -254,8 +346,12 @@ func load_game() -> void:
last_death_cause_key = str(data.get("last_death_cause_key", "death_default"))
playtime_seconds = float(data.get("playtime_seconds", 0.0))
current_scene_id = str(data.get("current_scene_id", "title_screen"))
if current_scene_id in ["shoe_boss", "meadow", "massage_studio"]:
current_scene_id = "village"
house_built = bool(data.get("house_built", false))
flags = data.get("flags", {})
if str(flags.get("last_gameplay_scene", "")) in ["shoe_boss", "meadow", "massage_studio"]:
flags["last_gameplay_scene"] = "village"
fat_time_left = float(data.get("fat_time_left", 0.0))
glitch_hits = int(data.get("glitch_hits", 0))
Inventory.deserialize(data.get("inventory", {}))

View file

@ -47,6 +47,12 @@ func has_item(item_id: String, amount: int = 1) -> bool:
func get_count(item_id: String) -> int:
return int(_items.get(item_id, 0))
func get_icon_path(item_id: String) -> String:
var def: Variant = _defs.get(item_id, {})
if typeof(def) == TYPE_DICTIONARY:
return str(def.get("icon", ""))
return ""
func get_all() -> Dictionary:
return _items.duplicate()
@ -75,7 +81,7 @@ func use_item(item_id: String) -> bool:
item_used.emit(item_id)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_boss_unclever"), 4.0)
ui.show_dialog_key("dialog_boss_unclever", 4.0)
GameState.goto_scene("final_boss")
return true
"wedding_egg":

View file

@ -1,52 +1,109 @@
@tool
extends Node3D
class_name BillboardSprite
## Mario-tree billboard: upright Quad (perpendicular to ground), yaw-only toward camera.
## PlaneMesh defaults to flat on XZ — that made sprites look like floor stickers.
## Mario-tree billboard: upright Quad, yaw-only toward camera.
## Size always via SpriteFit (world height), never raw pixels × constant.
## @tool: faces the editor viewport camera while editing.
@export var texture: Texture2D
@export var pixel_size: float = 0.05
@export var modulate_color: Color = Color.WHITE
@export var texture: Texture2D:
set(v):
texture = v
if is_node_ready():
_apply()
## Meters; prefer SpriteFit.WORLD_* constants when setting from code.
@export var target_height: float = 1.74:
set(v):
target_height = v
if is_node_ready():
_apply()
@export var max_width: float = 2.2:
set(v):
max_width = v
if is_node_ready():
_apply()
@export var modulate_color: Color = Color.WHITE:
set(v):
modulate_color = v
if is_node_ready():
_set_albedo(modulate_color if not _highlight else _highlight_color)
@onready var _mesh_instance: MeshInstance3D = $MeshInstance3D
var _quad_height: float = 1.0
var _highlight: bool = false
var _highlight_color: Color = Color(1.35, 1.25, 0.35)
func _ready() -> void:
_ensure_mesh()
_apply()
func _process(_delta: float) -> void:
_face_camera_yaw_only()
if Engine.is_editor_hint():
return
if _highlight and _mesh_instance and _mesh_instance.material_override:
var pulse := 0.85 + 0.15 * sin(Time.get_ticks_msec() * 0.012)
(_mesh_instance.material_override as BaseMaterial3D).albedo_color = _highlight_color * pulse
func set_texture(tex: Texture2D) -> void:
texture = tex
_apply()
func set_target_height(h: float) -> void:
target_height = h
_apply()
func set_highlight(enabled: bool, color: Color = Color(1.35, 1.25, 0.35)) -> void:
if Engine.is_editor_hint():
return
_highlight = enabled
_highlight_color = color
if not enabled:
_set_albedo(modulate_color)
func flash(color: Color = Color(2.0, 2.0, 1.2), duration: float = 0.12) -> void:
if Engine.is_editor_hint():
return
_set_albedo(color)
var tree := get_tree()
if tree == null:
tree = Engine.get_main_loop() as SceneTree
if tree:
await tree.create_timer(duration, true).timeout
if is_instance_valid(self) and not _highlight:
_set_albedo(modulate_color)
func _ensure_mesh() -> void:
if _mesh_instance == null:
_mesh_instance = get_node_or_null("MeshInstance3D") as MeshInstance3D
func _set_albedo(c: Color) -> void:
_ensure_mesh()
if _mesh_instance and _mesh_instance.material_override:
(_mesh_instance.material_override as BaseMaterial3D).albedo_color = c
func _apply() -> void:
_ensure_mesh()
if _mesh_instance == null:
return
# QuadMesh is vertical (XY), facing +Z — correct default for standing sprites.
var quad := QuadMesh.new()
var w := 1.0
var h := 1.0
if texture != null:
w = float(texture.get_width()) * pixel_size
h = float(texture.get_height()) * pixel_size
var size := SpriteFit.world_quad_size(texture, target_height, max_width)
var w := size.x
var h := size.y
quad.size = Vector2(w, h)
_quad_height = h
_mesh_instance.mesh = quad
# Pivot at feet: lift mesh so bottom sits on this node's origin.
_mesh_instance.position = Vector3(0.0, h * 0.5, 0.0)
_mesh_instance.rotation = Vector3.ZERO
var mat := StandardMaterial3D.new()
# No engine billboard — we yaw the Node3D ourselves so orientation stays upright.
mat.billboard_mode = BaseMaterial3D.BILLBOARD_DISABLED
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.1
mat.cull_mode = BaseMaterial3D.CULL_DISABLED # visible from both sides
mat.albedo_color = modulate_color
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.albedo_color = modulate_color if not _highlight else _highlight_color
if texture != null:
mat.albedo_texture = texture
_mesh_instance.material_override = mat
@ -59,6 +116,4 @@ func _face_camera_yaw_only() -> void:
to_cam.y = 0.0
if to_cam.length_squared() < 0.0001:
return
# QuadMesh faces +Z; look_at aims -Z at the target — aim -Z away from camera
# so +Z faces the camera.
look_at(global_position - to_cam, Vector3.UP)

View file

@ -14,10 +14,13 @@ func _ready() -> void:
add_to_group("enemy")
add_to_group("cheese_projectile")
var bill := get_node_or_null("BillboardSprite")
if bill and bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/cheese.png")
if tex:
bill.set_texture(tex)
if bill:
if bill.has_method("set_target_height"):
bill.set_target_height(SpriteFit.WORLD_PROP_HEIGHT)
if bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/cheese.png")
if tex:
bill.set_texture(tex)
func _physics_process(delta: float) -> void:
global_position += velocity * delta

View file

@ -1,9 +1,15 @@
@tool
extends Node3D
class_name CheeseSeller
## Places a cheese table and periodically launches moldy cheese at the player.
## DESIGN: stab the cheese (projectile), not the seller — seller dies of grief when cheese destroyed.
## @tool: billboard look only in editor (no cheese firing).
@export var texture_path: String = "res://assets/sprites/placeholders/cheese.png"
@export var texture_path: String = "res://assets/sprites/placeholders/cheese_seller.png":
set(v):
texture_path = v
if Engine.is_editor_hint() and is_node_ready():
_apply_billboard_look()
@export var fire_interval: float = 2.8
@export var cheese_speed: float = 7.0
@ -14,16 +20,28 @@ var _grief: int = 0
@onready var _billboard: BillboardSprite = $BillboardSprite
func _ready() -> void:
_apply_billboard_look()
if Engine.is_editor_hint():
set_process(false)
return
add_to_group("interactable")
add_to_group("cheese_seller")
_timer = randf() * fire_interval
if _billboard:
func _apply_billboard_look() -> void:
if _billboard == null:
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
if _billboard == null:
return
_billboard.set_target_height(SpriteFit.WORLD_PROP_HEIGHT)
if texture_path != "" and ResourceLoader.exists(texture_path):
var tex: Texture2D = load(texture_path)
if tex:
_billboard.set_texture(tex)
func _process(delta: float) -> void:
if not _alive:
if Engine.is_editor_hint() or not _alive:
return
_timer -= delta
if _timer <= 0.0:
@ -52,16 +70,23 @@ func notify_cheese_destroyed() -> void:
_alive = false
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_cheese"))
ui.show_dialog_key("dialog_cheese")
queue_free()
func on_interact(_player: Node) -> void:
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_cheese"))
ui.show_dialog_key("dialog_cheese")
func take_hit(_amount: int = 1) -> void:
## Hitting the seller does nothing useful (DESIGN).
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_cheese"))
ui.show_dialog_key("dialog_cheese")
func get_interact_prompt() -> String:
return "%s — %s" % [Locale.tr_key("prompt_talk"), Locale.tr_key("dialog_cheese")]
func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void:
if _billboard and _billboard.has_method("set_highlight"):
_billboard.set_highlight(enabled, color)

View file

@ -1,15 +1,41 @@
extends Area3D
class_name ExitTrigger
## Walk into this volume to advance or jump to a scene.
## Level exit volume. Floating door billboard ONLY for indoor exits (show_door).
## Outdoor transitions into buildings use ModelProp [E], not this door art.
@export var next_scene_id: String = ""
@export var use_goto_next: bool = true
@export var require_flag: String = ""
## Indoor only: floating door billboard. Outside houses leave this false.
@export var show_door: bool = false
@export var texture_path: String = "res://assets/sprites/placeholders/door.png"
@export var billboard_height: float = 2.05
func _ready() -> void:
body_entered.connect(_on_body_entered)
collision_layer = 0
collision_mask = 2 # player layer
add_to_group("exit_trigger")
if show_door:
_ensure_door_art()
func _ensure_door_art() -> void:
if texture_path == "":
return
var bill := get_node_or_null("BillboardSprite")
if bill == null:
var packed: PackedScene = load("res://scenes/prefabs/billboard_sprite.tscn")
if packed == null:
return
bill = packed.instantiate()
bill.name = "BillboardSprite"
add_child(bill)
if bill.has_method("set_target_height"):
bill.set_target_height(billboard_height)
if bill.has_method("set_texture"):
var tex: Texture2D = load(texture_path)
if tex:
bill.set_texture(tex)
func _on_body_entered(body: Node3D) -> void:
if not body.is_in_group("player"):
@ -17,7 +43,7 @@ func _on_body_entered(body: Node3D) -> void:
if require_flag != "" and not GameState.get_flag(require_flag, false):
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("exit_hint") + " (?)")
ui.show_dialog(Locale.tr_key("exit_locked"), 3.0)
return
if use_goto_next and next_scene_id == "":
GameState.goto_next()

View file

@ -1,10 +1,12 @@
extends Area3D
class_name HazardZone
## Damages the player on enter.
## Damages the player on enter. Optional billboard art via texture_path.
@export var damage: int = 1
@export var death_cause_key: String = "death_default"
@export var cooldown: float = 1.0
@export var texture_path: String = ""
@export var billboard_height: float = 1.4
var _ready_to_hurt: bool = true
@ -12,6 +14,25 @@ func _ready() -> void:
body_entered.connect(_on_body_entered)
collision_layer = 0
collision_mask = 2
_ensure_billboard()
func _ensure_billboard() -> void:
if texture_path == "":
return
var bill := get_node_or_null("BillboardSprite")
if bill == null:
var packed: PackedScene = load("res://scenes/prefabs/billboard_sprite.tscn")
if packed == null:
return
bill = packed.instantiate()
bill.name = "BillboardSprite"
add_child(bill)
if bill.has_method("set_target_height"):
bill.set_target_height(billboard_height)
if bill.has_method("set_texture"):
var tex: Texture2D = load(texture_path)
if tex:
bill.set_texture(tex)
func _on_body_entered(body: Node3D) -> void:
if not _ready_to_hurt:

View file

@ -1,43 +1,154 @@
@tool
extends Node3D
class_name HittableEntity
## Generic enemy / boss stub with hit points.
## Billboard enemy/boss with optional chase + melee AI (DESIGN.md combat).
## @tool: applies texture/height in the editor (AI disabled while editing).
signal defeated
signal hit(remaining_hp: int)
@export var max_hp: int = 4
@export var texture_path: String = "res://assets/sprites/placeholders/enemy.png"
@export var texture_path: String = "res://assets/sprites/placeholders/enemy.png":
set(v):
texture_path = v
if Engine.is_editor_hint() and is_node_ready():
_apply_billboard_look()
@export var billboard_height: float = 1.74:
set(v):
billboard_height = v
if Engine.is_editor_hint() and is_node_ready():
_apply_billboard_look()
@export var defeat_flag: String = ""
@export var achievement_on_defeat: String = ""
@export_group("AI")
@export var ai_enabled: bool = true
@export var move_speed: float = 2.8
@export var attack_range: float = 1.15 ## Shorter than player reach so melee isn't face-hug only
@export var attack_cooldown: float = 1.15
@export var hold_distance: float = 0.95 ## Stop closing once inside this * attack_range
@export var attack_damage: int = 1
@export var death_cause_key: String = "death_default"
@export var shout_key: String = ""
@export var shout_interval: float = 4.5
@export var aggro_range: float = 28.0
@export var flee_below_hp: int = 0 ## If >0 and hp <= this, flee instead of chase
@export var invulnerable: bool = false ## Urhahn-style: player hits do nothing useful
@export var instakill: bool = false ## Contact attack kills instantly
@export var glitzo_mode: bool = false ## Attacks use register_glitzo_hit (3=slow, 4=dead)
var hp: int = 4
var _alive: bool = true
var _attack_cd: float = 0.0
var _shout_cd: float = 1.0
@onready var _billboard: BillboardSprite = $BillboardSprite
func _ready() -> void:
_apply_billboard_look()
if Engine.is_editor_hint():
set_physics_process(false)
return
hp = max_hp
add_to_group("enemy")
add_to_group("interactable")
if _billboard:
func _apply_billboard_look() -> void:
if _billboard == null:
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
if _billboard == null:
return
_billboard.set_target_height(billboard_height)
if texture_path != "" and ResourceLoader.exists(texture_path):
var tex: Texture2D = load(texture_path)
if tex:
_billboard.set_texture(tex)
func _physics_process(delta: float) -> void:
if Engine.is_editor_hint() or not _alive or not ai_enabled:
return
if GameState.current_scene_id in ["title_screen", "death_screen"]:
return
if get_tree().paused:
return
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
return
_attack_cd = maxf(0.0, _attack_cd - delta)
_shout_cd = maxf(0.0, _shout_cd - delta)
var player := get_tree().get_first_node_in_group("player")
if player == null or not (player is Node3D):
return
var ppos: Vector3 = (player as Node3D).global_position
var to_player := ppos - global_position
to_player.y = 0.0
var dist := to_player.length()
if dist > aggro_range:
return
if shout_key != "" and _shout_cd <= 0.0 and dist < 12.0:
_shout_cd = shout_interval
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key(shout_key, 2.0, false)
elif ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key(shout_key), 2.0, shout_key, false)
var fleeing := flee_below_hp > 0 and hp <= flee_below_hp
var stop_at := attack_range * hold_distance
if fleeing:
if dist > 0.05:
global_position -= to_player.normalized() * move_speed * delta
global_position.y = 0.1
elif dist > stop_at:
# Close in, but stop short of the player so their longer reach matters
global_position += to_player.normalized() * move_speed * delta
global_position.y = 0.1
if not fleeing and dist <= attack_range and _attack_cd <= 0.0:
_do_attack()
func _do_attack() -> void:
_attack_cd = attack_cooldown
AudioDirector.play_hit()
if instakill:
GameState.apply_shock(10.0)
GameState.die(death_cause_key)
return
if glitzo_mode:
GameState.register_glitzo_hit()
return
GameState.take_damage(attack_damage, death_cause_key)
func take_hit(amount: int = 1) -> void:
if not _alive:
return
if invulnerable:
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_urhahn", 2.0)
return
hp = max(0, hp - amount)
hit.emit(hp)
AudioDirector.play_hit()
if hp <= 0:
_alive = false
ai_enabled = false
if defeat_flag != "":
GameState.set_flag(defeat_flag, true)
if achievement_on_defeat != "":
Achievements.unlock(achievement_on_defeat)
defeated.emit()
queue_free()
call_deferred("queue_free")
func on_interact(_player: Node) -> void:
take_hit(1)
if invulnerable:
_do_attack()
else:
take_hit(1)
func get_interact_prompt() -> String:
if invulnerable:
return Locale.tr_key("prompt_attack")
return Locale.tr_key("prompt_attack")
func set_interaction_highlight(enabled: bool, color: Color = Color(1.5, 0.45, 0.35)) -> void:
if _billboard and _billboard.has_method("set_highlight"):
_billboard.set_highlight(enabled, color)

View file

@ -1,38 +1,80 @@
@tool
extends Node3D
class_name InteractableNPC
## Billboard NPC with dialog key and optional quest hook.
## @tool: applies texture/height in the editor so NPCs are WYSIWYG (no quest logic).
signal interacted(npc: InteractableNPC)
@export var dialog_key: String = "dialog_hongkong_npc"
@export var texture_path: String = "res://assets/sprites/placeholders/npc.png"
@export var texture_path: String = "res://assets/sprites/placeholders/npc.png":
set(v):
texture_path = v
if Engine.is_editor_hint() and is_node_ready():
_apply_billboard_look()
@export var billboard_height: float = 1.74:
set(v):
billboard_height = v
if Engine.is_editor_hint() and is_node_ready():
_apply_billboard_look()
@export var billboard_max_width: float = 2.2:
set(v):
billboard_max_width = v
if Engine.is_editor_hint() and is_node_ready():
_apply_billboard_look()
@export var can_interact: bool = true ## false = deco only (e.g. village house)
@export var give_item_id: String = ""
@export var give_once: bool = false ## Don't stack if player already has the item
@export var despawn_after_give: bool = false ## Remove world pickup after taking
@export var require_item_id: String = ""
@export var consume_required: bool = false
@export var quest_flag: String = ""
@export var achievement_id: String = ""
@export var on_success_goto: String = ""
@export var prompt_key: String = "" ## Optional override, e.g. prompt_press_button
@onready var _billboard: BillboardSprite = $BillboardSprite
func _ready() -> void:
add_to_group("interactable")
if _billboard:
_apply_billboard_look()
if Engine.is_editor_hint():
return
if can_interact:
add_to_group("interactable")
func _apply_billboard_look() -> void:
if _billboard == null:
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
if _billboard == null:
return
_billboard.max_width = billboard_max_width
_billboard.set_target_height(billboard_height)
if texture_path != "" and ResourceLoader.exists(texture_path):
var tex: Texture2D = load(texture_path)
if tex:
_billboard.set_texture(tex)
func on_interact(_player: Node) -> void:
if Engine.is_editor_hint() or not can_interact:
return
interacted.emit(self)
# World buttons / portals: go immediately (optional short bark via dialog_key)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key(dialog_key))
if dialog_key != "" and on_success_goto == "":
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key(dialog_key)
elif ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key(dialog_key), 3.5, dialog_key)
if require_item_id != "" and not Inventory.has_item(require_item_id):
return
if require_item_id != "" and consume_required:
Inventory.remove_item(require_item_id, 1)
if give_item_id != "":
Inventory.add_item(give_item_id, 1)
if not (give_once and Inventory.has_item(give_item_id)):
Inventory.add_item(give_item_id, 1)
if despawn_after_give:
call_deferred("queue_free")
if quest_flag != "":
GameState.set_flag(quest_flag, true)
if quest_flag == "house_built":
@ -40,4 +82,32 @@ func on_interact(_player: Node) -> void:
if achievement_id != "":
Achievements.unlock(achievement_id)
if on_success_goto != "":
# Optional one-liner before leave (non-blocking bark if UI supports it)
if dialog_key != "" and ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key(dialog_key, 1.2, false)
GameState.goto_scene(on_success_goto)
func get_interact_prompt() -> String:
if prompt_key != "":
return Locale.tr_key(prompt_key)
if texture_path.ends_with("button.png"):
return Locale.tr_key("prompt_press_button")
if texture_path.ends_with("tiramisu.png"):
return Locale.tr_key("prompt_eat_tiramisu")
if give_item_id != "":
var item_name := Locale.tr_key("item_" + give_item_id)
return "%s — %s" % [Locale.tr_key("prompt_pick_up"), item_name]
if on_success_goto != "":
return Locale.tr_key("prompt_enter")
if quest_flag == "house_built":
return Locale.tr_key("prompt_build")
if dialog_key != "":
var label := Locale.tr_key(dialog_key)
if label.length() > 42:
label = label.substr(0, 40) + "…"
return "%s — %s" % [Locale.tr_key("prompt_talk"), label]
return Locale.tr_key("prompt_use")
func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void:
if _billboard and _billboard.has_method("set_highlight"):
_billboard.set_highlight(enabled, color)

View file

@ -0,0 +1,334 @@
@tool
extends Node3D
class_name ModelProp
## 3D GLB/prop with optional interact. Auto-fits wild asset scales via fit_height.
## When use_scale_catalog is on, data/model_scales.json overrides fit_* for this path.
## @tool: loads + fits the GLB in the editor viewport (does not own/save the mesh child).
## Optional StaticBody3D box from fitted AABB (layer 1 = Ground) — runtime only.
signal interacted(prop: ModelProp)
@export_file("*.glb", "*.gltf") var model_path: String = "":
set(v):
if model_path == v:
return
model_path = v
if Engine.is_editor_hint() and is_node_ready():
respawn_model()
## Extra multiplier after fit (usually 1,1,1). Used for slight per-slot variation.
@export var model_scale: Vector3 = Vector3.ONE:
set(v):
model_scale = v
if Engine.is_editor_hint() and is_node_ready() and _model != null:
_apply_fit()
@export var model_rotation_degrees: Vector3 = Vector3.ZERO:
set(v):
model_rotation_degrees = v
if _model != null:
_model.rotation_degrees = model_rotation_degrees
if Engine.is_editor_hint() and is_node_ready():
_apply_fit()
## Target AABB height in meters. 0 = use model_scale only (legacy).
@export var fit_height: float = 0.0:
set(v):
fit_height = v
if Engine.is_editor_hint() and is_node_ready() and _model != null:
_apply_fit()
## Cap horizontal size after height-fit (0 = no cap).
@export var fit_max_width: float = 0.0:
set(v):
fit_max_width = v
if Engine.is_editor_hint() and is_node_ready() and _model != null:
_apply_fit()
## Sit AABB bottom on this node's y=0.
@export var ground_align: bool = true:
set(v):
ground_align = v
if Engine.is_editor_hint() and is_node_ready() and _model != null:
_apply_fit()
## Apply SpriteFit catalog entry for model_path (scale lab SSOT).
@export var use_scale_catalog: bool = true:
set(v):
use_scale_catalog = v
if Engine.is_editor_hint() and is_node_ready() and _model != null:
_apply_fit()
## Add StaticBody3D + BoxShape3D from fitted AABB (houses on; animals/interior often off).
@export var add_collision: bool = true:
set(v):
add_collision = v
if is_node_ready():
_rebuild_collision()
## Shrink each side of the collision box (meters). Keeps a little approach room for interact.
@export var collision_inset: float = 0.15:
set(v):
collision_inset = v
if is_node_ready():
_rebuild_collision()
@export var can_interact: bool = false
@export var prompt_key: String = ""
@export var dialog_key: String = ""
@export var on_success_goto: String = ""
var _model: Node3D
var _highlight_meshes: Array[MeshInstance3D] = []
var _highlight_mat: StandardMaterial3D
var _collision_body: StaticBody3D
## Fitted mesh AABB in prop-local space (after fit / ground align).
var _local_aabb: AABB = AABB()
var _has_local_aabb: bool = false
func _ready() -> void:
_highlight_mat = StandardMaterial3D.new()
_highlight_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_highlight_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_highlight_mat.albedo_color = Color(1.4, 1.35, 0.4, 0.35)
## Runtime only: mirror catalog into exports for play-mode inspection.
## Never do this in editor — would dirty/save fit_* into the scene.
if not Engine.is_editor_hint():
_apply_catalog_overrides()
_spawn_model()
if Engine.is_editor_hint():
return
if can_interact:
add_to_group("interactable")
func _apply_catalog_overrides() -> void:
if not use_scale_catalog or model_path == "":
return
var entry := SpriteFit.scale_entry(model_path)
if entry.is_empty():
return
if entry.has("fit_height"):
fit_height = float(entry["fit_height"])
if entry.has("fit_max_width"):
fit_max_width = float(entry["fit_max_width"])
func _resolved_fit_height() -> float:
if use_scale_catalog and model_path != "":
var entry := SpriteFit.scale_entry(model_path)
if entry.has("fit_height"):
return float(entry["fit_height"])
return fit_height
func _resolved_fit_max_width() -> float:
if use_scale_catalog and model_path != "":
var entry := SpriteFit.scale_entry(model_path)
if entry.has("fit_max_width"):
return float(entry["fit_max_width"])
return fit_max_width
func set_fit_params(height_m: float, max_width_m: float, reapply: bool = true) -> void:
fit_height = height_m
fit_max_width = max_width_m
if reapply:
_apply_fit()
func respawn_model() -> void:
_clear_model()
if not Engine.is_editor_hint():
_apply_catalog_overrides()
_spawn_model()
func _clear_model() -> void:
_clear_collision()
if is_instance_valid(_model):
remove_child(_model)
_model.free()
_model = null
_highlight_meshes.clear()
_has_local_aabb = false
func _spawn_model() -> void:
_clear_model()
if model_path == "" or not ResourceLoader.exists(model_path):
if model_path != "":
push_warning("ModelProp missing model: %s" % model_path)
return
var packed: PackedScene = load(model_path)
if packed == null:
return
_model = packed.instantiate() as Node3D
if _model == null:
return
_model.rotation_degrees = model_rotation_degrees
_model.scale = Vector3.ONE
_model.position = Vector3.ZERO
add_child(_model)
## Preview mesh must not be saved into the edited scene.
_model.owner = null
_collect_meshes(_model)
_apply_fit()
func _collect_meshes(n: Node) -> void:
if n is MeshInstance3D:
_highlight_meshes.append(n as MeshInstance3D)
for c in n.get_children():
_collect_meshes(c)
func _xform_to_self(n: Node3D) -> Transform3D:
## Local transform chain — avoids get_tree()/global_transform when not needed.
var xf := Transform3D.IDENTITY
var cur: Node = n
while cur != null and cur != self:
if cur is Node3D:
xf = (cur as Node3D).transform * xf
cur = cur.get_parent()
return xf
func _aabb_in_self_space() -> AABB:
var merged := AABB()
var first := true
for mi in _highlight_meshes:
if not is_instance_valid(mi) or mi.mesh == null:
continue
var piece: AABB = _xform_to_self(mi) * mi.get_aabb()
if first:
merged = piece
first = false
else:
merged = merged.merge(piece)
return merged
func _apply_fit() -> void:
if _model == null or _highlight_meshes.is_empty():
return
# Measure unscaled (rotation already applied).
_model.scale = Vector3.ONE
_model.position = Vector3.ZERO
var aabb := _aabb_in_self_space()
var size := aabb.size
## Prefer Y as height; if the mesh is sideways (Y << longest axis), use longest.
var measure := size.y
var longest := maxf(size.x, maxf(size.y, size.z))
if measure <= 0.0001 or measure < longest * 0.55:
measure = longest
if measure <= 0.0001:
_model.scale = model_scale
_cache_local_aabb()
_rebuild_collision()
return
var s := 1.0
var use_h := _resolved_fit_height()
var use_w := _resolved_fit_max_width()
if use_h > 0.0:
s = use_h / measure
if use_w > 0.0:
var horiz := maxf(size.x, size.z) * s
## Also cap the longest axis so sideways models cannot become giants.
var long_after := longest * s
if horiz > use_w:
s *= use_w / horiz
long_after = longest * s
if long_after > use_h * 1.35 and use_h > 0.0:
s *= (use_h * 1.35) / maxf(long_after, 0.0001)
_model.scale = model_scale * s
if ground_align:
aabb = _aabb_in_self_space()
_model.position.y -= aabb.position.y
_cache_local_aabb()
_rebuild_collision()
func _cache_local_aabb() -> void:
if _highlight_meshes.is_empty():
_has_local_aabb = false
return
_local_aabb = _aabb_in_self_space()
_has_local_aabb = _local_aabb.size.length() > 0.0001
func _clear_collision() -> void:
if is_instance_valid(_collision_body):
remove_child(_collision_body)
_collision_body.free()
_collision_body = null
func _rebuild_collision() -> void:
_clear_collision()
## Runtime only — avoid editor physics / dirtying scenes with collision children.
if Engine.is_editor_hint() or not add_collision or not _has_local_aabb:
return
var aabb := _local_aabb
var inset := maxf(collision_inset, 0.0)
var size := Vector3(
maxf(aabb.size.x - inset * 2.0, 0.05),
maxf(aabb.size.y - inset, 0.05),
maxf(aabb.size.z - inset * 2.0, 0.05)
)
## Keep box sitting on ground (y=0): only inset sides + top, not bottom.
var center := Vector3(
aabb.position.x + aabb.size.x * 0.5,
size.y * 0.5,
aabb.position.z + aabb.size.z * 0.5
)
var body := StaticBody3D.new()
body.name = "Collision"
body.collision_layer = 1 ## same as Ground — player mask is 1
body.collision_mask = 0
var shape_node := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
shape_node.shape = box
shape_node.position = center
body.add_child(shape_node)
add_child(body)
body.owner = null
_collision_body = body
func get_interact_distance(from_global: Vector3) -> float:
## Distance to nearest point on fitted AABB so large house boxes stay reachable.
if not _has_local_aabb:
return global_position.distance_to(from_global)
var local := to_local(from_global)
var min_p := _local_aabb.position
var max_p := _local_aabb.position + _local_aabb.size
var closest := Vector3(
clampf(local.x, min_p.x, max_p.x),
clampf(local.y, min_p.y, max_p.y),
clampf(local.z, min_p.z, max_p.z)
)
return from_global.distance_to(to_global(closest))
func on_interact(_player: Node) -> void:
if Engine.is_editor_hint() or not can_interact:
return
interacted.emit(self)
var ui := get_node_or_null("/root/UIRoot")
if dialog_key != "" and on_success_goto == "":
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key(dialog_key)
if on_success_goto != "":
if dialog_key != "" and ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key(dialog_key, 1.0, false)
GameState.goto_scene(on_success_goto)
func get_interact_prompt() -> String:
if prompt_key != "":
return Locale.tr_key(prompt_key)
if on_success_goto != "":
return Locale.tr_key("prompt_press_button")
return Locale.tr_key("prompt_use")
func set_interaction_highlight(enabled: bool, color: Color = Color(1.4, 1.35, 0.4)) -> void:
## MeshInstance3D has no modulate — use material_overlay tint instead.
if Engine.is_editor_hint() or _highlight_mat == null:
return
_highlight_mat.albedo_color = Color(color.r, color.g, color.b, 0.35)
for mi in _highlight_meshes:
if not is_instance_valid(mi):
continue
mi.material_overlay = _highlight_mat if enabled else null
func _get_configuration_warnings() -> PackedStringArray:
var warnings: PackedStringArray = []
if model_path == "":
warnings.append("ModelProp: set model_path to a .glb/.gltf")
elif not ResourceLoader.exists(model_path):
warnings.append("ModelProp: model_path does not exist: %s" % model_path)
return warnings

View file

@ -0,0 +1,40 @@
extends Area3D
class_name QuirxelProjectile
## Flying pixel square that seeks / shoots toward the player (final boss).
var velocity: Vector3 = Vector3.ZERO
var lifetime: float = 6.0
var hp: int = 1
func _ready() -> void:
collision_layer = 4
collision_mask = 2
monitoring = true
body_entered.connect(_on_body)
add_to_group("enemy")
add_to_group("quirxel")
var bill := get_node_or_null("BillboardSprite")
if bill:
if bill.has_method("set_target_height"):
bill.set_target_height(SpriteFit.WORLD_PROP_HEIGHT * 0.45)
if bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/quirxel.png")
if tex:
bill.set_texture(tex)
func _physics_process(delta: float) -> void:
global_position += velocity * delta
lifetime -= delta
if lifetime <= 0.0:
call_deferred("queue_free")
func take_hit(_amount: int = 1) -> void:
hp -= 1
AudioDirector.play_hit()
if hp <= 0:
call_deferred("queue_free")
func _on_body(body: Node3D) -> void:
if body.is_in_group("player"):
GameState.take_damage(1, "death_quirxel")
call_deferred("queue_free")

View file

@ -0,0 +1,48 @@
extends Area3D
class_name ShoeProjectile
## Thrown shoe from Schuhmacher Reif. Hit = damage / death_shoe.
var velocity: Vector3 = Vector3.ZERO
var lifetime: float = 4.5
var hp: int = 1
var damage: int = 1
func _ready() -> void:
collision_layer = 4
collision_mask = 2
monitoring = true
body_entered.connect(_on_body)
add_to_group("enemy")
add_to_group("shoe_projectile")
var bill := get_node_or_null("BillboardSprite")
if bill:
if bill.has_method("set_target_height"):
bill.set_target_height(SpriteFit.WORLD_PROP_HEIGHT * 0.75)
if bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/shoe.png")
if tex:
bill.set_texture(tex)
func _physics_process(delta: float) -> void:
# Slight arc
velocity.y -= 6.0 * delta
global_position += velocity * delta
if global_position.y < 0.15:
global_position.y = 0.15
velocity.y = absf(velocity.y) * 0.25
velocity.x *= 0.85
velocity.z *= 0.85
lifetime -= delta
if lifetime <= 0.0:
call_deferred("queue_free")
func take_hit(_amount: int = 1) -> void:
hp -= 1
AudioDirector.play_hit()
if hp <= 0:
call_deferred("queue_free")
func _on_body(body: Node3D) -> void:
if body.is_in_group("player"):
GameState.take_damage(damage, "death_shoe")
call_deferred("queue_free")

472
scripts/dev/scale_lab.gd Normal file
View 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")

View file

@ -11,11 +11,17 @@ func _ready() -> void:
label.text = Locale.tr_key("dialog_catscene")
skip_btn.text = "Skip Katzin / Catscene"
cont_btn.text = Locale.tr_key("menu_continue")
AudioDirector.play_dialog_line("dialog_catscene")
func _on_continue_pressed() -> void:
GameState.goto_scene("farm_path")
func _on_skip_pressed() -> void:
label.text = Locale.tr_key("dialog_catscene_skip")
await get_tree().create_timer(2.0).timeout
AudioDirector.play_dialog_line("dialog_catscene_skip")
var tree := get_tree()
if tree == null:
tree = Engine.get_main_loop() as SceneTree
if tree:
await tree.create_timer(maxf(2.0, AudioDirector.last_voice_length()), true).timeout
GameState.reset_to_intro()

View file

@ -7,7 +7,7 @@ extends Control
@onready var btn_no: Button = $NoButton
@onready var btn_yes: Button = $YesButton
@onready var hint: Label = $Hint
@onready var skull: ColorRect = $Skull
@onready var skull: TextureRect = $Skull
@onready var bg: ColorRect = $Bg
var _blink: float = 0.0
@ -26,6 +26,7 @@ func _ready() -> void:
_refresh()
Locale.locale_changed.connect(_refresh)
skull.visible = false
SpriteFit.apply_texture_rect_budget(skull, SpriteFit.UI_POPUP_PX)
func _refresh() -> void:
title.text = Locale.tr_key("death_title")
@ -40,11 +41,13 @@ func _process(delta: float) -> void:
var idx := int(_blink) % _colors.size()
bg.color = _colors[idx]
_skull_timer += delta
if _skull_timer >= 10.0:
_skull_timer = 0.0
if skull.visible:
if _skull_timer >= 1.0:
skull.visible = false
_skull_timer = 0.0
elif _skull_timer >= 10.0:
skull.visible = true
await get_tree().create_timer(1.0).timeout
skull.visible = false
_skull_timer = 0.0
func _on_revive_pressed() -> void:
GameState.revive_continue()

View file

@ -1,34 +1,63 @@
extends Node3D
## Dresslermeister: Augen öffnen, Mähdrescher hijacken, Apfel dreschen.
## Dresslermeister: Augen auf, Mähdrescher hijacken, Apfel dreschen (DESIGN.md §10).
var _hijacked: int = 0
var _eyes_open: bool = false
func _ready() -> void:
GameState.current_scene_id = "dressler_boss"
GameState.heal_full()
var dressler := get_node_or_null("Dressler")
if dressler:
dressler.set("ai_enabled", false) # sleeps until eyes open
dressler.set("max_hp", 18)
dressler.set("hp", 18)
dressler.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
dressler.set("death_cause_key", "death_mower")
if dressler.has_signal("defeated"):
dressler.defeated.connect(_on_dressler_defeated)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_dressler"))
var dressler := get_node_or_null("Dressler")
if dressler and dressler.has_signal("defeated"):
dressler.defeated.connect(_on_dressler_defeated)
# Convert mower hazards into hijackables
for name in ["Mower1", "Mower2"]:
ui.show_dialog("Ein riesiger Apfel auf dem Feld…", 2.5)
for name in ["Mower1", "Mower2", "Mower3"]:
var m := get_node_or_null(name)
if m:
_make_hijackable(m)
func _make_hijackable(mower: Node) -> void:
mower.set_meta("hijacked", false)
if mower.has_signal("body_entered"):
# Keep death if not hijacked — wrap
pass
# Add interactable proxy as child Area isn't enough; use nearby attack
mower.add_to_group("hijackable_mower")
func _physics_process(_delta: float) -> void:
func _physics_process(delta: float) -> void:
_try_open_eyes()
if Input.is_action_just_pressed("interact") or Input.is_action_just_pressed("attack"):
_try_hijack()
# Moving mowers slowly toward player if not hijacked
var player := get_tree().get_first_node_in_group("player")
if player is Node3D:
for mower in get_tree().get_nodes_in_group("hijackable_mower"):
if mower is Node3D and not bool(mower.get_meta("hijacked", false)):
var to_p: Vector3 = (player as Node3D).global_position - mower.global_position
to_p.y = 0.0
if to_p.length() > 0.2:
mower.global_position += to_p.normalized() * 1.6 * delta
func _try_open_eyes() -> void:
if _eyes_open:
return
var player := get_tree().get_first_node_in_group("player")
var dressler := get_node_or_null("Dressler")
if player is Node3D and dressler is Node3D:
if player.global_position.distance_to(dressler.global_position) < 6.0:
_eyes_open = true
dressler.set("ai_enabled", true)
dressler.set("move_speed", 1.2)
dressler.set("attack_damage", 1)
dressler.set("shout_key", "dialog_dressler")
dressler.set("shout_interval", 6.0)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_dressler", 3.0)
func _make_hijackable(mower: Node) -> void:
mower.set_meta("hijacked", false)
mower.add_to_group("hijackable_mower")
func _try_hijack() -> void:
var player := get_tree().get_first_node_in_group("player")
@ -40,18 +69,14 @@ func _try_hijack() -> void:
continue
mower.set_meta("hijacked", true)
_hijacked += 1
# Disable kill: remove from monitoring / free hazard script effect
if mower.has_method("set"):
mower.set("monitoring", false)
if mower is Area3D:
(mower as Area3D).monitoring = false
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog("Fahrer rausgeworfen! Dresche den Apfel! (%d/2)" % _hijacked)
# Bonus damage to dressler for each hijack
ui.show_dialog("Fahrer rausgeworfen! Dresche den Apfel! (%d)" % _hijacked)
var dressler := get_node_or_null("Dressler")
if dressler and dressler.has_method("take_hit"):
dressler.take_hit(3)
dressler.take_hit(4)
return
func _on_dressler_defeated() -> void:
@ -59,5 +84,5 @@ func _on_dressler_defeated() -> void:
Inventory.add_item("dressler_apple", 1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_dressler_drop"), 4.0)
ui.show_dialog_key("dialog_dressler_drop", 4.0)
GameState.set_flag("dressler_defeated", true)

View file

@ -6,16 +6,33 @@ func _ready() -> void:
GameState.heal_full()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_end_angry"), 4.0)
ui.show_dialog_key("dialog_end_angry", 4.0)
var house := get_node_or_null("HouseSpot")
if house and house.has_signal("interacted"):
house.interacted.connect(_on_house)
var dentist := get_node_or_null("DentistHeist")
if dentist and dentist.has_signal("interacted"):
dentist.interacted.connect(_on_heist)
# Plot marker stays small until built — don't show full house twice
_setup_plot_marker()
if GameState.house_built or GameState.get_flag("house_built", false):
_show_house()
func _setup_plot_marker() -> void:
var spot := get_node_or_null("HouseSpot")
if spot == null:
return
if GameState.house_built or GameState.get_flag("house_built", false):
return
# Before build: small plot sign, not a second full-size house
spot.set("billboard_height", 1.0)
spot.set("billboard_max_width", 1.5)
var bill = spot.get_node_or_null("BillboardSprite")
if bill:
bill.set("max_width", 1.5)
if bill.has_method("set_target_height"):
bill.set_target_height(1.0)
func _on_house(_n: Node = null) -> void:
GameState.house_built = true
GameState.set_flag("house_built", true)
@ -25,21 +42,27 @@ func _on_house(_n: Node = null) -> void:
ui.show_dialog(Locale.tr_key("build_house"))
func _show_house() -> void:
var house := get_node_or_null("BuiltHouse")
if house:
house.visible = true
## One house only: grow HouseSpot to full size (no duplicate BuiltHouse).
var spot := get_node_or_null("HouseSpot")
if spot == null:
return
var scene: PackedScene = load("res://scenes/prefabs/npc.tscn")
if scene == null:
spot.set("billboard_height", SpriteFit.WORLD_HOUSE_HEIGHT)
spot.set("billboard_max_width", SpriteFit.WORLD_HOUSE_MAX_WIDTH)
spot.set("texture_path", "res://assets/sprites/placeholders/house.png")
_fit_house_billboard(spot)
# Remove legacy duplicate if any
var dup := get_node_or_null("BuiltHouse")
if dup:
dup.queue_free()
func _fit_house_billboard(node: Node) -> void:
var bill = node.get_node_or_null("BillboardSprite")
if bill == null:
return
var h: Node3D = scene.instantiate()
h.name = "BuiltHouse"
add_child(h)
h.global_position = Vector3(3, 0.1, -2)
h.set("texture_path", "res://assets/sprites/placeholders/house.png")
h.set("dialog_key", "build_house")
var bill = h.get_node_or_null("BillboardSprite")
if bill and bill.has_method("set_texture"):
bill.set("max_width", SpriteFit.WORLD_HOUSE_MAX_WIDTH)
if bill.has_method("set_target_height"):
bill.set_target_height(SpriteFit.WORLD_HOUSE_HEIGHT)
if bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/house.png")
if tex:
bill.set_texture(tex)
@ -49,4 +72,4 @@ func _on_heist(_n: Node = null) -> void:
Achievements.unlock("tooth_millionaire")
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_toothionaire"), 4.0)
ui.show_dialog_key("dialog_toothionaire", 4.0)

View file

@ -15,18 +15,29 @@ func _ready() -> void:
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
if mood == "detour":
ui.show_dialog(Locale.tr_key("dialog_farmer_detour"), 4.0)
ui.show_dialog_key("dialog_farmer_detour", 4.0)
else:
ui.show_dialog(Locale.tr_key("dialog_farmer_direct"), 3.5)
ui.show_dialog_key("dialog_farmer_direct", 3.5)
await get_tree().create_timer(1.2).timeout
ui.show_dialog(Locale.tr_key("dialog_farmer_quest"), 4.0)
ui.show_dialog_key("dialog_farmer_quest", 4.0)
var urhahn := get_node_or_null("Urhahn")
if urhahn and urhahn.has_signal("defeated"):
urhahn.defeated.connect(_on_urhahn_down)
if urhahn:
urhahn.set("ai_enabled", true)
urhahn.set("invulnerable", true)
urhahn.set("instakill", true)
urhahn.set("move_speed", 4.2)
urhahn.set("attack_cooldown", 0.85)
urhahn.set("death_cause_key", "death_rooster")
urhahn.set("shout_key", "dialog_urhahn")
urhahn.set("shout_interval", 5.0)
urhahn.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
if urhahn.has_signal("defeated"):
urhahn.defeated.connect(_on_urhahn_down)
var hazard := get_node_or_null("RoosterHazard")
if hazard and hazard.has_signal("body_entered"):
# Extra: shock before death for flavor when contacting hazard
if not hazard.body_entered.is_connected(_on_rooster_touch):
if hazard:
hazard.set("damage", 99)
hazard.set("death_cause_key", "death_rooster")
if hazard.has_signal("body_entered") and not hazard.body_entered.is_connected(_on_rooster_touch):
hazard.body_entered.connect(_on_rooster_touch)
var exit := get_node_or_null("Exit")
if exit:
@ -63,7 +74,7 @@ func _check_quests() -> void:
GameState.set_flag("farm_quests_done", true)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_paywall"), 4.0)
ui.show_dialog_key("dialog_paywall", 4.0)
Shop.purchase("continue_joke")
GameState.set_flag("paywall_cleared", true)

View file

@ -43,10 +43,10 @@ func _on_right_body(body: Node3D) -> void:
GameState.set_flag("mage_angered", true)
GameState.set_flag("planet_destroyed", true)
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_mage_angry"), 4.0)
ui.show_dialog_key("dialog_mage_angry", 4.0)
await get_tree().create_timer(2.0).timeout
GameState.goto_scene("moon")
else:
GameState.set_flag("called_black_mage", true)
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_mage_push"))
ui.show_dialog_key("dialog_mage_push")

View file

@ -1,11 +1,12 @@
extends Node3D
## Glitschkönig: Reveal, Quirxels, Split-Phasen, Flucht des letzten Pixels.
## Glitschkönig: Reveal, Quirxel-Schüsse, Split, Flucht (DESIGN.md §12).
enum Phase { FULL, SPLIT, FLEE, DONE }
var _phase: Phase = Phase.FULL
var _revealed: bool = false
var _splits_left: int = 2
var _splits_done: int = 0
const SPLITS_MAX := 2
func _ready() -> void:
GameState.current_scene_id = "final_boss"
@ -15,13 +16,22 @@ func _ready() -> void:
_do_reveal()
var boss := get_node_or_null("Hongkong")
if boss:
boss.set("max_hp", 12)
boss.set("max_hp", 16)
boss.set("hp", 16)
boss.set("ai_enabled", true)
boss.set("move_speed", 1.8)
boss.set("attack_damage", 1)
boss.set("attack_cooldown", 1.4)
boss.set("death_cause_key", "death_default")
boss.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
boss.set("shout_key", "dialog_hongkong_boss")
boss.set("shout_interval", 8.0)
if boss.has_signal("hit"):
boss.hit.connect(_on_boss_hit)
if boss.has_signal("defeated"):
boss.defeated.connect(_on_boss_defeated)
var t := Timer.new()
t.wait_time = 2.8
t.wait_time = 2.2
t.autostart = true
t.timeout.connect(_spawn_quirxel)
add_child(t)
@ -32,51 +42,65 @@ func _do_reveal() -> void:
Inventory.add_item("wartchen", 1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_reveal"), 5.5)
ui.show_dialog_key("dialog_reveal", 5.5)
await get_tree().create_timer(2.0).timeout
ui.show_dialog_key("dialog_boss_unclever", 3.5)
func _on_boss_hit(remaining: int) -> void:
if _phase == Phase.FULL and remaining <= 6 and _splits_left > 0:
var boss := get_node_or_null("Hongkong")
if remaining <= 10 and _splits_done < SPLITS_MAX and _phase != Phase.FLEE:
_phase = Phase.SPLIT
_splits_left -= 1
_splits_done += 1
_spawn_split_clone()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog("Er teilt sich wie Minecraft-Schleim!", 2.5)
elif remaining <= 2 and _phase != Phase.FLEE and _phase != Phase.DONE:
if remaining <= 3 and _phase != Phase.DONE:
_phase = Phase.FLEE
var boss := get_node_or_null("Hongkong")
if boss is Node3D:
boss.global_position += Vector3(randf_range(-4, 4), 0, -3)
if boss:
boss.set("flee_below_hp", 99)
boss.set("move_speed", 5.5)
boss.set("ai_enabled", true)
func _spawn_split_clone() -> void:
var scene: PackedScene = load("res://scenes/prefabs/enemy.tscn")
if scene == null:
return
var c: Node3D = scene.instantiate()
c.name = "GlitschSplit"
c.name = "GlitschSplit_%d" % _splits_done
add_child(c)
c.global_position = Vector3(randf_range(-3, 3), 0.1, -2)
c.set("max_hp", 4)
c.set("texture_path", "res://assets/sprites/placeholders/hongkong.png")
c.global_position = Vector3(randf_range(-4, 4), 0.1, -2)
c.set("max_hp", 5)
c.set("texture_path", "res://assets/sprites/placeholders/glitschkoenig.png")
c.set("ai_enabled", true)
c.set("move_speed", 3.2)
c.set("attack_damage", 1)
c.set("billboard_height", 1.2)
c.set("death_cause_key", "death_quirxel")
var bill = c.get_node_or_null("BillboardSprite")
if bill and bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/hongkong.png")
var tex: Texture2D = load("res://assets/sprites/placeholders/glitschkoenig.png")
if tex:
bill.set_texture(tex)
func _spawn_quirxel() -> void:
if _phase == Phase.DONE or GameState.get_flag("hongkong_defeated", false):
return
var scene: PackedScene = load("res://scenes/prefabs/hazard.tscn")
var scene: PackedScene = load("res://scenes/prefabs/quirxel_projectile.tscn")
if scene == null:
return
var h: Node3D = scene.instantiate()
add_child(h)
h.global_position = Vector3(randf_range(-6, 6), 0.1, randf_range(-6, 0))
h.set("death_cause_key", "death_quirxel")
await get_tree().create_timer(5.0).timeout
if is_instance_valid(h):
h.queue_free()
var player := get_tree().get_first_node_in_group("player")
var boss := get_node_or_null("Hongkong")
var origin := Vector3(0, 0.8, -2)
if boss is Node3D:
origin = (boss as Node3D).global_position + Vector3(0, 0.9, 0)
var q: Node3D = scene.instantiate()
add_child(q)
q.global_position = origin + Vector3(randf_range(-1.5, 1.5), 0, randf_range(-1, 1))
var dir := Vector3(0, 0, 1)
if player is Node3D:
dir = ((player as Node3D).global_position + Vector3(0, 0.8, 0) - q.global_position).normalized()
q.set("velocity", dir * 7.5)
func _on_boss_defeated() -> void:
_phase = Phase.DONE
@ -84,8 +108,8 @@ func _on_boss_defeated() -> void:
GameState.set_flag("hongkong_defeated", true)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_hongkong_boss"), 3.0)
ui.show_dialog_key("dialog_hongkong_boss", 3.0)
await get_tree().create_timer(2.5).timeout
ui.show_dialog(Locale.tr_key("dialog_bear_end"), 5.0)
ui.show_dialog_key("dialog_bear_end", 5.0)
await get_tree().create_timer(3.5).timeout
GameState.goto_scene("end_screen")

View file

@ -1,7 +1,13 @@
extends Control
## Intro: Tisch, Konsole, Sprecher, Explosion.
## Intro: Tisch, Konsole, Sprecher, Explosion — Platzhalter unter assets/sprites/placeholders/.
const TEX_CONSOLE := preload("res://assets/sprites/placeholders/console.png")
const TEX_CONSOLE_BOOM := preload("res://assets/sprites/placeholders/console_exploded.png")
@onready var label: Label = $Label
@onready var console: TextureRect = $Console
@onready var boom_flash: TextureRect = $BoomFlash
@onready var speaker: TextureRect = $Speaker
var _step: int = 0
@ -9,22 +15,57 @@ func _ready() -> void:
GameState.current_scene_id = "intro_cutscene"
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
AudioDirector.stop_music()
# Decorative full-screen nodes must not eat clicks meant to advance.
mouse_filter = Control.MOUSE_FILTER_STOP
for child_name in ["Bg", "Desk", "Console", "Speaker", "BoomFlash", "Label", "Hint"]:
var n := get_node_or_null(child_name) as Control
if n:
n.mouse_filter = Control.MOUSE_FILTER_IGNORE
console.texture = TEX_CONSOLE
boom_flash.visible = false
_show_step()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("skip_cutscene") or event.is_action_pressed("interact") or event.is_action_pressed("attack"):
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_advance()
accept_event()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("skip_cutscene") \
or event.is_action_pressed("dialog_advance") \
or event.is_action_pressed("ui_accept") \
or event.is_action_pressed("interact") \
or event.is_action_pressed("attack"):
_advance()
get_viewport().set_input_as_handled()
func _show_step() -> void:
match _step:
0:
# Sprecher + intakte Konsole auf dem Tisch
speaker.visible = true
boom_flash.visible = false
console.texture = TEX_CONSOLE
console.modulate = Color.WHITE
label.text = Locale.tr_key("dialog_intro_1")
AudioDirector.play_dialog_line("dialog_intro_1")
1:
# Explosion
AudioDirector.play_explosion()
speaker.visible = false
boom_flash.visible = true
console.texture = TEX_CONSOLE_BOOM
console.modulate = Color(1.2, 0.7, 0.4, 1)
label.text = "BOOM"
2:
# Nachspiel: Trümmer — same confirm keys as VN (Enter/C) + intro keys
boom_flash.visible = false
console.texture = TEX_CONSOLE_BOOM
console.modulate = Color(0.7, 0.55, 0.45, 1)
label.text = Locale.tr_key("dialog_intro_2")
AudioDirector.play_dialog_line("dialog_intro_2")
_:
AudioDirector.stop_voice()
GameState.goto_scene("village")
func _advance() -> void:

View file

@ -1,53 +1,5 @@
extends Node3D
## Massage: Schlange massieren → Bear-Save; oder Secret-Milch.
@onready var choice_ui: CanvasLayer = $ChoiceUI
## Legacy scene — Masseur-Kuh lives in the village now.
func _ready() -> void:
GameState.current_scene_id = "massage_studio"
GameState.heal_full()
choice_ui.visible = false
var cow := get_node_or_null("CowMasseur")
if cow and cow.has_signal("interacted"):
cow.interacted.connect(_on_cow)
if cow:
cow.require_item_id = ""
cow.achievement_id = ""
cow.quest_flag = ""
cow.consume_required = false
cow.give_item_id = ""
func _on_cow(_npc: Node = null) -> void:
choice_ui.visible = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
$ChoiceUI/Panel/Margin/VBox/Label.text = Locale.tr_key("dialog_cow")
$ChoiceUI/Panel/Margin/VBox/MassageBtn.text = Locale.tr_key("choice_massage_now")
$ChoiceUI/Panel/Margin/VBox/DrinkBtn.text = Locale.tr_key("choice_drink_first")
func _close_choices() -> void:
choice_ui.visible = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _on_massage_pressed() -> void:
_close_choices()
if not Inventory.has_item("snake"):
var ui0 := get_node_or_null("/root/UIRoot")
if ui0 and ui0.has_method("show_dialog"):
ui0.show_dialog(Locale.tr_key("dialog_villager_snake"))
return
Inventory.remove_item("snake", 1)
GameState.set_flag("snake_massaged", true)
Achievements.unlock("snake_massage")
if not GameState.get_flag("bear_saved", false):
Inventory.add_item("tooth", 1)
GameState.set_flag("bear_saved", true)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_cow_massage") + "\n" + Locale.tr_key("dialog_bear_saved"), 5.0)
func _on_drink_pressed() -> void:
_close_choices()
Inventory.add_item("milk", 1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_cow_drink"), 4.0)
GameState.goto_scene("village")

View file

@ -1,129 +1,5 @@
extends Node3D
## Wiese: Eisbär retten (Schlangen holen → Massage ODER hier abgeben), Glitzos.
var _glitzo_timer: float = 0.0
var _bear_saved: bool = false
var _attack_cd: float = 0.0
var _first_glitzo_done: bool = false
## Legacy scene — meadow is now a zone in village overworld.
func _ready() -> void:
GameState.current_scene_id = "meadow"
GameState.heal_full()
_bear_saved = bool(GameState.get_flag("bear_saved", false))
_first_glitzo_done = Achievements.is_unlocked("first_glitzo")
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
if _bear_saved:
ui.show_dialog(Locale.tr_key("dialog_bear_saved"))
else:
ui.show_dialog(Locale.tr_key("dialog_bear_help"))
var bear := get_node_or_null("Bear")
if bear and bear.has_signal("interacted"):
bear.interacted.connect(_on_bear)
var snake := get_node_or_null("SnakePickup")
if snake and snake.has_signal("interacted"):
snake.interacted.connect(_on_snake_pickup)
# Disable mower kill if snakes already bagged? Keep hazard until massage done.
if GameState.get_flag("snake_massaged", false):
_disable_mower()
func _process(delta: float) -> void:
_glitzo_timer += delta
if _glitzo_timer >= 3.5:
_glitzo_timer = 0.0
_spawn_glitzo()
_tick_glitzo_attacks(delta)
_cull_glitzos_off_meadow()
func _tick_glitzo_attacks(delta: float) -> void:
_attack_cd = max(0.0, _attack_cd - delta)
if _attack_cd > 0.0:
return
var player := get_tree().get_first_node_in_group("player")
if player == null:
return
for child in get_children():
if not child.is_in_group("enemy"):
continue
if not str(child.get("texture_path")).ends_with("glitzo.png") and not child.name.begins_with("Glitzo"):
continue
if child is Node3D and player.global_position.distance_to(child.global_position) < 1.6:
_attack_cd = 1.15
GameState.register_glitzo_hit()
return
func _cull_glitzos_off_meadow() -> void:
## DESIGN: leave the meadow → they shed pixels and die.
for child in get_children():
if child is Node3D and (child.name.begins_with("Glitzo") or str(child.get("texture_path")).ends_with("glitzo.png")):
var p: Vector3 = child.global_position
if abs(p.x) > 18.0 or abs(p.z) > 18.0:
child.queue_free()
func _spawn_glitzo() -> void:
var scene: PackedScene = load("res://scenes/prefabs/enemy.tscn")
if scene == null:
return
var g: Node3D = scene.instantiate()
g.name = "GlitzoSpawn_%d" % Time.get_ticks_msec()
g.set("max_hp", 4)
g.set("texture_path", "res://assets/sprites/placeholders/glitzo.png")
if not _first_glitzo_done:
g.set("achievement_on_defeat", "first_glitzo")
add_child(g)
g.global_position = Vector3(randf_range(-8, 8), 0.1, randf_range(-6, 2))
var bill: Node = g.get_node_or_null("BillboardSprite")
if bill and bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/glitzo.png")
if tex:
bill.set_texture(tex)
if g.has_signal("defeated"):
g.defeated.connect(_on_glitzo_killed)
func _on_glitzo_killed() -> void:
_first_glitzo_done = true
func _on_bear(_npc: Node = null) -> void:
var ui := get_node_or_null("/root/UIRoot")
if _bear_saved:
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_bear_saved"))
return
# Need snakes rescued via inventory + preferably massaged; tooth if massaged or handing snakes in after pickup
if Inventory.has_item("snake") and GameState.get_flag("snake_massaged", false):
_save_bear()
return
if Inventory.has_item("snake"):
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_villager_snake"))
return
# First talk: point at snakes / give hint, auto-pickup one for convenience
Inventory.add_item("snake", 1)
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_snake_pain") + "\n" + Locale.tr_key("dialog_bear_help"))
func _on_snake_pickup(_npc: Node = null) -> void:
if not Inventory.has_item("snake"):
Inventory.add_item("snake", 1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_snake_pain"))
func _save_bear() -> void:
_bear_saved = true
GameState.set_flag("bear_saved", true)
if not Inventory.has_item("tooth"):
Inventory.add_item("tooth", 1)
_disable_mower()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_bear_saved"), 4.5)
func _disable_mower() -> void:
var mower := get_node_or_null("MowerHazard")
if mower:
mower.queue_free()
func notify_snake_massaged() -> void:
if not _bear_saved:
_save_bear()
GameState.goto_scene("village")

View file

@ -0,0 +1,141 @@
extends Node3D
## Wiese-Zone innerhalb der Dorf-Hauptwelt (kein eigener Scene-Teleport).
## Glitzos leben nur in meadow_aabb; außerhalb → Pixel-Sprühnebel.
@export var meadow_aabb: AABB = AABB(Vector3(20.0, -1.0, -18.0), Vector3(36.0, 8.0, 36.0))
var _glitzo_timer: float = 0.0
var _bear_saved: bool = false
var _first_glitzo_done: bool = false
func _ready() -> void:
_bear_saved = bool(GameState.get_flag("bear_saved", false))
_first_glitzo_done = Achievements.is_unlocked("first_glitzo")
var bear := get_node_or_null("Bear")
if bear and bear.has_signal("interacted"):
bear.interacted.connect(_on_bear)
var snake := get_node_or_null("SnakePickup")
if snake and snake.has_signal("interacted"):
snake.interacted.connect(_on_snake_pickup)
if Inventory.has_item("snake") or GameState.get_flag("snake_massaged", false):
if snake:
snake.queue_free()
if GameState.get_flag("snake_massaged", false):
_disable_mower()
func _process(delta: float) -> void:
## Only tick while player is near the meadow (cheap).
var player := get_tree().get_first_node_in_group("player")
if player is Node3D:
var pp: Vector3 = (player as Node3D).global_position
if not _near_meadow(pp):
_cull_glitzos_off_meadow()
return
_glitzo_timer += delta
if _glitzo_timer >= 3.2:
_glitzo_timer = 0.0
_spawn_glitzo()
_cull_glitzos_off_meadow()
func _near_meadow(p: Vector3) -> bool:
var expanded := meadow_aabb.grow(8.0)
return expanded.has_point(Vector3(p.x, 0.0, p.z))
func _cull_glitzos_off_meadow() -> void:
for child in get_children():
if child is Node3D and (child.name.begins_with("Glitzo") or str(child.get("texture_path")).ends_with("glitzo.png")):
var p: Vector3 = child.global_position
if not meadow_aabb.has_point(Vector3(p.x, 0.0, p.z)):
_pixel_spray(p)
child.queue_free()
func _pixel_spray(at: Vector3) -> void:
var scene: PackedScene = load("res://scenes/prefabs/quirxel_projectile.tscn")
if scene == null:
return
for i in range(5):
var q: Node3D = scene.instantiate()
get_parent().add_child(q)
q.global_position = at + Vector3(0, 0.4, 0)
var ang := randf() * TAU
q.set("velocity", Vector3(cos(ang), 0.5, sin(ang)) * randf_range(3.0, 6.0))
q.set("lifetime", 0.7)
q.set("hp", 99)
func _spawn_glitzo() -> void:
var scene: PackedScene = load("res://scenes/prefabs/enemy.tscn")
if scene == null:
return
var g: Node3D = scene.instantiate()
g.name = "GlitzoSpawn_%d" % Time.get_ticks_msec()
g.set("max_hp", 4)
g.set("texture_path", "res://assets/sprites/placeholders/glitzo.png")
g.set("ai_enabled", true)
g.set("glitzo_mode", true)
g.set("move_speed", 2.6)
g.set("attack_cooldown", 1.15)
g.set("attack_range", 1.1)
g.set("death_cause_key", "death_glitzo")
g.set("billboard_height", 1.0)
g.set("aggro_range", 22.0)
if not _first_glitzo_done:
g.set("achievement_on_defeat", "first_glitzo")
add_child(g)
## Spawn inside meadow AABB (local to world coords used by export).
var min_p := meadow_aabb.position
var max_p := meadow_aabb.position + meadow_aabb.size
g.global_position = Vector3(
randf_range(min_p.x + 4.0, max_p.x - 4.0),
0.1,
randf_range(min_p.z + 4.0, max_p.z - 4.0)
)
var bill: Node = g.get_node_or_null("BillboardSprite")
if bill and bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/glitzo.png")
if tex:
bill.set_texture(tex)
if g.has_signal("defeated"):
g.defeated.connect(_on_glitzo_killed)
func _on_glitzo_killed() -> void:
_first_glitzo_done = true
func _on_bear(_npc: Node = null) -> void:
var ui := get_node_or_null("/root/UIRoot")
if _bear_saved:
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_bear_saved")
return
if Inventory.has_item("snake") and GameState.get_flag("snake_massaged", false):
_save_bear()
return
if Inventory.has_item("snake"):
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_villager_snake")
return
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_bear_help")
func _on_snake_pickup(_npc: Node = null) -> void:
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_snake_picked")
func _save_bear() -> void:
_bear_saved = true
GameState.set_flag("bear_saved", true)
if not Inventory.has_item("tooth"):
Inventory.add_item("tooth", 1)
_disable_mower()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_bear_saved", 4.5)
func _disable_mower() -> void:
var mower := get_node_or_null("MowerHazard")
if mower:
mower.queue_free()
func notify_snake_massaged() -> void:
if not _bear_saved:
_save_bear()

View file

@ -40,7 +40,7 @@ func _on_shades(_n: Node = null) -> void:
Shop.purchase("sunglasses")
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_sunglasses_free"))
ui.show_dialog_key("dialog_sunglasses_free")
func _start_bowser_fight(_n: Node = null) -> void:
if GameState.get_flag("bowser_defeated", false):
@ -51,7 +51,7 @@ func _start_bowser_fight(_n: Node = null) -> void:
_hits = 0
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_bowser"))
ui.show_dialog_key("dialog_bowser")
_refresh_hammers()
func _refresh_hammers() -> void:

View file

@ -1,20 +1,5 @@
extends Node3D
## Schuhmacher Reif. Sieg → Tonne Tiramisu + Fett-Debuff.
## Legacy scene — shoe boss is now local in village overworld.
func _ready() -> void:
GameState.current_scene_id = "shoe_boss"
GameState.heal_full()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_reif"))
var reif := get_node_or_null("Reif")
if reif and reif.has_signal("defeated"):
reif.defeated.connect(_on_reif_defeated)
func _on_reif_defeated() -> void:
Inventory.add_item("tiramisu", 1000)
GameState.apply_fat(600.0)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_tiramisu"), 5.0)
GameState.set_flag("reif_defeated", true)
GameState.goto_scene("village")

View file

@ -15,17 +15,38 @@ func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
AudioDirector.burger_cham_enabled = true
AudioDirector.play_title_music()
# Lyrics belong in the sung track — not on-screen (DESIGN.md §1).
if music_hint:
music_hint.visible = false
_refresh()
Locale.locale_changed.connect(_refresh)
_spawn_rain()
_spawn_disturbed()
lang_panel.visible = Settings.needs_language_picker()
start_btn.visible = not lang_panel.visible
func _spawn_disturbed() -> void:
## Bottom-left disturbed human (DESIGN.md §1)
var s := Sprite2D.new()
s.name = "DisturbedHuman"
s.texture = load("res://assets/sprites/placeholders/disturbed.png")
s.position = Vector2(90, 640)
s.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
SpriteFit.apply_sprite2d(s, 96.0)
add_child(s)
# Hide ColorRect placeholder if present
var old := get_node_or_null("Disturbed")
if old:
old.visible = false
func _refresh() -> void:
if not is_inside_tree():
return
subtitle.text = Locale.tr_key("title_pay2win")
bubble.text = Locale.tr_key("title_bubble")
start_btn.text = Locale.tr_key("menu_start")
music_hint.text = Locale.tr_key("title_music_hint")
if music_hint:
music_hint.visible = false
$LangPanel/Margin/VBox/Title.text = Locale.tr_key("lang_pick_title")
$LangPanel/Margin/VBox/BtnDE.text = Locale.tr_key("lang_de")
$LangPanel/Margin/VBox/BtnEN.text = Locale.tr_key("lang_en")
@ -41,7 +62,8 @@ func _spawn_rain() -> void:
var s := Sprite2D.new()
s.texture = load(paths[i % paths.size()])
s.position = Vector2(randf() * 1280.0, randf() * -720.0)
s.scale = Vector2(3, 3)
# Fit longest side to ~48px — works for 16×16 and 2k burger art alike.
SpriteFit.apply_sprite2d(s, SpriteFit.UI_RAIN_PX)
s.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
rain.add_child(s)
_rain_sprites.append(s)

View file

@ -1,15 +1,83 @@
@tool
extends Node3D
## Kaputtes AC-Dorf: fliehende NPCs, Tisch-Knopf → Reif.
## Hauptwelt: Dorf + Zaun/Tor + Wiese nebeneinander. Knopf → Reif vor Ort → Tor öffnet → Wiese zu Fuß.
## @tool: editor_preview_spawns shows deterministic ModelProp stand-ins at slot positions
## (never owned/saved; runtime clears them before random spawn).
const HOUSES_DIR := "res://assets/3DModelle/houses"
const ANIMALS_DIR := "res://assets/3DModelle/animals"
const MASSEUR_HOUSE_MODEL := "res://assets/3DModelle/houses/massour/massourhaus.glb"
const COW_MODEL := "res://assets/3DModelle/animals/cow/cow.glb"
const MODEL_PROP_SCENE := "res://scenes/prefabs/model_prop.tscn"
const ENEMY_SCENE := "res://scenes/prefabs/enemy.tscn"
const HOUSE_POOL_SKIP := ["massour"]
const ANIMAL_POOL_SKIP := ["cow"] ## cow = Masseur-Kuh only
const EDITOR_PREVIEW_PREFIX := "_EditorPreview_"
## Heights from data/model_scales.json via SpriteFit (scale lab SSOT).
const HOUSE_SLOTS: Array[Dictionary] = [
{"pos": Vector3(7.0, 0.0, -6.0), "yaw": 15.0, "mul": 1.0},
{"pos": Vector3(-9.0, 0.0, -7.0), "yaw": 40.0, "mul": 0.95},
{"pos": Vector3(12.0, 0.0, -11.0), "yaw": -25.0, "mul": 0.9},
{"pos": Vector3(-4.0, 0.0, -14.0), "yaw": 85.0, "mul": 1.05},
{"pos": Vector3(14.0, 0.0, -3.0), "yaw": -50.0, "mul": 0.92},
{"pos": Vector3(-14.0, 0.0, -12.0), "yaw": 130.0, "mul": 0.88},
]
const ANIMAL_SLOTS: Array[Dictionary] = [
{"pos": Vector3(2.5, 0.0, 2.0), "yaw": 40.0, "mul": 1.0},
{"pos": Vector3(-2.0, 0.0, -1.5), "yaw": -70.0, "mul": 0.9},
{"pos": Vector3(6.5, 0.0, 0.5), "yaw": 15.0, "mul": 0.85},
{"pos": Vector3(-8.0, 0.0, 1.0), "yaw": 110.0, "mul": 1.05},
{"pos": Vector3(1.0, 0.0, -6.0), "yaw": -30.0, "mul": 1.1},
{"pos": Vector3(-4.5, 0.0, -5.5), "yaw": 200.0, "mul": 0.8},
{"pos": Vector3(9.0, 0.0, -4.0), "yaw": -100.0, "mul": 0.75},
]
## When on (editor only): spawn representative houses/animals at slot positions.
@export var editor_preview_spawns: bool = true:
set(v):
editor_preview_spawns = v
if Engine.is_editor_hint() and is_node_ready():
_editor_refresh_preview()
@onready var choice_ui: CanvasLayer = $ChoiceUI
var _flee_dirs: Dictionary = {}
var _throw_cd: float = 1.2
var _reif_alive: bool = false
var _reif_fight_active: bool = false
var _gate_open: bool = false
func _ready() -> void:
if Engine.is_editor_hint():
_editor_refresh_preview()
set_process(false)
set_physics_process(false)
return
## Drop any leftover editor stand-ins before runtime spawn (no double houses).
_free_editor_previews()
GameState.current_scene_id = "village"
GameState.heal_full()
_clear_legacy_house_nodes()
_spawn_random_houses()
_spawn_random_animals()
_spawn_masseur_house()
_spawn_cow_masseur()
_spawn_progress_house()
_setup_table_button()
_setup_reif()
_setup_gate()
if choice_ui:
choice_ui.visible = false
# Opening bark must not hard-block entry (blocking VN + player mouse capture race).
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("dialog_villager_run"))
# Strip wrong early toothionaire / snake gift
if ui and ui.has_method("show_dialog_key"):
if Inventory.has_item("snake") and not GameState.get_flag("snake_massaged", false):
ui.show_dialog_key("dialog_villager_snake", 3.5, false)
else:
ui.show_dialog_key("dialog_villager_run", 3.5, false)
var dentist := get_node_or_null("Dentist")
if dentist:
dentist.give_item_id = ""
@ -20,11 +88,559 @@ func _ready() -> void:
if fan:
fan.give_item_id = ""
fan.dialog_key = "dialog_villager_run"
_setup_flee()
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
## Belt-and-suspenders: never persist preview meshes into village.tscn.
_strip_preview_owners()
func _free_editor_previews() -> void:
var to_free: Array[Node] = []
for child in get_children():
if str(child.name).begins_with(EDITOR_PREVIEW_PREFIX):
to_free.append(child)
for n in to_free:
remove_child(n)
n.free()
func _strip_preview_owners() -> void:
for child in get_children():
if str(child.name).begins_with(EDITOR_PREVIEW_PREFIX):
_clear_owner_recursive(child)
func _clear_owner_recursive(n: Node) -> void:
n.owner = null
for c in n.get_children():
_clear_owner_recursive(c)
func _editor_refresh_preview() -> void:
if not Engine.is_editor_hint():
return
_free_editor_previews()
if not editor_preview_spawns:
return
## Deterministic pool picks so layout is stable while editing (runtime still random).
var houses := _list_random_house_models()
var animals := _list_random_animal_models()
var i := 0
for slot in HOUSE_SLOTS:
if houses.is_empty():
break
var path: String = houses[i % houses.size()]
_spawn_prop(
"%sRandomHouse_%d" % [EDITOR_PREVIEW_PREFIX, i],
path,
slot["pos"],
float(slot.get("yaw", 0.0)),
SpriteFit.fit_height_for(path, SpriteFit.WORLD_HOUSE_HEIGHT),
SpriteFit.fit_max_width_for(path, SpriteFit.WORLD_HOUSE_MAX_WIDTH),
float(slot.get("mul", 1.0))
)
i += 1
i = 0
for slot in ANIMAL_SLOTS:
if animals.is_empty():
break
var apath: String = animals[i % animals.size()]
_spawn_prop(
"%sRandomAnimal_%d" % [EDITOR_PREVIEW_PREFIX, i],
apath,
slot["pos"],
float(slot.get("yaw", 0.0)),
SpriteFit.animal_height(apath),
SpriteFit.animal_max_width(apath),
float(slot.get("mul", 1.0)),
false,
false
)
i += 1
if ResourceLoader.exists(MASSEUR_HOUSE_MODEL):
_spawn_prop(
"%sMasseurHouse" % EDITOR_PREVIEW_PREFIX,
MASSEUR_HOUSE_MODEL,
Vector3(-5.5, 0.0, 2.5),
-40.0,
SpriteFit.fit_height_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_HEIGHT),
SpriteFit.fit_max_width_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_MAX_WIDTH),
1.0,
false
)
if ResourceLoader.exists(COW_MODEL):
_spawn_prop(
"%sCowMasseur" % EDITOR_PREVIEW_PREFIX,
COW_MODEL,
Vector3(-4.2, 0.0, 3.8),
140.0,
SpriteFit.fit_height_for(COW_MODEL, SpriteFit.WORLD_COW_HEIGHT),
SpriteFit.fit_max_width_for(COW_MODEL, 2.4),
1.0,
true,
false
)
if not houses.is_empty():
var ppath: String = houses[0]
_spawn_prop(
"%sProgressHouse" % EDITOR_PREVIEW_PREFIX,
ppath,
Vector3(0.0, 0.0, -16.0),
0.0,
SpriteFit.fit_height_for(ppath, SpriteFit.WORLD_HOUSE_HEIGHT),
SpriteFit.fit_max_width_for(ppath, SpriteFit.WORLD_HOUSE_MAX_WIDTH),
1.0,
false
)
func _setup_table_button() -> void:
var btn := get_node_or_null("Table/TableButton")
if btn == null:
return
btn.set("on_success_goto", "")
btn.set("can_interact", true)
btn.set("prompt_key", "prompt_press_button")
if btn.has_signal("interacted") and not btn.interacted.is_connected(_on_table_button):
btn.interacted.connect(_on_table_button)
func _on_table_button(_prop: Node = null) -> void:
if GameState.get_flag("reif_defeated", false):
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key("exit_after_reif", 3.5)
return
if _reif_fight_active:
return
_start_reif_fight()
func _setup_reif() -> void:
var reif := get_node_or_null("Reif")
if reif == null:
return
if GameState.get_flag("reif_defeated", false):
reif.queue_free()
_reif_alive = false
_reif_fight_active = false
return
## Dormant until table button — no chase / no interact yet.
reif.visible = false
reif.set("ai_enabled", false)
reif.process_mode = Node.PROCESS_MODE_DISABLED
if reif.is_in_group("interactable"):
reif.remove_from_group("interactable")
if reif.is_in_group("enemy"):
reif.remove_from_group("enemy")
_reif_alive = true
_reif_fight_active = false
func _start_reif_fight() -> void:
var reif := get_node_or_null("Reif")
if reif == null:
reif = _spawn_reif()
if reif == null:
return
_reif_fight_active = true
_reif_alive = true
reif.visible = true
reif.process_mode = Node.PROCESS_MODE_INHERIT
if not reif.is_in_group("interactable"):
reif.add_to_group("interactable")
if not reif.is_in_group("enemy"):
reif.add_to_group("enemy")
reif.set("ai_enabled", true)
reif.set("move_speed", 3.2)
reif.set("attack_damage", 1)
reif.set("attack_cooldown", 1.1)
reif.set("attack_range", 1.25)
reif.set("death_cause_key", "death_shoe")
reif.set("shout_key", "dialog_reif")
reif.set("shout_interval", 5.0)
reif.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
reif.set("max_hp", 8)
reif.set("hp", 8)
reif.set("defeat_flag", "reif_defeated")
if reif.has_signal("defeated") and not reif.defeated.is_connected(_on_reif_defeated):
reif.defeated.connect(_on_reif_defeated)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key("dialog_reif")
func _spawn_reif() -> Node3D:
var packed: PackedScene = load(ENEMY_SCENE)
if packed == null:
return null
var reif: Node3D = packed.instantiate()
reif.name = "Reif"
reif.set("max_hp", 8)
reif.set("texture_path", "res://assets/sprites/placeholders/reif.png")
reif.set("billboard_height", SpriteFit.WORLD_BOSS_HEIGHT)
reif.set("defeat_flag", "reif_defeated")
add_child(reif)
reif.global_position = Vector3(0.0, 0.1, -1.5)
return reif
func _physics_process(delta: float) -> void:
if Engine.is_editor_hint():
return
if not _reif_fight_active or not _reif_alive:
return
var reif := get_node_or_null("Reif")
if reif == null or not is_instance_valid(reif):
_reif_alive = false
return
_throw_cd -= delta
if _throw_cd > 0.0:
return
_throw_cd = randf_range(1.6, 2.4)
_throw_shoe(reif as Node3D)
func _throw_shoe(reif: Node3D) -> void:
var player := get_tree().get_first_node_in_group("player")
if player == null or not (player is Node3D):
return
var dist := reif.global_position.distance_to((player as Node3D).global_position)
if dist < 2.0 and randf() > 0.35:
return
var scene: PackedScene = load("res://scenes/prefabs/shoe_projectile.tscn")
if scene == null:
return
var shoe: Node3D = scene.instantiate()
add_child(shoe)
shoe.global_position = reif.global_position + Vector3(0, 1.1, 0)
var target: Vector3 = (player as Node3D).global_position + Vector3(0, 0.9, 0)
var to_target := target - shoe.global_position
var horiz := Vector3(to_target.x, 0.0, to_target.z)
var speed := 9.0
if horiz.length() > 0.01:
horiz = horiz.normalized()
else:
horiz = Vector3(0, 0, 1)
var vy := 3.5 + dist * 0.35
shoe.set("velocity", Vector3(horiz.x * speed, vy, horiz.z * speed))
shoe.set("damage", 1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog") and randf() < 0.4:
ui.show_dialog_key("dialog_reif", 1.5, false)
AudioDirector.play_hit()
func _on_reif_defeated() -> void:
_reif_alive = false
_reif_fight_active = false
for child in get_children():
if child.is_in_group("shoe_projectile"):
child.queue_free()
_spawn_tiramisu_tonne()
GameState.set_flag("reif_defeated", true)
_open_gate(true)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog(Locale.tr_key("prompt_eat_tiramisu") + "\n" + Locale.tr_key("exit_after_reif"), 5.0)
func _spawn_tiramisu_tonne() -> void:
if get_node_or_null("TiramisuTonne") != null:
return
var scene: PackedScene = load("res://scenes/prefabs/npc.tscn")
if scene == null:
return
var tonne: Node3D = scene.instantiate()
tonne.name = "TiramisuTonne"
tonne.set("dialog_key", "dialog_tiramisu")
tonne.set("texture_path", "res://assets/sprites/placeholders/tiramisu.png")
tonne.set("billboard_height", 1.8)
tonne.set("billboard_max_width", 2.2)
tonne.set("give_item_id", "")
tonne.set("on_success_goto", "")
tonne.set("quest_flag", "")
add_child(tonne)
tonne.global_position = Vector3(0, 0.1, -2)
var bill = tonne.get_node_or_null("BillboardSprite")
if bill:
if bill.has_method("set_target_height"):
bill.set_target_height(1.8)
bill.set("max_width", 2.2)
if bill.has_method("set_texture"):
var tex: Texture2D = load("res://assets/sprites/placeholders/tiramisu.png")
if tex:
bill.set_texture(tex)
if tonne.has_signal("interacted"):
tonne.interacted.connect(_on_tonne_interacted)
func _on_tonne_interacted(_n: Node = null) -> void:
if GameState.get_flag("tiramisu_eaten", false):
return
GameState.set_flag("tiramisu_eaten", true)
if not Inventory.has_item("tiramisu"):
Inventory.add_item("tiramisu", 1)
GameState.apply_fat(600.0)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog"):
ui.show_dialog_key("dialog_tiramisu", 5.0)
var tonne := get_node_or_null("TiramisuTonne")
if tonne:
tonne.queue_free()
func _setup_gate() -> void:
var gate := get_node_or_null("MeadowGate")
var label := gate.get_node_or_null("GateLabel") if gate else null
if GameState.get_flag("reif_defeated", false):
_open_gate(false)
elif label is Label3D:
(label as Label3D).text = Locale.tr_key("gate_locked")
func _open_gate(animate: bool) -> void:
_gate_open = true
var gate := get_node_or_null("MeadowGate")
if gate == null:
return
var col := gate.get_node_or_null("CollisionShape3D")
if col is CollisionShape3D:
(col as CollisionShape3D).disabled = true
## Swing open toward meadow (+X).
if animate:
var tw := create_tween()
tw.tween_property(gate, "rotation_degrees:y", -95.0, 0.85).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
else:
gate.rotation_degrees.y = -95.0
var label := gate.get_node_or_null("GateLabel")
if label is Label3D:
(label as Label3D).text = Locale.tr_key("gate_open")
func _scan_glb_pool(root_dir: String, skip_folders: Array) -> Array[String]:
var out: Array[String] = []
var dir := DirAccess.open(root_dir)
if dir == null:
return out
dir.list_dir_begin()
var folder := dir.get_next()
while folder != "":
if dir.current_is_dir() and not folder.begins_with(".") and folder not in skip_folders:
var sub := "%s/%s" % [root_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]
if ResourceLoader.exists(path):
out.append(path)
fname = subdir.get_next()
folder = dir.get_next()
return out
func _list_random_house_models() -> Array[String]:
return _scan_glb_pool(HOUSES_DIR, HOUSE_POOL_SKIP)
func _list_random_animal_models() -> Array[String]:
return _scan_glb_pool(ANIMALS_DIR, ANIMAL_POOL_SKIP)
func _clear_legacy_house_nodes() -> void:
for child in get_children():
if str(child.name).begins_with("House"):
child.queue_free()
func _spawn_prop(name_: String, path: String, pos: Vector3, yaw: float, fit_h: float, fit_w: float, mul: float, interact: bool = false, collide: bool = true) -> Node3D:
var packed: PackedScene = load(MODEL_PROP_SCENE)
if packed == null:
return null
var prop: Node3D = packed.instantiate()
prop.name = name_
prop.set("model_path", path)
prop.set("fit_height", fit_h)
prop.set("fit_max_width", fit_w)
prop.set("ground_align", true)
prop.set("model_scale", Vector3(mul, mul, mul))
prop.set("model_rotation_degrees", Vector3(0.0, yaw, 0.0))
prop.set("can_interact", interact)
## Must set before add_child — ModelProp builds collision in _ready.
prop.set("add_collision", collide)
add_child(prop)
## Editor previews must not be written into village.tscn on save.
prop.owner = null
prop.position = pos
return prop
func _spawn_random_houses() -> void:
var pool := _list_random_house_models()
if pool.is_empty():
push_warning("village: no random house models under %s" % HOUSES_DIR)
return
var i := 0
for slot in HOUSE_SLOTS:
var path: String = pool[randi() % pool.size()]
_spawn_prop(
"RandomHouse_%d" % i,
path,
slot["pos"],
float(slot.get("yaw", 0.0)),
SpriteFit.fit_height_for(path, SpriteFit.WORLD_HOUSE_HEIGHT),
SpriteFit.fit_max_width_for(path, SpriteFit.WORLD_HOUSE_MAX_WIDTH),
float(slot.get("mul", 1.0))
)
i += 1
func _spawn_random_animals() -> void:
var pool := _list_random_animal_models()
if pool.is_empty():
push_warning("village: no random animal models under %s" % ANIMALS_DIR)
return
var i := 0
for slot in ANIMAL_SLOTS:
var path: String = pool[randi() % pool.size()]
var fit_h := SpriteFit.animal_height(path)
var fit_w := SpriteFit.animal_max_width(path)
_spawn_prop(
"RandomAnimal_%d" % i,
path,
slot["pos"],
float(slot.get("yaw", 0.0)),
fit_h,
fit_w,
float(slot.get("mul", 1.0)),
false,
false ## animals do not block walking
)
i += 1
func _spawn_masseur_house() -> void:
if not ResourceLoader.exists(MASSEUR_HOUSE_MODEL):
push_warning("village: massourhaus.glb missing")
return
_spawn_prop(
"MasseurHouse",
MASSEUR_HOUSE_MODEL,
Vector3(-5.5, 0.0, 2.5),
-40.0,
SpriteFit.fit_height_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_HEIGHT),
SpriteFit.fit_max_width_for(MASSEUR_HOUSE_MODEL, SpriteFit.WORLD_HOUSE_MAX_WIDTH),
1.0,
false
)
func _spawn_progress_house() -> void:
## Outdoor: [E] leaves overworld → Catscene (after snake massage).
var pool := _list_random_house_models()
if pool.is_empty():
return
var path: String = pool[0]
var house := _spawn_prop(
"ProgressHouse",
path,
Vector3(0.0, 0.0, -16.0),
0.0,
SpriteFit.fit_height_for(path, SpriteFit.WORLD_HOUSE_HEIGHT),
SpriteFit.fit_max_width_for(path, SpriteFit.WORLD_HOUSE_MAX_WIDTH),
1.0,
true
)
if house == null:
return
house.set("prompt_key", "prompt_enter")
if house.has_signal("interacted"):
house.interacted.connect(_on_progress_house)
func _on_progress_house(_prop: Node = null) -> void:
var ui := get_node_or_null("/root/UIRoot")
if Inventory.has_item("snake") and not GameState.get_flag("snake_massaged", false):
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key("dialog_villager_snake")
return
if GameState.get_flag("snake_massaged", false) or GameState.get_flag("bear_saved", false):
GameState.goto_scene("catscene")
return
if not GameState.get_flag("reif_defeated", false):
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key("hint_press_button_first", 4.0)
return
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key("hint_go_meadow_gate", 4.0)
func _spawn_cow_masseur() -> void:
if not ResourceLoader.exists(COW_MODEL):
push_warning("village: cow.glb missing")
return
var cow := _spawn_prop(
"CowMasseur",
COW_MODEL,
Vector3(-4.2, 0.0, 3.8),
140.0,
SpriteFit.fit_height_for(COW_MODEL, SpriteFit.WORLD_COW_HEIGHT),
SpriteFit.fit_max_width_for(COW_MODEL, 2.4),
1.0,
true,
false ## keep interact range clear
)
if cow == null:
return
cow.set("prompt_key", "prompt_talk")
if cow.has_signal("interacted"):
cow.interacted.connect(_on_cow)
func _on_cow(_npc: Node = null) -> void:
if choice_ui == null:
return
choice_ui.visible = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
$ChoiceUI/Panel/Margin/VBox/Label.text = Locale.tr_key("dialog_cow")
$ChoiceUI/Panel/Margin/VBox/MassageBtn.text = Locale.tr_key("choice_massage_now")
$ChoiceUI/Panel/Margin/VBox/DrinkBtn.text = Locale.tr_key("choice_drink_first")
AudioDirector.play_dialog_line("dialog_cow")
func _close_choices() -> void:
if choice_ui:
choice_ui.visible = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _on_massage_pressed() -> void:
_close_choices()
if not Inventory.has_item("snake"):
var ui0 := get_node_or_null("/root/UIRoot")
if ui0 and ui0.has_method("show_dialog_key"):
ui0.show_dialog_key("dialog_villager_snake")
return
Inventory.remove_item("snake", 1)
GameState.set_flag("snake_massaged", true)
Achievements.unlock("snake_massage")
if not GameState.get_flag("bear_saved", false):
Inventory.add_item("tooth", 1)
GameState.set_flag("bear_saved", true)
var meadow := get_node_or_null("MeadowZone")
if meadow and meadow.has_method("notify_snake_massaged"):
meadow.notify_snake_massaged()
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key("dialog_cow_massage", 5.0)
func _on_drink_pressed() -> void:
_close_choices()
Inventory.add_item("milk", 1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("show_dialog_key"):
ui.show_dialog_key("dialog_cow_drink", 4.0)
func _setup_flee() -> void:
const FLEE_SKIP := [
"TableButton", "Table", "MasseurHouse", "CowMasseur", "ProgressHouse",
"Exit", "Player", "Ground", "MeadowGround", "WorldEnvironment", "DirectionalLight3D",
"MeadowFence", "MeadowGate", "MeadowZone", "Reif", "TiramisuTonne",
]
for n in get_children():
if n.is_in_group("interactable") and n.name != "TableButton" and n is Node3D:
if n.name in FLEE_SKIP or str(n.name).begins_with(EDITOR_PREVIEW_PREFIX) or str(n.name).begins_with("RandomHouse_") or str(n.name).begins_with("RandomAnimal_") or str(n.name).begins_with("House") or str(n.name).begins_with("Fence"):
continue
if n is ModelProp:
continue
if n.is_in_group("interactable") and n is Node3D:
var tex := str(n.get("texture_path"))
if tex.ends_with("house.png") or tex.ends_with("table.png") or tex.ends_with("button.png"):
continue
_flee_dirs[n] = Vector3(randf_range(-1, 1), 0, randf_range(-1, 1)).normalized()
func _process(delta: float) -> void:
if Engine.is_editor_hint():
return
for n in _flee_dirs.keys():
if not is_instance_valid(n):
continue

View file

@ -1,19 +1,22 @@
@tool
extends CharacterBody3D
class_name PlayerController
## Third-person controller: camera follows player, movement is camera-relative.
## Billboard sprites face the camera (Mario-tree style).
## Third-person controller + focus highlight / attack feedback.
## @tool: only applies billboard look in the editor (no input/camera capture).
@export var move_speed: float = 6.0
@export var attack_range: float = 1.8
@export var attack_range: float = 2.75 ## Longer than enemy melee so you can poke before they hit
@export var interact_range: float = 2.8
@export var attack_damage: int = 1
@export var mouse_sensitivity: float = 0.0025
@export var min_pitch_deg: float = -35.0
@export var max_pitch_deg: float = 55.0
@export var spring_length: float = 5.5
@export var camera_height: float = 1.4
@export var spring_length: float = 6.0
@export var camera_height: float = 1.62
signal interacted
signal attacked
signal focus_changed(target: Node, prompt: String)
@onready var _billboard: BillboardSprite = $BillboardSprite
@onready var _yaw_pivot: Node3D = $CameraYaw
@ -22,9 +25,19 @@ signal attacked
var _locked: bool = false
var _pitch: float = deg_to_rad(-18.0)
var _focus: Node = null
var _attack_focus: Node = null
var _melee_vfx: MeleeVfx
func _ready() -> void:
_apply_editor_billboard()
if Engine.is_editor_hint():
set_physics_process(false)
set_process(false)
set_process_unhandled_input(false)
return
add_to_group("player")
camera_height = SpriteFit.WORLD_CAMERA_EYE
if _spring:
_spring.spring_length = spring_length
_spring.collision_mask = 1
@ -33,17 +46,43 @@ func _ready() -> void:
_camera.current = true
_camera.fov = 70.0
_apply_pitch()
_set_mouse_captured(true)
if _billboard and _billboard.texture == null:
# Scene load may already have opened a blocking VN (pause + visible mouse).
# Capturing here would steal the cursor from Weiter / dialog UI.
var ui0 := get_node_or_null("/root/UIRoot")
if ui0 and ui0.has_method("is_dialog_open") and ui0.is_dialog_open():
_set_mouse_captured(false)
else:
_set_mouse_captured(true)
_apply_editor_billboard()
_melee_vfx = MeleeVfx.new()
_melee_vfx.name = "MeleeVfx"
add_child(_melee_vfx)
_melee_vfx.set_ranges(attack_range, interact_range)
func _apply_editor_billboard() -> void:
if _billboard == null:
_billboard = get_node_or_null("BillboardSprite") as BillboardSprite
if _billboard == null:
return
_billboard.max_width = SpriteFit.WORLD_MAX_WIDTH
_billboard.set_target_height(SpriteFit.WORLD_PLAYER_HEIGHT)
if _billboard.texture == null:
var tex: Texture2D = load("res://assets/sprites/placeholders/player.png")
if tex:
_billboard.set_texture(tex)
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
_clear_focus_highlights()
_set_mouse_captured(false)
func _unhandled_input(event: InputEvent) -> void:
if _locked or get_tree().paused:
if Engine.is_editor_hint() or _locked or get_tree().paused:
return
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
return
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
var motion := event as InputEventMouseMotion
@ -60,6 +99,12 @@ func _unhandled_input(event: InputEvent) -> void:
func _physics_process(_delta: float) -> void:
if _locked or get_tree().paused:
velocity = Vector3.ZERO
_update_focus()
return
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("is_dialog_open") and ui.is_dialog_open():
velocity = Vector3.ZERO
_update_focus()
return
var input := Vector2(
@ -83,10 +128,13 @@ func _physics_process(_delta: float) -> void:
speed *= 0.55
if GameState.is_shocked():
velocity = Vector3.ZERO
_update_focus()
return
velocity = dir * speed
move_and_slide()
_update_focus()
if Input.is_action_just_pressed("interact"):
_try_interact()
if Input.is_action_just_pressed("attack"):
@ -100,7 +148,6 @@ func lock_input(locked: bool) -> void:
_set_mouse_captured(true)
func set_gameplay_focused(focused: bool) -> void:
## Called by pause UI: release mouse in menus, recapture in gameplay.
if focused and not _locked:
_set_mouse_captured(true)
else:
@ -117,17 +164,137 @@ func _set_mouse_captured(captured: bool) -> void:
else:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _interact_distance_to(body: Node3D) -> float:
if body.has_method("get_interact_distance"):
return float(body.call("get_interact_distance", global_position))
return global_position.distance_to(body.global_position)
func _nearest_in_group(group: String, radius: float) -> Node:
var best: Node = null
var best_d := radius
for body in get_tree().get_nodes_in_group(group):
if body == self or not (body is Node3D):
continue
if not is_instance_valid(body):
continue
var d: float = _interact_distance_to(body as Node3D)
if d <= best_d:
best_d = d
best = body
return best
func _update_focus() -> void:
if get_tree().paused or _locked or GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene"]:
_set_focus(null, null)
return
var interact := _nearest_in_group("interactable", interact_range)
if interact != null and interact.is_in_group("enemy"):
var talk := _nearest_talk_target(interact_range)
if talk != null:
interact = talk
var attack := _nearest_in_group("enemy", attack_range)
_set_focus(interact, attack)
func _nearest_talk_target(radius: float) -> Node:
var best: Node = null
var best_d := radius
for body in get_tree().get_nodes_in_group("interactable"):
if body == self or not (body is Node3D):
continue
if body.is_in_group("enemy"):
continue
var d: float = _interact_distance_to(body as Node3D)
if d <= best_d:
best_d = d
best = body
return best
func _set_focus(interact: Node, attack: Node) -> void:
if interact == _focus and attack == _attack_focus:
_emit_prompt()
return
_clear_focus_highlights()
_focus = interact
_attack_focus = attack
if _focus != null and _focus.has_method("set_interaction_highlight"):
_focus.set_interaction_highlight(true, Color(1.4, 1.35, 0.4))
elif _focus != null:
_highlight_node(_focus, true, Color(1.4, 1.35, 0.4))
if _attack_focus != null and _attack_focus != _focus:
if _attack_focus.has_method("set_interaction_highlight"):
_attack_focus.set_interaction_highlight(true, Color(1.5, 0.45, 0.35))
else:
_highlight_node(_attack_focus, true, Color(1.5, 0.45, 0.35))
_emit_prompt()
func _emit_prompt() -> void:
## Interact stays as text prompt; attack/range is visual (rings + swing), not text.
var prompt := ""
if _focus != null:
if _focus.has_method("get_interact_prompt"):
prompt = str(_focus.get_interact_prompt())
else:
prompt = Locale.tr_key("prompt_use")
var exit_hint := _near_exit_hint()
if exit_hint != "" and prompt == "":
prompt = exit_hint
elif exit_hint != "" and prompt != "":
prompt = "%s %s" % [prompt, exit_hint]
if _melee_vfx:
_melee_vfx.show_attack_range(_attack_focus != null)
_melee_vfx.show_interact_range(_focus != null)
focus_changed.emit(_focus, prompt)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("set_interact_prompt"):
ui.set_interact_prompt(prompt)
func _near_exit_hint() -> String:
for body in get_tree().get_nodes_in_group("exit_trigger"):
if body is Node3D and global_position.distance_to((body as Node3D).global_position) <= 3.5:
return Locale.tr_key("prompt_exit")
return ""
func _clear_focus_highlights() -> void:
if _focus != null and is_instance_valid(_focus):
if _focus.has_method("set_interaction_highlight"):
_focus.set_interaction_highlight(false)
else:
_highlight_node(_focus, false)
if _attack_focus != null and is_instance_valid(_attack_focus) and _attack_focus != _focus:
if _attack_focus.has_method("set_interaction_highlight"):
_attack_focus.set_interaction_highlight(false)
else:
_highlight_node(_attack_focus, false)
func _highlight_node(node: Node, enabled: bool, color: Color = Color(1.35, 1.25, 0.35)) -> void:
var bill = node.get_node_or_null("BillboardSprite")
if bill and bill.has_method("set_highlight"):
bill.set_highlight(enabled, color)
func _try_interact() -> void:
interacted.emit()
for body in get_tree().get_nodes_in_group("interactable"):
if body is Node3D and global_position.distance_to(body.global_position) <= 2.2:
if body.has_method("on_interact"):
body.on_interact(self)
return
var target := _focus
if target == null:
target = _nearest_talk_target(interact_range)
if target == null:
target = _nearest_in_group("interactable", interact_range)
if target != null and target.has_method("on_interact"):
_flash_feedback(target, Color(1.6, 1.6, 0.6))
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("pulse_interact"):
ui.pulse_interact()
target.on_interact(self)
func _try_attack() -> void:
attacked.emit()
AudioDirector.play_hit()
if _melee_vfx:
_melee_vfx.play_swing()
if _billboard and _billboard.has_method("flash"):
_billboard.flash(Color(2.2, 2.0, 1.4), 0.1)
var ui := get_node_or_null("/root/UIRoot")
if ui and ui.has_method("pulse_attack"):
ui.pulse_attack()
var dmg := attack_damage
if Inventory.has_item("tooth"):
dmg = max(dmg, 2)
@ -135,7 +302,31 @@ func _try_attack() -> void:
dmg = max(dmg, 99)
if Inventory.has_item("wartchen"):
dmg = max(dmg, 3)
var hit_any := false
var targets: Array = []
if _attack_focus != null:
targets.append(_attack_focus)
for body in get_tree().get_nodes_in_group("enemy"):
if body in targets:
continue
if body is Node3D and global_position.distance_to(body.global_position) <= attack_range:
targets.append(body)
for body in targets:
if body is Node3D and global_position.distance_to(body.global_position) <= attack_range:
if body.has_method("take_hit"):
body.take_hit(dmg)
_flash_feedback(body, Color(2.0, 0.5, 0.4))
if _melee_vfx:
_melee_vfx.burst_at((body as Node3D).global_position, true)
hit_any = true
if not hit_any:
# Miss puff at swing tip in front of player
var tip := global_position + (-_yaw_pivot.global_transform.basis.z) * attack_range
tip.y = global_position.y
if _melee_vfx:
_melee_vfx.burst_at(tip, false)
func _flash_feedback(node: Node, color: Color) -> void:
var bill = node.get_node_or_null("BillboardSprite")
if bill and bill.has_method("flash"):
bill.flash(color, 0.15)

View file

@ -421,6 +421,10 @@ def main() -> None:
write_png(sprites / "burger.png", 16, 16, solid(180, 120, 40))
write_png(sprites / "disturbed.png", 16, 16, face(solid(180, 180, 180)))
write_png(sprites / "shoe.png", 16, 16, solid(90, 50, 20))
write_png(sprites / "reif.png", 16, 16, face(solid(120, 80, 40)))
write_png(sprites / "mark.png", 16, 16, face(solid(100, 70, 35)))
write_png(sprites / "stick.png", 16, 16, solid(90, 55, 20))
write_png(sprites / "tiramisu.png", 16, 16, solid(90, 55, 30))
write_png(sprites / "glitzo.png", 16, 16, checker((255, 0, 255, 255), (0, 255, 255, 255), 2))
write_png(sprites / "bear.png", 16, 16, solid(240, 240, 255))
write_png(sprites / "snake.png", 16, 16, solid(40, 180, 60))
@ -435,6 +439,35 @@ def main() -> None:
write_png(sprites / "house.png", 16, 16, solid(140, 90, 50))
write_png(sprites / "skull.png", 16, 16, solid(240, 240, 240))
write_png(sprites / "quirxel.png", 8, 8, solid(255, 255, 0))
# DESIGN inventory / props (stubs — do not clobber large user art in repo by hand)
for name, rgb in {
"tooth.png": (250, 250, 245),
"milk.png": (245, 245, 250),
"banana.png": (240, 210, 40),
"wartchen.png": (220, 80, 140),
"sunglasses.png": (20, 20, 20),
"death_ticket.png": (180, 160, 80),
"moon_sword.png": (180, 200, 255),
"chicken.png": (240, 220, 180),
"pig.png": (240, 160, 180),
"lava_chicken.png": (220, 60, 20),
"wedding_egg.png": (255, 240, 200),
"dressler_apple.png": (180, 20, 20),
"burger_cham.png": (180, 100, 40),
"snake_mower.png": (40, 180, 60),
"mower.png": (100, 100, 110),
"cheese_seller.png": (240, 210, 80),
"glitschkoenig.png": (255, 0, 255),
"button.png": (200, 40, 40),
"table.png": (120, 80, 40),
"dentist.png": (220, 220, 230),
"shorts_stim.png": (80, 200, 120),
"hammer.png": (140, 100, 60),
"whiff.png": (120, 200, 255),
"arrow_left.png": (255, 220, 40),
"moon_researcher.png": (100, 100, 140),
}.items():
write_png(sprites / name, 16, 16, solid(*rgb))
write_tone_wav(audio / "music_title.wav", 220.0, 1.2, 0.45)
write_tone_wav(audio / "sfx_explosion.wav", 60.0, 0.5, 0.5)
@ -489,7 +522,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, -10)
[node name="Reif" parent="." instance=ExtResource("5")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.1, -2)
max_hp = 5
texture_path = "res://assets/sprites/placeholders/shoe.png"
texture_path = "res://assets/sprites/placeholders/reif.png"
defeat_flag = "reif_defeated"
achievement_on_defeat = "dressler_apples"

View file

@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Generate high-quality Piper TTS for dialog_* / lang_voice_* from locales.
Uses large/high Piper models from data/voice_cast.json.
Until real voice takes exist, run --watch to auto-rebuild when scripts change.
Usage:
python3 scripts/tools/generate_voice_dialogs.py --force
python3 scripts/tools/generate_voice_dialogs.py --watch
python3 scripts/tools/generate_voice_dialogs.py --lang de --force
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CAST_PATH = ROOT / "data" / "voice_cast.json"
LOCALES = ROOT / "data" / "locales"
OUT_ROOT = ROOT / "assets" / "audio" / "voice"
MANIFEST = OUT_ROOT / "_manifest.json"
PIPER = os.path.expanduser("~/.local/bin/piper")
DEFAULT_VOICES = Path(os.path.expanduser("~/.local/share/piper/voices"))
STAGE_DIR = re.compile(r"\*[^*]+\*")
EMOJI_ETC = re.compile(r"[⬜]+")
def clean_tts(text: str) -> str:
text = STAGE_DIR.sub(" ", text)
text = EMOJI_ETC.sub(" ", text)
text = text.replace("…", "...")
text = text.replace("—", " - ")
text = text.replace("–", " - ")
text = re.sub(r"\s+", " ", text).strip()
return text
def load_cast() -> dict:
return json.loads(CAST_PATH.read_text(encoding="utf-8"))
def voices_dir(cast: dict) -> Path:
return Path(os.path.expanduser(cast.get("voices_dir", str(DEFAULT_VOICES))))
def resolve_model(cast: dict, model_key: str) -> Path:
models = cast["models"]
if model_key not in models:
raise KeyError(f"unknown model key {model_key}")
path = voices_dir(cast) / models[model_key]
return path
def npc_params(cast: dict, npc_id: str, lang: str) -> dict:
npc = cast["npcs"][npc_id]
# New format: per-lang dict; legacy: flat speaker ints
entry = npc.get(lang, npc)
if isinstance(entry, dict) and ("model" in entry or "speaker" in entry):
params = dict(entry)
else:
defaults = cast.get("lang_defaults", {})
params = {
"model": defaults.get(lang, "de_high"),
"speaker": int(entry) if not isinstance(entry, dict) else 0,
"length_scale": float(npc.get("length_scale", 1.0)),
}
params.setdefault("speaker", 0)
params.setdefault("length_scale", 1.0)
params.setdefault("noise_scale", 0.667)
params.setdefault("noise_w", 0.8)
params.setdefault("sentence_silence", 0.35)
return params
def fingerprint(text: str, params: dict, model_path: Path) -> str:
payload = {
"text": text,
"model": model_path.name,
"speaker": int(params.get("speaker", 0)),
"length_scale": float(params.get("length_scale", 1.0)),
"noise_scale": float(params.get("noise_scale", 0.667)),
"noise_w": float(params.get("noise_w", 0.8)),
"sentence_silence": float(params.get("sentence_silence", 0.35)),
}
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def synthesize(model: Path, text: str, out_wav: Path, params: dict) -> None:
out_wav.parent.mkdir(parents=True, exist_ok=True)
tmp = out_wav.with_suffix(".tmp.wav")
cmd = [
PIPER,
"-m", str(model),
"-s", str(int(params.get("speaker", 0))),
"--length_scale", str(float(params.get("length_scale", 1.0))),
"--noise_scale", str(float(params.get("noise_scale", 0.667))),
"--noise_w", str(float(params.get("noise_w", 0.8))),
"--sentence_silence", str(float(params.get("sentence_silence", 0.35))),
"-f", str(tmp),
]
proc = subprocess.run(cmd, input=text.encode("utf-8"), capture_output=True)
if proc.returncode != 0 or not tmp.exists() or tmp.stat().st_size < 44:
err = proc.stderr.decode("utf-8", errors="replace")
if tmp.exists():
tmp.unlink(missing_ok=True)
raise RuntimeError(f"piper failed for {out_wav.name}: {err[-500:]}")
tmp.replace(out_wav)
def load_manifest() -> dict:
if MANIFEST.exists():
try:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
return {}
def save_manifest(data: dict) -> None:
OUT_ROOT.mkdir(parents=True, exist_ok=True)
MANIFEST.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def generate(force: bool = False, langs: list[str] | None = None) -> tuple[int, int]:
if not Path(PIPER).exists():
raise SystemExit(f"piper not found at {PIPER}")
cast = load_cast()
lines = cast["lines"]
all_langs = langs or ["de", "en", "by"]
locale_data = {}
for lang in all_langs:
locale_data[lang] = json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8"))
manifest = {} if force else load_manifest()
done = 0
skipped = 0
for lang in all_langs:
print(f"== {lang}", flush=True)
for key, npc_id in lines.items():
text_raw = locale_data[lang].get(key)
if not text_raw:
print(f" skip missing {key}")
skipped += 1
continue
tts = clean_tts(str(text_raw))
if not tts:
skipped += 1
continue
params = npc_params(cast, npc_id, lang)
model_path = resolve_model(cast, str(params["model"]))
if not model_path.exists():
raise SystemExit(f"MISSING model {model_path}")
fp = fingerprint(tts, params, model_path)
out = OUT_ROOT / lang / f"{key}.wav"
mkey = f"{lang}/{key}.wav"
if (
not force
and out.exists()
and out.stat().st_size > 1000
and manifest.get(mkey) == fp
):
skipped += 1
continue
print(f" {key} npc={npc_id} model={model_path.name} spk={params.get('speaker')}")
synthesize(model_path, tts, out, params)
manifest[mkey] = fp
done += 1
save_manifest(manifest)
print(f"generated={done} skipped={skipped} out={OUT_ROOT}", flush=True)
return done, skipped
def watch_sources_mtime() -> float:
mtimes = [CAST_PATH.stat().st_mtime]
for p in LOCALES.glob("*.json"):
mtimes.append(p.stat().st_mtime)
return max(mtimes)
def watch_loop(interval: float = 2.0) -> int:
print(f"watching {LOCALES} + {CAST_PATH} (interval={interval}s) — Ctrl+C to stop", flush=True)
print("placeholder TTS only; replace WAVs later with real takes (same paths).", flush=True)
last = 0.0
# initial sync
generate(force=False)
last = watch_sources_mtime()
try:
while True:
time.sleep(interval)
now = watch_sources_mtime()
if now > last:
print("\n[watch] locale/cast changed — regenerating…")
generate(force=False)
last = watch_sources_mtime()
except KeyboardInterrupt:
print("\n[watch] stopped")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--force", action="store_true", help="Rebuild all WAVs")
ap.add_argument("--lang", action="append", help="Only these langs (de/en/by)")
ap.add_argument("--watch", action="store_true", help="Auto-regenerate when locales/cast change")
ap.add_argument("--interval", type=float, default=2.0, help="Watch poll seconds")
args = ap.parse_args()
if args.watch:
return watch_loop(args.interval)
generate(force=args.force, langs=args.lang)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Auto-organize GLBs dropped into assets/3DModelle/.
Drop/rename freely in the folder root or `_inbox/`. After files sit unchanged
for `--stable-secs` (default 45), they get classified into props/houses/animals,
related textures move along, and `.import` source paths are fixed.
Usage:
python3 scripts/tools/organize_3d_models.py --watch
python3 scripts/tools/organize_3d_models.py --force --import
python3 scripts/tools/organize_3d_models.py --dry-run
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MODELS = ROOT / "assets" / "3DModelle"
INBOX = MODELS / "_inbox"
ANIMAL_WORDS = (
"cat", "chimp", "chimpanzee", "ape", "gorilla", "giraffe", "snail",
"spider", "cow", "pig", "bear", "dog", "chicken", "rooster", "snake",
"horse", "sheep", "goat", "duck", "fish", "bird", "monkey",
)
def log(msg: str) -> None:
print(msg, flush=True)
def classify(glb_filename: str) -> tuple[Path, str]:
"""Return (dest_dir relative to MODELS, target_filename)."""
stem = Path(glb_filename).stem
low = stem.lower()
if "massour" in low or "masseur" in low:
return Path("houses/massour"), "massourhaus.glb"
if "broken" in low and ("house" in low or "haus" in low or low == "brokenhouse"):
return Path("houses/broken"), "BrokenHouse.glb"
if "house" in low or "haus" in low:
# house_b / house-c / houseB
m = re.search(r"house[_\-]?([a-z0-9]{1,4})$", low)
if m and m.group(1) not in ("glb", "pbr"):
folder = f"house_{m.group(1)}"
return Path("houses") / folder, f"{folder}.glb"
if re.fullmatch(r"house", low) or low.startswith("house_texture"):
return Path("houses/house"), "house.glb"
# unknown house-like → own folder from cleaned stem
clean = re.sub(r"[^a-z0-9_]+", "_", low).strip("_")
return Path("houses") / clean, f"{clean}.glb"
for key, folder, fname in (
("button", "button", "Button.glb"),
("knopf", "button", "Button.glb"),
("table", "table", "table.glb"),
("tisch", "table", "table.glb"),
("door", "door", "door.glb"),
("stuhl", "chair", "chair.glb"),
("chair", "chair", "chair.glb"),
):
if key in low:
return Path("props") / folder, fname
for w in ANIMAL_WORDS:
if w in low:
folder = {
"chimpanzee": "chimp",
"ape": "chimp",
"monkey": "chimp",
}.get(w, w)
# prefer explicit chimp/gorilla in name
if "gorilla" in low:
folder = "gorilla"
elif "chimp" in low or "chimpanzee" in low:
folder = "chimp"
# cow/ → Masseur only (village skips this folder)
return Path("animals") / folder, f"{folder}.glb"
# vendor-long name → last alpha token
clean = stem
if len(stem) > 24 and ("-" in stem or "_" in stem):
parts = re.split(r"[-_]+", stem)
for part in reversed(parts):
if part.isalpha() and len(part) >= 3:
clean = part.lower()
break
clean = re.sub(r"[^a-zA-Z0-9_]+", "_", clean).strip("_") or "model"
return Path("props") / clean.lower(), f"{clean}.glb"
def related_files(glb: Path) -> list[Path]:
parent = glb.parent
stem = glb.stem
prefixes = {stem, stem.lower()}
# base house textures: house_Fence, house_texture…
if stem.lower() == "house":
prefixes.add("house_")
out: list[Path] = []
for p in parent.iterdir():
if not p.is_file() or p == glb:
continue
if p.suffix.lower() == ".glb":
continue
if p.name == glb.name + ".import":
out.append(p)
continue
low = p.name.lower()
for pref in prefixes:
pl = pref.lower()
if low.startswith(pl):
# don't steal house_b_* for house.glb
if pl == "house_" and low.startswith("house_b"):
continue
out.append(p)
break
return out
def fix_import_paths(folder: Path) -> None:
for imp in folder.rglob("*.import"):
src = Path(str(imp)[:-7])
if not src.exists():
continue
rel = "res://" + str(src.relative_to(ROOT)).replace("\\", "/")
text = imp.read_text(encoding="utf-8")
lines: list[str] = []
changed = False
for line in text.splitlines(True):
if line.startswith("source_file="):
new = f'source_file="{rel}"\n'
changed = changed or line != new
lines.append(new)
else:
lines.append(line)
if changed:
imp.write_text("".join(lines), encoding="utf-8")
def move_into(src: Path, dest_dir: Path, dest_name: str | None = None) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / (dest_name or src.name)
if dest.exists() and src.resolve() == dest.resolve():
return dest
if dest.exists():
log(f" keep existing {dest.relative_to(MODELS)}")
if src.exists() and src.resolve() != dest.resolve():
# leave duplicate source — user can delete
log(f" (source still at {src.relative_to(MODELS)})")
return dest
shutil.move(str(src), str(dest))
return dest
def organize_glb(glb: Path, dry_run: bool = False) -> Path | None:
rel_dir, target_name = classify(glb.name)
dest_dir = MODELS / rel_dir
log(f"organize {glb.name} → {rel_dir}/{target_name}")
if dry_run:
return dest_dir / target_name
extras = related_files(glb)
dest_glb = move_into(glb, dest_dir, target_name)
# imports / textures that were next to old location
for extra in extras:
if not extra.exists():
continue
if extra.name.endswith(".import") and extra.name.startswith(Path(glb.name).stem):
# rename import to match new glb name if needed
if extra.name == glb.name + ".import" and target_name != glb.name:
move_into(extra, dest_dir, target_name + ".import")
continue
move_into(extra, dest_dir)
# stray import at old path
old_imp = Path(str(glb) + ".import")
if old_imp.exists():
move_into(old_imp, dest_dir, target_name + ".import")
fix_import_paths(dest_dir)
return dest_glb
def root_glbs() -> list[Path]:
out: list[Path] = []
for base in (MODELS, INBOX):
if not base.is_dir():
continue
for p in base.iterdir():
if p.is_file() and p.suffix.lower() == ".glb":
out.append(p)
return out
def bundle_mtime(glb: Path) -> float:
mt = glb.stat().st_mtime
for p in related_files(glb):
try:
mt = max(mt, p.stat().st_mtime)
except OSError:
pass
return mt
def organize_orphans(dry_run: bool = False) -> int:
"""Loose textures at MODELS root → matching model folder or props/_loose."""
n = 0
for p in list(MODELS.iterdir()):
if not p.is_file():
continue
if p.suffix.lower() == ".glb" or p.name.endswith(".import") or p.name == "README.md":
continue
low = p.name.lower()
target: Path | None = None
for glb in MODELS.rglob("*.glb"):
stem = glb.stem.lower()
if low.startswith(stem):
if stem == "house" and low.startswith("house_b"):
continue
target = glb.parent
break
if target is None:
target = MODELS / "props" / "_loose"
log(f"orphan {p.name} → {target.relative_to(MODELS)}")
if not dry_run:
move_into(p, target)
imp = MODELS / (p.name + ".import")
if imp.exists():
move_into(imp, target)
fix_import_paths(target)
n += 1
return n
def organize_stable(stable_secs: float, dry_run: bool = False) -> int:
now = time.time()
n = 0
for glb in root_glbs():
age = now - bundle_mtime(glb)
if age < stable_secs:
log(f"wait {glb.name} ({stable_secs - age:.0f}s left)")
continue
organize_glb(glb, dry_run=dry_run)
n += 1
n += organize_orphans(dry_run=dry_run)
return n
def run_godot_import() -> None:
godot = Path(os.path.expanduser("~/.local/bin/godot"))
if not godot.exists():
which = shutil.which("godot")
godot = Path(which) if which else Path()
if not godot.exists():
log("godot not found — skip import")
return
log("godot --import …")
subprocess.run(
[str(godot), "--headless", "--path", str(ROOT), "--import"],
check=False,
)
def watch_loop(stable_secs: float, interval: float, do_import: bool) -> int:
INBOX.mkdir(parents=True, exist_ok=True)
log(f"watching {MODELS} + _inbox/ — organize after {stable_secs:.0f}s idle")
try:
while True:
n = organize_stable(stable_secs, dry_run=False)
if n > 0 and do_import:
run_godot_import()
time.sleep(interval)
except KeyboardInterrupt:
log("stopped")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--watch", action="store_true")
ap.add_argument("--stable-secs", type=float, default=45.0)
ap.add_argument("--interval", type=float, default=5.0)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--import", dest="do_import", action="store_true")
ap.add_argument("--force", action="store_true", help="Ignore debounce (stable-secs=0)")
args = ap.parse_args()
if not MODELS.is_dir():
log(f"missing {MODELS}")
return 1
stable = 0.0 if args.force else args.stable_secs
if args.watch:
# watch always uses configured debounce (force only for one-shot)
return watch_loop(args.stable_secs, args.interval, args.do_import)
n = organize_stable(stable, dry_run=args.dry_run)
log(f"organized={n}")
if n > 0 and args.do_import and not args.dry_run:
run_godot_import()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,9 +1,16 @@
extends CanvasLayer
## Global UI: pause, inventory, shop, settings, toast, dialog, burger-cham.
## Global UI: pause, inventory, shop, settings, toast, VN-dialog, burger-cham.
@onready var pause_panel: PanelContainer = $PausePanel
@onready var dialog_panel: PanelContainer = $DialogPanel
@onready var dialog_label: Label = $DialogPanel/Margin/DialogLabel
@onready var dialog_layer: Control = $DialogLayer
@onready var dialog_dim: ColorRect = $DialogLayer/Dim
@onready var dialog_panel: PanelContainer = $DialogLayer/DialogPanel
@onready var dialog_label: Label = $DialogLayer/DialogPanel/Margin/VBox/DialogLabel
@onready var speaker_label: Label = $DialogLayer/DialogPanel/Margin/VBox/SpeakerLabel
@onready var continue_hint: Label = $DialogLayer/DialogPanel/Margin/VBox/Footer/ContinueHint
@onready var advance_btn: Button = $DialogLayer/DialogPanel/Margin/VBox/Footer/AdvanceButton
@onready var bark_panel: PanelContainer = $BarkPanel
@onready var bark_label: Label = $BarkPanel/BarkLabel
@onready var toast_label: Label = $ToastLabel
@onready var burger_overlay: ColorRect = $BurgerOverlay
@onready var burger_label: Label = $BurgerOverlay/BurgerLabel
@ -12,20 +19,50 @@ extends CanvasLayer
@onready var sfx_slider: HSlider = $PausePanel/Margin/VBox/SfxSlider
@onready var music_slider: HSlider = $PausePanel/Margin/VBox/MusicSlider
@onready var lang_option: OptionButton = $PausePanel/Margin/VBox/LangOption
@onready var hp_label: Label = $HUD/HpLabel
@onready var hp_hearts: HBoxContainer = $HUD/HpHearts
@onready var status_label: Label = $HUD/StatusLabel
@onready var fat_stim: ColorRect = $FatStim
@onready var fat_label: Label = $FatStim/FatLabel
@onready var interact_prompt: Label = $HUD/InteractPrompt
@onready var attack_flash: ColorRect = $HUD/AttackFlash
const TYPEWRITER_CPS := 42.0
const PANEL_SLIDE := 36.0
var _dialog_timer: float = 0.0
var _fat_blink: float = 0.0
var _toast_brief_timer: float = 0.0
var _heart_full: Texture2D
var _heart_empty: Texture2D
## VN dialog state
var _dialog_open: bool = false
var _dialog_blocking: bool = false
var _dialog_paused_game: bool = false
var _dialog_full_text: String = ""
var _dialog_visible_chars: float = 0.0
var _dialog_typing: bool = false
var _dialog_queue: Array[Dictionary] = []
var _dialog_panel_rest_top: float = -210.0
var _bark_timer: float = 0.0
var _continue_blink: float = 0.0
var _voice_cast: Dictionary = {}
var _open_tween: Tween
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
layer = 100
_heart_full = load("res://assets/sprites/placeholders/heart.png")
_heart_empty = load("res://assets/sprites/placeholders/heart_empty.png")
_load_voice_cast()
_style_dialog_panel()
pause_panel.visible = false
dialog_panel.visible = false
dialog_layer.visible = false
bark_panel.visible = false
burger_overlay.visible = false
toast_label.visible = false
_dialog_panel_rest_top = dialog_panel.offset_top
if advance_btn:
advance_btn.pressed.connect(_on_dialog_advance_pressed)
_setup_lang_option()
_refresh_texts()
sfx_slider.value = Settings.sfx_volume
@ -42,14 +79,126 @@ func _ready() -> void:
_on_hp(GameState.hp, GameState.max_hp)
_refresh_inventory()
_refresh_points(Shop.dressler_points)
if interact_prompt:
interact_prompt.visible = false
if attack_flash:
attack_flash.visible = false
func _load_voice_cast() -> void:
var f := FileAccess.open("res://data/voice_cast.json", FileAccess.READ)
if f == null:
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
if typeof(parsed) == TYPE_DICTIONARY:
_voice_cast = parsed
func _style_dialog_panel() -> void:
var box := StyleBoxFlat.new()
box.bg_color = Color(0.07, 0.08, 0.12, 0.94)
box.border_color = Color(0.85, 0.72, 0.4, 0.85)
box.set_border_width_all(2)
box.set_corner_radius_all(10)
box.content_margin_left = 4
box.content_margin_right = 4
box.content_margin_top = 4
box.content_margin_bottom = 4
dialog_panel.add_theme_stylebox_override("panel", box)
var bark_box := box.duplicate() as StyleBoxFlat
bark_box.bg_color = Color(0.1, 0.08, 0.06, 0.88)
bark_panel.add_theme_stylebox_override("panel", bark_box)
func _process(delta: float) -> void:
if _dialog_timer > 0.0:
_dialog_timer -= delta
if _dialog_timer <= 0.0:
dialog_panel.visible = false
if _dialog_open and _dialog_typing:
_dialog_visible_chars += TYPEWRITER_CPS * delta
var shown := mini(int(_dialog_visible_chars), _dialog_full_text.length())
dialog_label.text = _dialog_full_text.substr(0, shown)
if shown >= _dialog_full_text.length():
_dialog_typing = false
_dialog_visible_chars = float(_dialog_full_text.length())
if _dialog_open and not _dialog_typing:
_continue_blink += delta
var blink_a: float = 0.45 + 0.4 * abs(sin(_continue_blink * 4.0))
continue_hint.modulate.a = blink_a
advance_btn.modulate.a = 0.85 + 0.15 * abs(sin(_continue_blink * 4.0))
if _bark_timer > 0.0:
_bark_timer -= delta
if _bark_timer <= 0.0:
bark_panel.visible = false
if _toast_brief_timer > 0.0:
_toast_brief_timer -= delta
_update_fat_stim(delta)
func _input(event: InputEvent) -> void:
if not _dialog_open or not _dialog_blocking:
return
# Advance first: Space is both ui_accept and interact — don't let interact steal it.
if event.is_action_pressed("dialog_advance") or event.is_action_pressed("ui_accept"):
_advance_dialog()
get_viewport().set_input_as_handled()
return
# Gameplay-only keys must NOT dismiss / advance dialog (E / attack click)
if event.is_action_pressed("interact") or event.is_action_pressed("attack"):
get_viewport().set_input_as_handled()
return
func set_interact_prompt(text: String) -> void:
if interact_prompt == null:
return
if _dialog_open or text == "" or GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene", "catscene"]:
interact_prompt.visible = false
return
interact_prompt.text = text
interact_prompt.visible = true
func _wait(sec: float) -> void:
## Safe timer: works when paused; never call create_timer on a null tree.
var tree := get_tree()
if tree == null:
tree = Engine.get_main_loop() as SceneTree
if tree == null:
return
await tree.create_timer(sec, true).timeout
func force_close_dialog() -> void:
## Called on scene change so pause/dialog state cannot leak to title.
_dialog_queue.clear()
_dialog_open = false
_dialog_typing = false
_dialog_blocking = false
if dialog_layer:
dialog_layer.visible = false
if bark_panel:
bark_panel.visible = false
_bark_timer = 0.0
_dialog_paused_game = false
_sync_player_focus(true)
func pulse_attack() -> void:
if attack_flash == null:
return
attack_flash.color = Color(1.0, 0.85, 0.3, 0.18)
attack_flash.visible = true
await _wait(0.08)
if is_instance_valid(attack_flash):
attack_flash.visible = false
func pulse_interact() -> void:
if attack_flash == null:
return
attack_flash.color = Color(0.5, 1.0, 0.7, 0.14)
attack_flash.visible = true
await _wait(0.1)
if is_instance_valid(attack_flash):
attack_flash.visible = false
func show_toast_brief(text: String, duration: float = 0.5) -> void:
toast_label.text = text
toast_label.visible = true
_toast_brief_timer = duration
await _wait(duration)
if _toast_brief_timer <= 0.0:
toast_label.visible = false
func _update_fat_stim(delta: float) -> void:
if fat_stim == null:
return
@ -59,8 +208,23 @@ func _update_fat_stim(delta: float) -> void:
_fat_blink += delta
fat_label.text = Locale.tr_key("dialog_tiramisu")
fat_stim.color = Color(0.05, 0.05, 0.08, 0.35 + 0.25 * abs(sin(_fat_blink * 3.0)))
var stim := fat_stim.get_node_or_null("StimImage") as TextureRect
if stim == null:
stim = TextureRect.new()
stim.name = "StimImage"
fat_stim.add_child(stim)
stim.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
stim.offset_left = -80.0
stim.offset_top = -200.0
stim.offset_right = 80.0
stim.offset_bottom = -40.0
stim.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
stim.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
stim.texture = load("res://assets/sprites/placeholders/shorts_stim.png")
func _unhandled_input(event: InputEvent) -> void:
if _dialog_open and _dialog_blocking:
return
if event.is_action_pressed("pause_menu"):
toggle_pause()
get_viewport().set_input_as_handled()
@ -68,6 +232,8 @@ func _unhandled_input(event: InputEvent) -> void:
func toggle_pause() -> void:
if GameState.current_scene_id in ["title_screen", "death_screen", "intro_cutscene", "catscene"]:
return
if _dialog_open and _dialog_blocking:
return
pause_panel.visible = not pause_panel.visible
get_tree().paused = pause_panel.visible
_sync_player_focus(not pause_panel.visible)
@ -80,10 +246,134 @@ func _sync_player_focus(focused: bool) -> void:
if node.has_method("set_gameplay_focused"):
node.set_gameplay_focused(focused)
func show_dialog(text: String, duration: float = 3.5) -> void:
dialog_label.text = text
dialog_panel.visible = true
_dialog_timer = duration
## Blocking VN dialog (pauses world). `duration` ignored for dismiss — user advances with Enter/C.
func show_dialog(text: String, _duration: float = 3.5, voice_key: String = "", blocking: bool = true) -> void:
if not blocking:
_show_bark(text, maxf(_duration, 2.0), voice_key)
return
_dialog_queue.append({"text": text, "voice_key": voice_key})
if not _dialog_open:
_present_next_dialog()
func show_dialog_key(key: String, duration: float = 3.5, blocking: bool = true) -> void:
show_dialog(Locale.tr_key(key), duration, key, blocking)
func is_dialog_open() -> bool:
return _dialog_open and _dialog_blocking
func _show_bark(text: String, duration: float, voice_key: String) -> void:
bark_label.text = text
bark_panel.visible = true
_bark_timer = duration
if voice_key != "":
AudioDirector.play_dialog_line(voice_key)
func _present_next_dialog() -> void:
if _dialog_queue.is_empty():
_close_dialog()
return
var entry: Dictionary = _dialog_queue.pop_front()
var text: String = str(entry.get("text", ""))
var voice_key: String = str(entry.get("voice_key", ""))
_dialog_full_text = text
_dialog_visible_chars = 0.0
_dialog_typing = text.length() > 0
_dialog_blocking = true
_dialog_open = true
dialog_label.text = ""
speaker_label.text = _speaker_for_key(voice_key)
continue_hint.text = Locale.tr_key("dialog_continue")
advance_btn.text = Locale.tr_key("dialog_advance_btn")
dialog_layer.visible = true
if interact_prompt:
interact_prompt.visible = false
_begin_dialog_pause()
# Player _ready may run after this and steal mouse — re-assert after scene settles.
call_deferred("_ensure_dialog_input_ready")
_play_open_anim()
if voice_key != "":
AudioDirector.play_dialog_line(voice_key)
func _speaker_for_key(voice_key: String) -> String:
if voice_key == "":
return Locale.tr_key("speaker_system")
var lines: Variant = _voice_cast.get("lines", {})
if typeof(lines) == TYPE_DICTIONARY and lines.has(voice_key):
var npc := str(lines[voice_key])
var sk := "speaker_%s" % npc
var named := Locale.tr_key(sk)
if named != sk:
return named
return npc.capitalize()
return Locale.tr_key("speaker_system")
func _play_open_anim() -> void:
if _open_tween and _open_tween.is_running():
_open_tween.kill()
dialog_dim.color.a = 0.0
dialog_panel.modulate.a = 0.0
dialog_panel.offset_top = _dialog_panel_rest_top + PANEL_SLIDE
dialog_panel.offset_bottom = -28.0 + PANEL_SLIDE
_open_tween = create_tween()
_open_tween.set_parallel(true)
_open_tween.tween_property(dialog_dim, "color:a", 0.55, 0.28).set_ease(Tween.EASE_OUT)
_open_tween.tween_property(dialog_panel, "modulate:a", 1.0, 0.32).set_ease(Tween.EASE_OUT)
_open_tween.tween_property(dialog_panel, "offset_top", _dialog_panel_rest_top, 0.35).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_open_tween.tween_property(dialog_panel, "offset_bottom", -28.0, 0.35).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
func _advance_dialog() -> void:
if not _dialog_open:
return
if _dialog_typing:
# First confirm: finish typewriter
_dialog_typing = false
_dialog_visible_chars = float(_dialog_full_text.length())
dialog_label.text = _dialog_full_text
return
# Second confirm: next line or close
if not _dialog_queue.is_empty():
_present_next_dialog()
else:
_close_dialog()
func _on_dialog_advance_pressed() -> void:
_advance_dialog()
func _begin_dialog_pause() -> void:
if pause_panel.visible:
_dialog_paused_game = false
return
if not get_tree().paused:
get_tree().paused = true
_dialog_paused_game = true
else:
_dialog_paused_game = true
_sync_player_focus(false)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _ensure_dialog_input_ready() -> void:
if not _dialog_open or not _dialog_blocking:
return
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_sync_player_focus(false)
if advance_btn:
advance_btn.disabled = false
advance_btn.grab_focus()
func _end_dialog_pause() -> void:
if _dialog_paused_game and not pause_panel.visible:
get_tree().paused = false
_sync_player_focus(true)
_dialog_paused_game = false
func _close_dialog() -> void:
_dialog_open = false
_dialog_blocking = false
_dialog_typing = false
_dialog_queue.clear()
dialog_layer.visible = false
AudioDirector.stop_voice()
_end_dialog_pause()
func _setup_lang_option() -> void:
lang_option.clear()
@ -103,14 +393,14 @@ func _refresh_texts() -> void:
$PausePanel/Margin/VBox/ResumeButton.text = Locale.tr_key("menu_resume")
$PausePanel/Margin/VBox/ShopTitle.text = Locale.tr_key("menu_shop")
$PausePanel/Margin/VBox/BuyPointButton.text = Locale.tr_key("menu_buy_point")
$PausePanel/Margin/VBox/BuySunglassesButton.text = Locale.tr_key("shop_sunglasses")
$PausePanel/Margin/VBox/BuyTicketButton.text = Locale.tr_key("shop_ticket")
$PausePanel/Margin/VBox/BuyMilkButton.text = Locale.tr_key("shop_continue_joke")
$PausePanel/Margin/VBox/BuyTiramisuButton.text = Locale.tr_key("shop_next_level")
$PausePanel/Margin/VBox/SfxLabel.text = Locale.tr_key("menu_sfx")
$PausePanel/Margin/VBox/MusicLabel.text = Locale.tr_key("menu_music")
$PausePanel/Margin/VBox/LangLabel.text = Locale.tr_key("menu_language")
$PausePanel/Margin/VBox/InventoryLabel.text = Locale.tr_key("menu_inventory")
if continue_hint:
continue_hint.text = Locale.tr_key("dialog_continue")
if advance_btn:
advance_btn.text = Locale.tr_key("dialog_advance_btn")
_refresh_points(Shop.dressler_points)
func _refresh_points(points: int) -> void:
@ -118,34 +408,81 @@ func _refresh_points(points: int) -> void:
func _refresh_inventory() -> void:
inventory_list.clear()
inventory_list.fixed_icon_size = Vector2i(int(SpriteFit.UI_ICON_PX), int(SpriteFit.UI_ICON_PX))
var items := Inventory.get_all()
for id in items.keys():
var count: int = int(items[id])
var item_name: String = Locale.tr_key("item_" + str(id))
var idx := inventory_list.item_count
inventory_list.add_item("%s x%d" % [item_name, count])
inventory_list.set_item_metadata(inventory_list.item_count - 1, id)
inventory_list.set_item_metadata(idx, id)
var icon_path := Inventory.get_icon_path(str(id))
if icon_path != "":
var tex: Texture2D = load(icon_path)
if tex:
inventory_list.set_item_icon(idx, tex)
func _on_hp(hp: int, max_hp: int) -> void:
_rebuild_hearts(hp, max_hp)
var extra := ""
if GameState.is_fat():
extra += " | " + Locale.tr_key("status_fat", [str(int(GameState.fat_time_left))])
extra += Locale.tr_key("status_fat", [str(int(GameState.fat_time_left))])
if GameState.is_shocked():
extra += " | " + Locale.tr_key("status_shock")
if extra != "":
extra += " · "
extra += Locale.tr_key("status_shock")
if GameState.is_glitched_slow():
extra += " | " + Locale.tr_key("status_glitch")
hp_label.text = "HP %d/%d%s" % [hp, max_hp, extra]
if extra != "":
extra += " · "
extra += Locale.tr_key("status_glitch")
if status_label:
status_label.text = extra
status_label.visible = extra != ""
func _rebuild_hearts(hp: int, max_hp: int) -> void:
if hp_hearts == null:
return
while hp_hearts.get_child_count() > 0:
var c := hp_hearts.get_child(0)
hp_hearts.remove_child(c)
c.queue_free()
var n := maxi(max_hp, 1)
for i in range(n):
var tex_rect := TextureRect.new()
tex_rect.custom_minimum_size = Vector2(28, 28)
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tex_rect.texture = _heart_full if i < hp else _heart_empty
tex_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
hp_hearts.add_child(tex_rect)
func _on_achievement(_id: String, title: String) -> void:
toast_label.text = Locale.tr_key("toast_achievement", [title])
toast_label.visible = true
await get_tree().create_timer(3.0).timeout
toast_label.visible = false
await _wait(3.0)
if is_instance_valid(toast_label):
toast_label.visible = false
func _on_burger_cham() -> void:
burger_label.text = Locale.tr_key("burger_cham")
burger_overlay.visible = true
await get_tree().create_timer(2.5).timeout
burger_overlay.visible = false
var img := burger_overlay.get_node_or_null("BurgerImage") as TextureRect
if img == null:
img = TextureRect.new()
img.name = "BurgerImage"
burger_overlay.add_child(img)
img.set_anchors_preset(Control.PRESET_CENTER)
img.offset_left = -120.0
img.offset_top = -200.0
img.offset_right = 120.0
img.offset_bottom = -40.0
img.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
img.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
img.texture = load("res://assets/sprites/placeholders/burger_cham.png")
SpriteFit.apply_texture_rect_budget(img, 240.0)
await _wait(2.5)
if is_instance_valid(burger_overlay):
burger_overlay.visible = false
func _on_resume_pressed() -> void:
pause_panel.visible = false
@ -155,18 +492,6 @@ func _on_resume_pressed() -> void:
func _on_buy_point() -> void:
Shop.purchase("buy_point")
func _on_buy_sunglasses() -> void:
Shop.purchase("sunglasses")
func _on_buy_ticket() -> void:
Shop.purchase("death_ticket")
func _on_buy_milk() -> void:
Shop.purchase("continue_joke")
func _on_buy_tiramisu() -> void:
Shop.purchase("next_level")
func _on_sfx_changed(value: float) -> void:
Settings.set_sfx_volume(value)

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

159
scripts/vfx/melee_vfx.gd Normal file
View file

@ -0,0 +1,159 @@
extends Node3D
class_name MeleeVfx
## Visible melee swing (fist/tooth/sword) + range rings + hit particles.
@export var attack_range: float = 2.75
@export var interact_range: float = 2.8
var _swinging: bool = false
var _swing_t: float = 0.0
var _swing_dur: float = 0.22
var _weapon: Node3D
var _weapon_bill: BillboardSprite
var _atk_ring: MeshInstance3D
var _use_ring: MeshInstance3D
var _particles: CPUParticles3D
func _ready() -> void:
_build_weapon()
_build_rings()
_build_particles()
visible = true
func _build_weapon() -> void:
_weapon = Node3D.new()
_weapon.name = "WeaponSwing"
add_child(_weapon)
var packed: PackedScene = load("res://scenes/prefabs/billboard_sprite.tscn")
if packed:
_weapon_bill = packed.instantiate()
_weapon.add_child(_weapon_bill)
_weapon_bill.position = Vector3(0.85, 0.9, 0.0)
_weapon_bill.set_target_height(0.55)
_weapon_bill.max_width = 0.7
_weapon.visible = false
func _build_rings() -> void:
_atk_ring = _make_ring(Color(1.0, 0.35, 0.25, 0.35), attack_range)
_atk_ring.name = "AttackRangeRing"
add_child(_atk_ring)
_use_ring = _make_ring(Color(1.0, 0.9, 0.25, 0.28), interact_range)
_use_ring.name = "InteractRangeRing"
add_child(_use_ring)
_atk_ring.visible = false
_use_ring.visible = false
func _make_ring(color: Color, radius: float) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = maxf(0.05, radius - 0.06)
torus.outer_radius = radius
torus.rings = 12
torus.ring_segments = 24
mi.mesh = torus
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = color
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mi.material_override = mat
mi.position = Vector3(0, 0.05, 0)
mi.rotation_degrees = Vector3(0, 0, 0)
return mi
func _build_particles() -> void:
_particles = CPUParticles3D.new()
_particles.name = "HitParticles"
_particles.emitting = false
_particles.one_shot = true
_particles.explosiveness = 0.95
_particles.amount = 18
_particles.lifetime = 0.35
_particles.direction = Vector3(0, 1, 0)
_particles.spread = 180.0
_particles.initial_velocity_min = 2.5
_particles.initial_velocity_max = 6.0
_particles.gravity = Vector3(0, -8, 0)
_particles.scale_amount_min = 0.08
_particles.scale_amount_max = 0.18
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(1.0, 0.85, 0.3)
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
_particles.mesh = SphereMesh.new()
(_particles.mesh as SphereMesh).radius = 0.08
(_particles.mesh as SphereMesh).height = 0.16
_particles.material_override = mat
add_child(_particles)
func _process(delta: float) -> void:
if _swinging:
_swing_t += delta
var u := clampf(_swing_t / _swing_dur, 0.0, 1.0)
# Arc from -70° to +70° around player up-axis
var ang := deg_to_rad(lerp(-70.0, 70.0, u))
_weapon.rotation = Vector3(0, ang, 0)
_weapon.position = Vector3(0, 0.15 + 0.1 * sin(u * PI), 0)
if u >= 1.0:
_swinging = false
_weapon.visible = false
func set_ranges(attack_r: float, interact_r: float) -> void:
attack_range = attack_r
interact_range = interact_r
_resize_ring(_atk_ring, attack_r)
_resize_ring(_use_ring, interact_r)
func _resize_ring(mi: MeshInstance3D, radius: float) -> void:
if mi == null:
return
var torus := TorusMesh.new()
torus.inner_radius = maxf(0.05, radius - 0.07)
torus.outer_radius = radius
torus.rings = 12
torus.ring_segments = 24
mi.mesh = torus
func show_attack_range(on: bool) -> void:
if _atk_ring:
_atk_ring.visible = on
func show_interact_range(on: bool) -> void:
if _use_ring:
_use_ring.visible = on
func play_swing() -> void:
_refresh_weapon_texture()
_swinging = true
_swing_t = 0.0
_weapon.visible = true
_weapon.rotation = Vector3(0, deg_to_rad(-70), 0)
func burst_at(world_pos: Vector3, hit: bool) -> void:
if _particles == null:
return
_particles.global_position = world_pos + Vector3(0, 0.9, 0)
var mat := _particles.material_override as StandardMaterial3D
if mat:
mat.albedo_color = Color(1.0, 0.35, 0.25) if hit else Color(0.7, 0.7, 0.75)
_particles.restart()
_particles.emitting = true
func _refresh_weapon_texture() -> void:
if _weapon_bill == null:
return
var path := "res://assets/sprites/placeholders/fist.png"
var h := 0.55
if Inventory.has_item("moon_sword"):
path = "res://assets/sprites/placeholders/moon_sword.png"
h = 0.9
elif Inventory.has_item("wartchen"):
path = "res://assets/sprites/placeholders/wartchen.png"
h = 0.65
elif Inventory.has_item("tooth"):
path = "res://assets/sprites/placeholders/tooth.png"
h = 0.6
_weapon_bill.set_target_height(h)
var tex: Texture2D = load(path)
if tex:
_weapon_bill.set_texture(tex)