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()