Explosion_der_Spiele/scripts/ui/ui_root.gd
copilot 1cc8aa9b9d Add village 3D wiring, voice lines, and 1.74 m world scale.
Houses ~8.5 m; chimps capped ~0.85 m with safer axis fitting. Large GLB/PBR packs stay local until Git LFS (Forgejo HTTP 413).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:02:57 +02:00

515 lines
18 KiB
GDScript

extends CanvasLayer
## Global UI: pause, inventory, shop, settings, toast, VN-dialog, burger-cham.
@onready var pause_panel: PanelContainer = $PausePanel
@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
@onready var points_label: Label = $PausePanel/Margin/VBox/PointsLabel
@onready var inventory_list: ItemList = $PausePanel/Margin/VBox/InventoryList
@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_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 _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_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
music_slider.value = 1.0
music_slider.editable = false
Achievements.unlocked.connect(_on_achievement)
AudioDirector.burger_cham_triggered.connect(_on_burger_cham)
Settings.settings_changed.connect(_refresh_texts)
Locale.locale_changed.connect(_refresh_texts)
Inventory.inventory_changed.connect(_refresh_inventory)
Shop.points_changed.connect(_refresh_points)
GameState.hp_changed.connect(_on_hp)
GameState.status_changed.connect(func(): _on_hp(GameState.hp, GameState.max_hp))
_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_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
var fat := GameState.is_fat()
fat_stim.visible = fat and GameState.current_scene_id not in ["title_screen", "death_screen", "intro_cutscene", "catscene"]
if fat:
_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()
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)
if pause_panel.visible:
_refresh_texts()
_refresh_inventory()
func _sync_player_focus(focused: bool) -> void:
for node in get_tree().get_nodes_in_group("player"):
if node.has_method("set_gameplay_focused"):
node.set_gameplay_focused(focused)
## 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()
lang_option.add_item("Deutsch", 0)
lang_option.add_item("English", 1)
lang_option.add_item("Bayerisch", 2)
match Settings.language:
"en":
lang_option.select(1)
"by":
lang_option.select(2)
_:
lang_option.select(0)
func _refresh_texts() -> void:
$PausePanel/Margin/VBox/TitleLabel.text = Locale.tr_key("menu_settings")
$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/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:
points_label.text = Locale.tr_key("menu_points", [str(points)])
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(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))])
if GameState.is_shocked():
if extra != "":
extra += " · "
extra += Locale.tr_key("status_shock")
if GameState.is_glitched_slow():
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 _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
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
get_tree().paused = false
_sync_player_focus(true)
func _on_buy_point() -> void:
Shop.purchase("buy_point")
func _on_sfx_changed(value: float) -> void:
Settings.set_sfx_volume(value)
func _on_music_changed(_value: float) -> void:
Settings.set_music_volume(1.0)
music_slider.value = 1.0
func _on_lang_selected(index: int) -> void:
match index:
1:
Settings.set_language("en")
2:
Settings.set_language("by")
_:
Settings.set_language("de")
func _on_inventory_activated(index: int) -> void:
var id: Variant = inventory_list.get_item_metadata(index)
if typeof(id) == TYPE_STRING:
Inventory.use_item(str(id))
_refresh_inventory()