W1/A2: add SimBridge.CONNECTED guard before send_input in toggle handler
W2/A1: assert PlatformInfo != null in HardwareDetector._ready();
add ordering comment in project.godot [autoload] section
W3: replace FileAccess.open() with get_file_as_string() in
read_benchmark_cache() — auto-closes, no leak
W4/A3: add _extract_bool_setting() helper in game_state.gd that handles
both {"Bool": true} and plain bool; push_warning on type mismatch
S5: sync _pre_battery_pref from GameState after load_ai_pref() in
HardwareDetector._ready() — closes race if system starts on battery
S6: separate "red" TPT status into its own arm with STATUS_RED dot and
label colour — three-colour mapping: green/yellow/red now distinct
A4: replace DebugConsole.PREFS_PATH compile-time dependency in
_save_ai_pref() with local SETTINGS_CFG_PATH constant
S7 (battery CI guard) already applied by Hoshe in test file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
381 lines
13 KiB
GDScript
381 lines
13 KiB
GDScript
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).
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
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)
|