extends Control ## #528: Audio settings dialog — 5-bus volume sliders. ## #646: AI-Enhanced Dialogue toggle + hardware detection status (D-138). ## Opens on OPEN_MENU (ESC) from main.gd. Closes on OPEN_MENU again or CLOSE button. ## Volumes persist via AudioManager._save_prefs() on each slider change. ## AI Dialogue toggle persists via ConfigFile (client-local) + ChangeSettings IPC (server SQLite). signal closed signal debug_console_toggled(enabled: bool) # #581: debug console enabled/disabled signal ai_dialogue_toggled(enabled: bool) # #646: AI-Enhanced Dialogue enabled/disabled const BG_COLOR := Color(0.05, 0.05, 0.08, 0.90) const BORDER_COLOR := Color("#4a9ebb") const TEXT_COLOR := Color(0.878, 0.969, 0.98, 1) const TITLE_COLOR := Color("#4a9ebb") const STATUS_GREEN := Color("#6bc9a6") const STATUS_YELLOW := Color("#e8c547") const STATUS_RED := Color("#c84040") const FONT_SIZE := 14 const FONT_SIZE_SMALL := 11 ## Shared client preferences file — avoids compile-time dependency on DebugConsole for path. const SETTINGS_CFG_PATH := "user://settings.cfg" const BOX_WIDTH := 460 const BOX_HEIGHT := 500 # +36 debug console, +88 AI dialogue section const PADDING := 20 const ROW_HEIGHT := 36 # Bus display labels → bus name strings (must match AudioManager BUS_* constants) const BUS_ROWS: Array = [ ["Music", "Music"], ["Ambient", "Ambient"], ["World SFX", "WorldSFX"], ["Player Actions", "PlayerActions"], ["UI Sounds", "UISounds"], ] var _active: bool = false var _container: VBoxContainer = null # #646: AI Dialogue hardware status and toggle node ref — used by testable API methods var _ai_hw_status: String = "" # "pass" | "marginal" | "fail" | "" (not yet checked) var _ai_check_node: CheckButton = null var _ai_inference_suspended: bool = false # D-138 §8 Layer 3 battery auto-suspend state var _ai_battery_warning_label: Label = null # shown when on battery; toggle stays enabled func _ready() -> void: visible = false mouse_filter = Control.MOUSE_FILTER_STOP func open() -> void: if _active: return _active = true visible = true _build_ui() queue_redraw() func close() -> void: if not _active: return _active = false visible = false _destroy_ui() queue_redraw() closed.emit() func is_open() -> bool: return _active func _build_ui() -> void: var vp_size := get_viewport_rect().size var box_pos := Vector2((vp_size.x - BOX_WIDTH) / 2.0, (vp_size.y - BOX_HEIGHT) / 2.0) _container = VBoxContainer.new() _container.position = box_pos + Vector2(PADDING, PADDING + 28) _container.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0) _container.add_theme_constant_override("separation", 4) add_child(_container) for row_data in BUS_ROWS: var label_text: String = row_data[0] var bus_name: String = row_data[1] var hbox := HBoxContainer.new() hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) _container.add_child(hbox) var label := Label.new() label.text = label_text label.custom_minimum_size = Vector2(150, 0) label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER label.add_theme_font_size_override("font_size", FONT_SIZE) label.add_theme_color_override("font_color", TEXT_COLOR) hbox.add_child(label) var slider := HSlider.new() slider.min_value = -40.0 slider.max_value = 0.0 slider.step = 0.5 slider.value = AudioManager.get_volume(bus_name) slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL hbox.add_child(slider) var db_label := Label.new() db_label.text = _format_db(slider.value) db_label.custom_minimum_size = Vector2(70, 0) db_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT db_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER db_label.add_theme_font_size_override("font_size", FONT_SIZE) db_label.add_theme_color_override("font_color", TEXT_COLOR) hbox.add_child(db_label) slider.value_changed.connect( func(value: float) -> void: AudioManager.set_volume(bus_name, value) db_label.text = _format_db(value) ) # #581: Debug Console toggle var debug_hbox := HBoxContainer.new() debug_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) _container.add_child(debug_hbox) var debug_label := Label.new() debug_label.text = "Debug Console" debug_label.custom_minimum_size = Vector2(150, 0) debug_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER debug_label.add_theme_font_size_override("font_size", FONT_SIZE) debug_label.add_theme_color_override("font_color", TEXT_COLOR) debug_hbox.add_child(debug_label) var debug_check := CheckButton.new() # Query live DebugConsole node if available; fall back to prefs file var console_node := get_node_or_null("/root/Main/ModalLayer/DebugConsole") if console_node and console_node.has_method("is_enabled"): debug_check.button_pressed = console_node.is_enabled() else: var cfg := ConfigFile.new() debug_check.button_pressed = true if cfg.load(DebugConsole.PREFS_PATH) == OK: debug_check.button_pressed = cfg.get_value( DebugConsole.PREFS_SECTION, DebugConsole.PREFS_KEY_ENABLED, true ) debug_check.toggled.connect(func(enabled: bool) -> void: debug_console_toggled.emit(enabled)) debug_hbox.add_child(debug_check) # #646: AI Dialogue section divider var ai_divider := Control.new() ai_divider.custom_minimum_size = Vector2(0, 8) _container.add_child(ai_divider) var ai_section_label := Label.new() ai_section_label.text = "AI DIALOGUE" ai_section_label.add_theme_font_size_override("font_size", FONT_SIZE - 2) ai_section_label.add_theme_color_override("font_color", TITLE_COLOR) _container.add_child(ai_section_label) # #646: Detect hardware and set status var ram_result := HardwareDetector.check_ram() var tpt_cache: Variant = HardwareDetector.read_benchmark_cache() var ram_class: String = ram_result["classification"] var tpt_class: String = "green" var tpt_tps: float = 0.0 if tpt_cache != null: tpt_class = tpt_cache["classification"] tpt_tps = tpt_cache["tps"] # Composite status: RAM fail overrides TPT; RAM marginal keeps its own category var hw_status: String if ram_class == "fail": hw_status = "fail" elif ram_class == "marginal": hw_status = "marginal" else: # RAM passes — status follows TPT if benchmarked, else "pass" hw_status = "pass" if tpt_cache == null else tpt_class set_ai_dialogue_hardware_status(hw_status) # D-138 §8 Layer 3: reflect current battery-suspension state from HardwareDetector. _ai_inference_suspended = HardwareDetector.inference_suspended var ai_hbox := HBoxContainer.new() ai_hbox.custom_minimum_size = Vector2(0, ROW_HEIGHT) _container.add_child(ai_hbox) var ai_label := Label.new() ai_label.text = get_ai_dialogue_label_text() ai_label.custom_minimum_size = Vector2(200, 0) ai_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER ai_label.add_theme_font_size_override("font_size", FONT_SIZE) ai_label.add_theme_color_override("font_color", TEXT_COLOR) ai_hbox.add_child(ai_label) # Status dot — colored square indicating hardware classification var status_dot := ColorRect.new() status_dot.custom_minimum_size = Vector2(10, 10) status_dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER match _ai_hw_status: "pass", "green": status_dot.color = STATUS_GREEN "marginal", "yellow": status_dot.color = STATUS_YELLOW _: # "red", "fail" status_dot.color = STATUS_RED ai_hbox.add_child(status_dot) var dot_spacer := Control.new() dot_spacer.custom_minimum_size = Vector2(8, 0) ai_hbox.add_child(dot_spacer) _ai_check_node = CheckButton.new() _ai_check_node.button_pressed = GameState.ai_enhanced_dialogue_enabled _ai_check_node.disabled = (_ai_hw_status == "fail") ai_hbox.add_child(_ai_check_node) # Status message label — only shown when non-empty var status_msg: String = HardwareDetector.status_message( ram_class, tpt_class, ram_result["free_mb"], tpt_tps ) var ai_status_label := Label.new() ai_status_label.text = status_msg ai_status_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL) ai_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART ai_status_label.custom_minimum_size = Vector2(BOX_WIDTH - PADDING * 2, 0) match _ai_hw_status: "marginal", "yellow": ai_status_label.add_theme_color_override("font_color", STATUS_YELLOW) "red", "fail": ai_status_label.add_theme_color_override("font_color", STATUS_RED) _: ai_status_label.add_theme_color_override("font_color", TEXT_COLOR) ai_status_label.visible = not status_msg.is_empty() _container.add_child(ai_status_label) # Battery warning label — visible when on battery power; toggle stays enabled per D-138. _ai_battery_warning_label = Label.new() _ai_battery_warning_label.text = UIStrings.get_text("settings.ai_battery_warning") _ai_battery_warning_label.add_theme_font_size_override("font_size", FONT_SIZE_SMALL) _ai_battery_warning_label.add_theme_color_override("font_color", STATUS_YELLOW) _ai_battery_warning_label.visible = _ai_inference_suspended _container.add_child(_ai_battery_warning_label) _ai_check_node.toggled.connect( func(enabled: bool) -> void: GameState.ai_enhanced_dialogue_enabled = enabled _save_ai_pref(enabled) if SimBridge.state == SimBridge.ConnectionState.CONNECTED: ( SimBridge . send_input( { "action": InputMapper.Action.CHANGE_SETTINGS, "action_data": {"ai_enhanced_dialogue": enabled}, "timestamp_msec": Time.get_ticks_msec(), } ) ) ai_dialogue_toggled.emit(enabled) ) # Spacer var spacer := Control.new() spacer.custom_minimum_size = Vector2(0, 8) _container.add_child(spacer) # Close button var close_btn := Button.new() close_btn.text = "CLOSE" close_btn.add_theme_font_size_override("font_size", FONT_SIZE) close_btn.pressed.connect(close) _container.add_child(close_btn) # Quit to Menu button (#258: D-085 session management) var quit_btn := Button.new() quit_btn.text = UIStrings.get_text("menu.quit_to_menu") quit_btn.add_theme_font_size_override("font_size", FONT_SIZE) quit_btn.add_theme_color_override("font_color", Color("#c87040")) quit_btn.pressed.connect(_on_quit_to_menu) _container.add_child(quit_btn) func _destroy_ui() -> void: if _container: _container.queue_free() _container = null _ai_check_node = null # freed with _container _ai_battery_warning_label = null # -- #646: AI Dialogue testable API ------------------------------------------- ## Returns the canonical label text for the AI-Enhanced Dialogue toggle (D-138). ## Reads from UIStrings yaml (settings.ai_dialogue_toggle) for localization support. func get_ai_dialogue_label_text() -> String: return UIStrings.get_text("settings.ai_dialogue_toggle") ## Set the hardware detection status — drives toggle enabled/disabled state. ## Accepts: "pass" | "marginal" | "fail" (RAM) or "green" | "yellow" | "red" (TPT). ## Only "fail" (insufficient RAM) disables the toggle. All others leave it enabled — ## D-138: no hard minimum spec floor; player can always override recommendations. func set_ai_dialogue_hardware_status(status: String) -> void: _ai_hw_status = status if _ai_check_node != null: _ai_check_node.disabled = (status == "fail") ## Returns true if the AI-Enhanced Dialogue toggle is currently enabled (not greyed out). ## Only disabled by RAM "fail" — battery suspension shows a warning but never greys the toggle. func is_ai_dialogue_toggle_enabled() -> bool: return _ai_hw_status != "fail" ## Returns true if AI inference is currently auto-suspended due to battery power (D-138 §8 Layer 3). ## Set by set_ai_inference_suspended() when HardwareDetector detects battery profile. func is_ai_inference_suspended() -> bool: return _ai_inference_suspended ## Returns true if the battery warning label is currently visible. func get_battery_warning_visible() -> bool: if _ai_battery_warning_label == null: return false return _ai_battery_warning_label.visible ## Update battery suspension display state. ## Shows/hides the battery warning label; toggle stays enabled — player autonomy wins (D-138). ## Called when PlatformInfo.power_profile_changed fires (via main.gd or HardwareDetector). ## Tests can inject suspended=true to verify the warning label appears. func set_ai_inference_suspended(suspended: bool) -> void: _ai_inference_suspended = suspended if _ai_battery_warning_label != null: _ai_battery_warning_label.visible = suspended ## Persist the AI Dialogue enabled state to the local prefs file. ## Uses the same settings.cfg as DebugConsole — different section ("ai_dialogue"). func _save_ai_pref(enabled: bool) -> void: var cfg := ConfigFile.new() cfg.load(SETTINGS_CFG_PATH) cfg.set_value("ai_dialogue", "enabled", enabled) cfg.save(SETTINGS_CFG_PATH) func _draw() -> void: if not _active: return var viewport_size := get_viewport_rect().size # Dim overlay draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR) # Dialog box var box_pos := Vector2( (viewport_size.x - BOX_WIDTH) / 2.0, (viewport_size.y - BOX_HEIGHT) / 2.0 ) var box_rect := Rect2(box_pos, Vector2(BOX_WIDTH, BOX_HEIGHT)) draw_rect(box_rect, Color(0.08, 0.08, 0.12, 0.95)) draw_rect(box_rect, BORDER_COLOR, false, 1.0) # Title var font := ThemeDB.fallback_font draw_string( font, box_pos + Vector2(PADDING, PADDING + 18), "SETTINGS", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 2, TITLE_COLOR ) func _on_quit_to_menu() -> void: close() SessionManager.quit_to_menu() static func _format_db(db: float) -> String: if db <= -40.0: return "mute" return "%d dB" % int(db)