refactor(ui): migrate 6 meta screens to MetaScreen pattern (Workstream 2)
Relocates main_menu, character_creation, settings_dialog, debug_console,
bug_report_dialog, loading_screen from flat client/ui/ into structured
client/ui/meta/screens/<name>/. All six now extend MetaScreen instead
of Control; the base handles open/close lifecycle, visibility,
captures_input, and — for overlays — the sim-pause contract.
Screen policies set per Tyre's proposal:
- settings_dialog: pauses_sim=false, PUSHES onto MetaStack
- debug_console: pauses_sim=true, PUSHES (D-088 routing via base)
- bug_report_dialog: pauses_sim=true, PUSHES
- loading_screen: closable_by_escape=false, PUSHES
- main_menu, character_creation: scene-roots, extend MetaScreen for
the lifecycle contract only, do NOT push onto the stack
character_creation stays at its current surface (tabs, descriptor,
creation_confirmed signal unchanged). Tab consolidation and
CharacterProfile migration happen in Workstreams 5 and 6.
Knock-on changes:
- main.tscn ModalLayer CanvasLayer renamed to MetaLayer; main.gd
@onready refs updated; constants.gd comment updated; test_client_p3
and test_ui_framework_sprint15 assertions updated; test_monologue_display
and .tscn header comments updated.
- OPEN_MENU handler now pushes settings_dialog onto MetaStack before
calling open(). Full ESC priority chain lands in Workstream 4.
- atlas_app.gd: _unhandled_key_input signature widened from
InputEventKey to InputEvent with an is-check, per Godot 4 API. Pre-
existing narrowing was silently tolerated until main.tscn started
fully instantiating under the new pattern.
- test_client_p3: entity_renderer type annotations corrected from
ColorRect to Sprite2D (stale since a prior refactor); facing
indicator rotation assertion switched to angle_difference() for
modular-safe comparison.
Verification:
- gdlint client/scripts/ client/ui/ — zero problems
- godot --headless --path client --quit — no SCRIPT ERROR
- test_client_p3: 24/24 pass
- test_ui_framework_sprint15: 54/54 pass
- test_implant_nav_stack: 52/52 pass
- test_implant_registry: 42/42 pass
- test_implant_app_lifecycle: 36/36 pass
Workstream 1 foundation (84105916) remains unchanged. Workstreams 3-8
follow: protocol layer, Option A sequencing, 3-tab restructure,
Bookmark tab, location picker, Skills stub.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
extends MetaScreen
|
||||
|
||||
## #507: WRONG button — full 60-tick capture: ring buffer, snapshot history, replay seed.
|
||||
## Upgrade of the Sprint 9 MVP (#495).
|
||||
##
|
||||
## On F12: pause sim, show one-line prompt, save all ring buffer data, unpause.
|
||||
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
|
||||
## - snapshot.json — single-tick point-in-time (MVP compat)
|
||||
## - render.txt — client-side text render
|
||||
## - description.txt — tester notes + room/tick/seed metadata
|
||||
## - inputs.jsonl — last 60 ticks of PlayerInput (replay-compatible JSONL)
|
||||
## - snapshots.jsonl — last 60 ticks of ObserverSnapshot (one JSON per line)
|
||||
## - seed.txt — RNG seed for deterministic replay
|
||||
## - screenshot.png — viewport capture taken before dialog opened
|
||||
##
|
||||
## Ring buffer: pre-allocated RING_SIZE arrays at startup. record_tick() is the
|
||||
## public API for main.gd. _push_tick_inputs() / _push_tick_snapshot() are the
|
||||
## internal implementations, exposed for unit testing (test_bug_report_ring_buffer.gd).
|
||||
##
|
||||
## Spec: inputs.jsonl is compatible with tooling/test-client --replay (replay.rs).
|
||||
## Format: one JSON array per line, each array = Vec<PlayerInput> for that tick.
|
||||
|
||||
signal capture_completed
|
||||
signal capture_cancelled
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.85)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color("#c8d0e0")
|
||||
const FONT_SIZE := 14
|
||||
const LABEL_FONT_SIZE := 13
|
||||
const BOX_WIDTH := 500
|
||||
const BOX_HEIGHT := 120
|
||||
const PADDING := 16
|
||||
|
||||
# #507: Ring buffer capacity — 60 ticks of history
|
||||
const RING_SIZE := 60
|
||||
|
||||
var _line_edit: LineEdit = null
|
||||
var _captured_screenshot: Image = null
|
||||
|
||||
# #507: Pre-allocated ring buffers (no per-tick allocation after _ready).
|
||||
# Input ring: replay-format PlayerInput arrays, one per tick.
|
||||
# Snapshot ring: ObserverSnapshot JSON strings, one per tick.
|
||||
# Separate heads and counts so each buffer can be tested independently.
|
||||
# Memory ceiling: 60 snapshot JSON strings (each ~2-8KB depending on entity count)
|
||||
# + 60 input arrays (negligible). Worst case ~480KB resident. Acceptable for a
|
||||
# debug tool that is always active during Gauntlet play.
|
||||
var _input_ring: Array = [] # Array[Array] — each slot: Array of {tick, action} dicts
|
||||
var _input_head: int = 0 # Next write index (0..RING_SIZE-1)
|
||||
var _input_count: int = 0 # Filled slot count (0..RING_SIZE)
|
||||
|
||||
var _snapshot_ring: Array = [] # Array[String] — each slot: JSON-serialized ObserverSnapshot
|
||||
var _snapshot_head: int = 0
|
||||
var _snapshot_count: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
pauses_sim = true
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
||||
# Pre-allocate ring buffers — resize then fill sentinels.
|
||||
# The ring array itself never grows after _ready. Each write replaces the GDScript
|
||||
# reference in an existing slot (not a new allocation of the ring), though the input
|
||||
# Array stored per slot is a fresh ref each tick.
|
||||
_input_ring.resize(RING_SIZE)
|
||||
_snapshot_ring.resize(RING_SIZE)
|
||||
for i in range(RING_SIZE):
|
||||
_input_ring[i] = []
|
||||
_snapshot_ring[i] = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API for main.gd: record one tick's data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Record one tick. Called from main.gd on every server tick (snapshot arrival).
|
||||
## - tick: current server tick number
|
||||
## - snapshot_json: JSON.stringify(GameState.current_snapshot)
|
||||
## - mapper_inputs: Array of InputMapper dicts (BUG_REPORT/OPEN_MENU excluded).
|
||||
## These are in raw InputMapper format and will be converted to replay format.
|
||||
func record_tick(tick: int, snapshot_json: String, mapper_inputs: Array) -> void:
|
||||
# Convert mapper inputs to replay-compatible format, then push both buffers.
|
||||
var replay_inputs: Array = []
|
||||
for inp in mapper_inputs:
|
||||
var ri := _to_replay_format(inp, tick)
|
||||
if not ri.is_empty():
|
||||
replay_inputs.append(ri)
|
||||
_push_tick_inputs(tick, replay_inputs)
|
||||
_push_tick_snapshot(snapshot_json)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal ring buffer operations (also exposed for tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Push replay-format inputs for one tick. inputs is Array of {tick, action} dicts.
|
||||
## Overwrites oldest entry when buffer is full (circular eviction).
|
||||
@warning_ignore("unused_parameter")
|
||||
func _push_tick_inputs(_tick: int, inputs: Array) -> void:
|
||||
_input_ring[_input_head] = inputs
|
||||
_input_head = (_input_head + 1) % RING_SIZE
|
||||
if _input_count < RING_SIZE:
|
||||
_input_count += 1
|
||||
|
||||
|
||||
## Push a JSON-serialized ObserverSnapshot string for one tick.
|
||||
func _push_tick_snapshot(snapshot_json: String) -> void:
|
||||
_snapshot_ring[_snapshot_head] = snapshot_json
|
||||
_snapshot_head = (_snapshot_head + 1) % RING_SIZE
|
||||
if _snapshot_count < RING_SIZE:
|
||||
_snapshot_count += 1
|
||||
|
||||
|
||||
## Format the input ring buffer as JSONL for writing to inputs.jsonl.
|
||||
## Returns a String with one JSON array per line, oldest to newest.
|
||||
## Each line: Array of {tick, action} replay-format PlayerInput objects.
|
||||
func _format_inputs_jsonl() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
var start := (_input_head - _input_count + RING_SIZE) % RING_SIZE
|
||||
for i in range(_input_count):
|
||||
var idx := (start + i) % RING_SIZE
|
||||
lines.append(JSON.stringify(_input_ring[idx]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
## Format the snapshot ring buffer as JSONL for writing to snapshots.jsonl.
|
||||
## Returns a String with one JSON string per line, oldest to newest.
|
||||
func _format_snapshots_jsonl() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
var start := (_snapshot_head - _snapshot_count + RING_SIZE) % RING_SIZE
|
||||
for i in range(_snapshot_count):
|
||||
var idx := (start + i) % RING_SIZE
|
||||
lines.append(_snapshot_ring[idx])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
## Return the RNG seed for seed.txt. Never returns null.
|
||||
## Uses GameState.rng_seed if available; falls back to "unavailable" string.
|
||||
## Note: rng_seed is u64 on the server. JSON encodes u64 as a number, which
|
||||
## loses precision above 2^53 via float intermediary. When the server field
|
||||
## lands, consider string-encoding the seed to preserve all 64 bits.
|
||||
func _get_current_seed() -> Variant:
|
||||
if GameState.rng_seed != null:
|
||||
return GameState.rng_seed
|
||||
return "unavailable"
|
||||
|
||||
|
||||
## Convert one InputMapper dict to replay-compatible PlayerInput dict.
|
||||
## Returns empty dict for client-only actions (BUG_REPORT, OPEN_MENU).
|
||||
## Replay format: {"tick": N, "action": "MoveNorth"} or
|
||||
## {"tick": N, "action": {"Interact": {"target_entity_id": ..., "verb": ...}}}
|
||||
func _to_replay_format(input: Dictionary, tick: int) -> Dictionary:
|
||||
var action_enum: int = input.get("action", -1)
|
||||
var wire: String = SimBridge.action_enum_to_wire(action_enum)
|
||||
if wire.is_empty():
|
||||
return {} # Client-only action (BUG_REPORT, OPEN_MENU)
|
||||
|
||||
var result := {"tick": tick}
|
||||
var action_data: Variant = input.get("action_data")
|
||||
|
||||
match wire:
|
||||
"Interact":
|
||||
# Rust PlayerAction::Interact { target_entity_id, verb }
|
||||
result["action"] = {"Interact": action_data if action_data is Dictionary else {}}
|
||||
"SetFacing":
|
||||
# Rust PlayerAction::SetFacing(String) — wrap direction string
|
||||
var facing := ""
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
result["action"] = {"SetFacing": facing}
|
||||
_:
|
||||
# Simple enum variants: "MoveNorth", "Pause", "TeleportToHub", etc.
|
||||
result["action"] = wire
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-accessible accessors (ring buffer introspection)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _get_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
|
||||
func _get_snapshot_buffer_capacity() -> int:
|
||||
return RING_SIZE
|
||||
|
||||
|
||||
func _get_input_buffer() -> Array:
|
||||
return _input_ring
|
||||
|
||||
|
||||
func _get_filled_input_count() -> int:
|
||||
return _input_count
|
||||
|
||||
|
||||
func _get_filled_snapshot_count() -> int:
|
||||
return _snapshot_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UI / capture flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func start_capture() -> void:
|
||||
if is_open():
|
||||
return
|
||||
# Capture screenshot BEFORE showing the dialog overlay
|
||||
_captured_screenshot = get_viewport().get_texture().get_image()
|
||||
MetaStack.push(self)
|
||||
open()
|
||||
|
||||
|
||||
func on_open() -> void:
|
||||
_line_edit = LineEdit.new()
|
||||
_line_edit.placeholder_text = "Describe the issue..."
|
||||
_line_edit.size = Vector2(BOX_WIDTH - PADDING * 2, 30)
|
||||
_line_edit.position = Vector2(
|
||||
(get_viewport_rect().size.x - BOX_WIDTH) / 2.0 + PADDING,
|
||||
(get_viewport_rect().size.y - BOX_HEIGHT) / 2.0 + 50
|
||||
)
|
||||
_line_edit.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
_line_edit.text_submitted.connect(_on_text_submitted)
|
||||
add_child(_line_edit)
|
||||
_line_edit.grab_focus()
|
||||
|
||||
|
||||
func on_close() -> void:
|
||||
if _line_edit:
|
||||
_line_edit.release_focus()
|
||||
_line_edit.queue_free()
|
||||
_line_edit = null
|
||||
_captured_screenshot = null
|
||||
|
||||
|
||||
func on_escape() -> bool:
|
||||
capture_cancelled.emit()
|
||||
return false # let MetaStack close
|
||||
|
||||
|
||||
func _on_text_submitted(text: String) -> void:
|
||||
_save_report(text)
|
||||
close()
|
||||
capture_completed.emit()
|
||||
|
||||
|
||||
func _save_report(description: String) -> void:
|
||||
var tick := GameState.current_tick
|
||||
var timestamp := Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
|
||||
var dir_name := "gauntlet-t%d-%s" % [tick, timestamp]
|
||||
var base_path := "user://bug-reports/" + dir_name
|
||||
|
||||
# Ensure directory exists
|
||||
var dir_err := DirAccess.make_dir_recursive_absolute(base_path)
|
||||
if dir_err != OK:
|
||||
push_error("BugReport: failed to create directory %s (error %d)" % [base_path, dir_err])
|
||||
return
|
||||
|
||||
var files_saved := 0
|
||||
|
||||
# 1. snapshot.json — single-tick point-in-time (MVP compat, #495)
|
||||
var snapshot_path := base_path + "/snapshot.json"
|
||||
var snapshot_file := FileAccess.open(snapshot_path, FileAccess.WRITE)
|
||||
if snapshot_file:
|
||||
snapshot_file.store_string(JSON.stringify(GameState.current_snapshot, "\t"))
|
||||
snapshot_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % snapshot_path)
|
||||
|
||||
# 2. render.txt — simplified client-side text render of snapshot
|
||||
var render_path := base_path + "/render.txt"
|
||||
var render_file := FileAccess.open(render_path, FileAccess.WRITE)
|
||||
if render_file:
|
||||
render_file.store_string(_render_snapshot_text())
|
||||
render_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % render_path)
|
||||
|
||||
# 3. description.txt — tester description + metadata
|
||||
# Room: uses GameState.room_id (v0.1: room name is the map identifier)
|
||||
var desc_path := base_path + "/description.txt"
|
||||
var desc_file := FileAccess.open(desc_path, FileAccess.WRITE)
|
||||
if desc_file:
|
||||
desc_file.store_string("Description: %s\n" % description)
|
||||
desc_file.store_string("Tick: %d\n" % tick)
|
||||
desc_file.store_string(
|
||||
"Room: %s\n" % str(GameState.room_id if GameState.room_id else "none")
|
||||
)
|
||||
desc_file.store_string("Stance: %s\n" % GameState.player_stance)
|
||||
desc_file.store_string("Facing: %s\n" % GameState.player_facing)
|
||||
desc_file.store_string("Position: %s\n" % str(GameState.player_position))
|
||||
desc_file.store_string("Timestamp: %s\n" % Time.get_datetime_string_from_system())
|
||||
desc_file.store_string("RingBufferTicks: %d\n" % _input_count)
|
||||
desc_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % desc_path)
|
||||
|
||||
# 4. inputs.jsonl — last N ticks of PlayerInput (replay-compatible)
|
||||
# One JSON array per line. Empty array = idle tick.
|
||||
# Compatible with tooling/test-client --replay (replay.rs).
|
||||
var inputs_path := base_path + "/inputs.jsonl"
|
||||
var inputs_file := FileAccess.open(inputs_path, FileAccess.WRITE)
|
||||
if inputs_file:
|
||||
inputs_file.store_string(_format_inputs_jsonl())
|
||||
inputs_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % inputs_path)
|
||||
|
||||
# 5. snapshots.jsonl — last N ticks of ObserverSnapshot, oldest to newest.
|
||||
var snaps_path := base_path + "/snapshots.jsonl"
|
||||
var snaps_file := FileAccess.open(snaps_path, FileAccess.WRITE)
|
||||
if snaps_file:
|
||||
snaps_file.store_string(_format_snapshots_jsonl())
|
||||
snaps_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % snaps_path)
|
||||
|
||||
# 6. seed.txt — RNG seed for deterministic replay.
|
||||
# Server must include "rng_seed" (u64) in ObserverSnapshot for this to be populated.
|
||||
# If absent: includes a note on the required protocol change.
|
||||
var seed_path := base_path + "/seed.txt"
|
||||
var seed_file := FileAccess.open(seed_path, FileAccess.WRITE)
|
||||
if seed_file:
|
||||
var seed_val: Variant = _get_current_seed()
|
||||
seed_file.store_string(str(seed_val) + "\n")
|
||||
if seed_val == "unavailable":
|
||||
(
|
||||
seed_file
|
||||
. store_string(
|
||||
"# Server protocol change required: add 'rng_seed' (u64) field to ObserverSnapshot.\n"
|
||||
)
|
||||
)
|
||||
seed_file.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % seed_path)
|
||||
|
||||
# 7. screenshot.png — viewport capture taken before dialog opened
|
||||
if _captured_screenshot:
|
||||
var screenshot_path := base_path + "/screenshot.png"
|
||||
var img_err := _captured_screenshot.save_png(screenshot_path)
|
||||
if img_err == OK:
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s (error %d)" % [screenshot_path, img_err])
|
||||
|
||||
print(
|
||||
(
|
||||
"BugReport: saved %d/7 files to %s (ring: %d ticks)"
|
||||
% [files_saved, base_path, _input_count]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## Simplified client-side text render of the current snapshot.
|
||||
## MVP version — full fidelity via server's format_snapshot_text() is a stretch goal.
|
||||
func _render_snapshot_text() -> String:
|
||||
var lines: PackedStringArray = []
|
||||
lines.append("=== Snapshot t%d ===" % GameState.current_tick)
|
||||
lines.append(
|
||||
(
|
||||
"Player: %s facing %s (%s)"
|
||||
% [str(GameState.player_position), GameState.player_facing, GameState.player_stance]
|
||||
)
|
||||
)
|
||||
|
||||
if GameState.game_time.size() > 0:
|
||||
lines.append(
|
||||
(
|
||||
"Time: day %s, %s, %s"
|
||||
% [
|
||||
str(GameState.game_time.get("day", "?")),
|
||||
str(GameState.game_time.get("day_phase", "?")),
|
||||
str(GameState.game_time.get("tick_rate", "?"))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Entities (%d):" % GameState.visible_entities.size())
|
||||
for entity in GameState.visible_entities:
|
||||
var kind_str: String = ""
|
||||
if entity.has("kind") and entity.kind is Dictionary:
|
||||
kind_str = entity.kind.get("variant", "?")
|
||||
elif entity.has("kind") and entity.kind is String:
|
||||
kind_str = entity.kind
|
||||
var vis: String = entity.get("visibility", "?")
|
||||
lines.append(
|
||||
(
|
||||
" #%s %s at (%s, %s) [%s]"
|
||||
% [
|
||||
str(entity.get("entity_id", "?")),
|
||||
kind_str,
|
||||
str(entity.get("x", "?")),
|
||||
str(entity.get("y", "?")),
|
||||
vis
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Visible tiles: %d" % GameState.visible_tiles.size())
|
||||
|
||||
if GameState.current_monologue != null:
|
||||
lines.append("Monologue: %s" % str(GameState.current_monologue.get("text", "")))
|
||||
if GameState.current_dialogue != null:
|
||||
lines.append(
|
||||
(
|
||||
"Dialogue: %s says '%s'"
|
||||
% [
|
||||
str(GameState.current_dialogue.get("npc_name", "?")),
|
||||
str(GameState.current_dialogue.get("speech", ""))
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not is_open():
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
# Full-screen dim
|
||||
draw_rect(Rect2(Vector2.ZERO, viewport_size), BG_COLOR)
|
||||
|
||||
# Center 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, 24),
|
||||
"WRONG — Describe the issue (Enter to save, Esc to cancel):",
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1,
|
||||
LABEL_FONT_SIZE,
|
||||
TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
func is_active() -> bool:
|
||||
return is_open()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,510 @@
|
||||
class_name DebugConsole
|
||||
extends MetaScreen
|
||||
|
||||
## In-game debug console (#581). Tilde key (`) toggles open/closed.
|
||||
## Semi-transparent panel anchored to bottom ~40% of screen.
|
||||
## Dispatches DebugCommandKind variants to server via SimBridge.
|
||||
## Settings-toggled; enabled state persisted in user://settings.cfg.
|
||||
## D-088: triggers Overlay pause while open — sim must not advance during debug input.
|
||||
|
||||
signal pause_requested # D-088: pause sim while console is open
|
||||
signal unpause_requested # D-088: unpause sim when console closes
|
||||
|
||||
const PREFS_PATH := "user://settings.cfg"
|
||||
const PREFS_SECTION := "debug"
|
||||
const PREFS_KEY_ENABLED := "console_enabled"
|
||||
const MAX_LOG_LINES := 50
|
||||
|
||||
const BG_COLOR := Color(0.04, 0.04, 0.06, 0.92)
|
||||
const BORDER_COLOR := Color("#4a9ebb")
|
||||
const TEXT_COLOR := Color("#c8d0e0")
|
||||
const SUCCESS_COLOR := Color("#6bc9a6")
|
||||
const ERROR_COLOR := Color("#d45d5d")
|
||||
const INPUT_COLOR := Color("#e8c547")
|
||||
|
||||
var _enabled: bool = true
|
||||
var _log_lines: Array[String] = []
|
||||
var _panel: PanelContainer = null
|
||||
var _output_log: RichTextLabel = null
|
||||
var _input_line: LineEdit = null
|
||||
var _history: Array[String] = []
|
||||
var _history_idx: int = -1
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
pauses_sim = true
|
||||
_load_prefs()
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_build_ui()
|
||||
get_viewport().size_changed.connect(_update_panel_layout)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
_panel = PanelContainer.new()
|
||||
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_panel.anchor_left = 0.0
|
||||
_panel.anchor_top = 0.6
|
||||
_panel.anchor_right = 1.0
|
||||
_panel.anchor_bottom = 1.0
|
||||
_panel.offset_left = 0.0
|
||||
_panel.offset_top = 0.0
|
||||
_panel.offset_right = 0.0
|
||||
_panel.offset_bottom = 0.0
|
||||
|
||||
var bg_style := StyleBoxFlat.new()
|
||||
bg_style.bg_color = BG_COLOR
|
||||
bg_style.border_color = BORDER_COLOR
|
||||
bg_style.border_width_top = 1
|
||||
bg_style.content_margin_left = 8.0
|
||||
bg_style.content_margin_right = 8.0
|
||||
bg_style.content_margin_top = 6.0
|
||||
bg_style.content_margin_bottom = 6.0
|
||||
_panel.add_theme_stylebox_override("panel", bg_style)
|
||||
add_child(_panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
_panel.add_child(vbox)
|
||||
|
||||
_output_log = RichTextLabel.new()
|
||||
_output_log.bbcode_enabled = true
|
||||
_output_log.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_output_log.scroll_following = true
|
||||
_output_log.selection_enabled = true
|
||||
_output_log.add_theme_color_override("default_color", TEXT_COLOR)
|
||||
_output_log.add_theme_font_size_override("normal_font_size", 13)
|
||||
vbox.add_child(_output_log)
|
||||
|
||||
var sep := HSeparator.new()
|
||||
vbox.add_child(sep)
|
||||
|
||||
_input_line = LineEdit.new()
|
||||
_input_line.placeholder_text = "enter command (help for list)"
|
||||
_input_line.clear_button_enabled = false
|
||||
_input_line.add_theme_font_size_override("font_size", 13)
|
||||
_input_line.add_theme_color_override("font_color", INPUT_COLOR)
|
||||
_input_line.text_submitted.connect(_on_input_submitted)
|
||||
_input_line.gui_input.connect(_on_input_key)
|
||||
vbox.add_child(_input_line)
|
||||
|
||||
|
||||
func _update_panel_layout() -> void:
|
||||
# Anchors handle resize automatically; no manual size calc needed.
|
||||
pass
|
||||
|
||||
|
||||
# -- Input handling --
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
if not event is InputEventKey or not event.pressed or event.echo:
|
||||
return
|
||||
if event.keycode == KEY_QUOTELEFT:
|
||||
get_viewport().set_input_as_handled()
|
||||
_toggle()
|
||||
return
|
||||
if is_open():
|
||||
# Consume all keyboard events — prevent movement/action leaking through
|
||||
get_viewport().set_input_as_handled()
|
||||
if event.keycode == KEY_ESCAPE:
|
||||
close()
|
||||
|
||||
|
||||
func _on_input_key(event: InputEvent) -> void:
|
||||
if not event is InputEventKey or not event.pressed or event.echo:
|
||||
return
|
||||
if event.keycode == KEY_UP:
|
||||
_history_up()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event.keycode == KEY_DOWN:
|
||||
_history_down()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _toggle() -> void:
|
||||
if is_open():
|
||||
close()
|
||||
else:
|
||||
MetaStack.push(self)
|
||||
open()
|
||||
|
||||
|
||||
func on_open() -> void:
|
||||
_input_line.clear()
|
||||
_input_line.grab_focus()
|
||||
_history_idx = -1
|
||||
pause_requested.emit() # D-088: pause sim while typing debug commands
|
||||
|
||||
|
||||
func on_close() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_input_line.release_focus()
|
||||
unpause_requested.emit() # D-088: resume sim when console closes
|
||||
|
||||
|
||||
# -- Command input --
|
||||
|
||||
|
||||
func _on_input_submitted(text: String) -> void:
|
||||
var trimmed := text.strip_edges()
|
||||
_input_line.clear()
|
||||
_history_idx = -1
|
||||
if trimmed.is_empty():
|
||||
return
|
||||
if _history.is_empty() or _history[0] != trimmed:
|
||||
_history.push_front(trimmed)
|
||||
if _history.size() > 20:
|
||||
_history.pop_back()
|
||||
_append_text("> " + trimmed, TEXT_COLOR)
|
||||
_dispatch(trimmed)
|
||||
|
||||
|
||||
func _dispatch(line: String) -> void:
|
||||
var parts := line.split(" ", false)
|
||||
if parts.is_empty():
|
||||
return
|
||||
var cmd := parts[0].to_lower()
|
||||
match cmd:
|
||||
"help":
|
||||
_print_help()
|
||||
"ticks":
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
_append_text("usage: ticks <n>", ERROR_COLOR)
|
||||
return
|
||||
var n := int(parts[1])
|
||||
if n <= 0:
|
||||
_append_text("ticks: n must be > 0", ERROR_COLOR)
|
||||
return
|
||||
_send_debug({"AdvanceTicks": n})
|
||||
"contaminate":
|
||||
_send_debug("SkipToContamination")
|
||||
"tp":
|
||||
if parts.size() < 2:
|
||||
_append_text("usage: tp <x> <y> [z] or tp <location_name>", ERROR_COLOR)
|
||||
return
|
||||
if parts.size() >= 3 and parts[1].is_valid_int() and parts[2].is_valid_int():
|
||||
var z := 0
|
||||
if parts.size() >= 4:
|
||||
if parts[3].is_valid_int():
|
||||
z = int(parts[3])
|
||||
else:
|
||||
_append_text("tp: invalid z '%s' — defaulting to 0" % parts[3], ERROR_COLOR)
|
||||
_send_debug(
|
||||
{"TeleportToPosition": {"x": int(parts[1]), "y": int(parts[2]), "z": z}}
|
||||
)
|
||||
else:
|
||||
var loc := " ".join(PackedStringArray(parts.slice(1)))
|
||||
_send_debug({"TeleportToLocation": loc})
|
||||
"activate":
|
||||
_send_debug("ForceContaminationActivate")
|
||||
"triangle":
|
||||
if parts.size() < 2:
|
||||
_append_text("usage: triangle <id>", ERROR_COLOR)
|
||||
return
|
||||
_send_debug({"ForceTriangleActivation": parts[1]})
|
||||
"npc":
|
||||
if parts.size() < 2 or not parts[1].is_valid_int():
|
||||
_append_text("usage: npc <entity_id>", ERROR_COLOR)
|
||||
return
|
||||
_send_debug({"InspectNpc": int(parts[1])})
|
||||
"triangles":
|
||||
_send_debug("ListTriangles")
|
||||
"pop":
|
||||
_send_debug("ListPopulation")
|
||||
"status":
|
||||
_send_debug("GetContaminationStatus")
|
||||
"econ":
|
||||
_dispatch_econ(parts)
|
||||
_:
|
||||
_append_text("unknown command: '%s' (type 'help')" % cmd, ERROR_COLOR)
|
||||
|
||||
|
||||
# #825: Economics debug console commands (D-178, D-180, D-181).
|
||||
# Three subcommands: inject (fire EconEvent), param (tweak α/β/friction), inspect (read signals).
|
||||
# TODO: Server-side DebugCommandKind variants (InjectEconEvent, SetEconParam, GetEconState)
|
||||
# ship with #823. Until then, _send_debug will transmit the payload but the server will
|
||||
# respond with an "unknown command" error. Wire format is ready; server handler is not.
|
||||
func _dispatch_econ(parts: Array) -> void:
|
||||
if parts.size() < 2:
|
||||
_append_text(
|
||||
(
|
||||
"usage:\n"
|
||||
+ " econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]\n"
|
||||
+ " econ param <alpha|beta|friction> <value> [system_a] [system_b]\n"
|
||||
+ " econ inspect <system_id>"
|
||||
),
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
var sub: String = parts[1].to_lower()
|
||||
match sub:
|
||||
"inject":
|
||||
_econ_inject(parts)
|
||||
"param":
|
||||
_econ_param(parts)
|
||||
"inspect":
|
||||
_econ_inspect(parts)
|
||||
_:
|
||||
_append_text("econ: unknown subcommand '%s'" % sub, ERROR_COLOR)
|
||||
|
||||
|
||||
# econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]
|
||||
# Minimal form: econ inject Sol shock 0.5
|
||||
# Full form: econ inject Sol fusion_fuel boost 1.2 100
|
||||
func _econ_inject(parts: Array) -> void:
|
||||
# parts[0]="econ", parts[1]="inject", rest is args
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 3:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> [commodity_id] <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
# Parse: detect whether commodity_id is present by checking if args[1] is an effect keyword
|
||||
var system_id: String = args[0]
|
||||
var commodity_id: String = ""
|
||||
var effect: String = ""
|
||||
var magnitude_str: String = ""
|
||||
var duration_ticks: int = 0
|
||||
|
||||
var ticks_str := ""
|
||||
if args[1].to_lower() in ["shock", "boost"]:
|
||||
# No commodity_id: econ inject <system> <effect> <magnitude> [ticks]
|
||||
effect = args[1].to_lower()
|
||||
magnitude_str = args[2]
|
||||
if args.size() >= 4:
|
||||
ticks_str = args[3]
|
||||
elif args.size() == 3:
|
||||
# 3 args but args[1] isn't shock/boost — bad effect keyword, not a commodity
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % args[1], ERROR_COLOR
|
||||
)
|
||||
return
|
||||
else:
|
||||
# With commodity_id: econ inject <system> <commodity> <effect> <magnitude> [ticks]
|
||||
commodity_id = args[1]
|
||||
if args.size() < 4:
|
||||
_append_text(
|
||||
"usage: econ inject <system_id> <commodity_id> <shock|boost> <magnitude> [ticks]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
effect = args[2].to_lower()
|
||||
if not effect in ["shock", "boost"]:
|
||||
_append_text(
|
||||
"econ inject: effect must be 'shock' or 'boost', got '%s'" % effect, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
magnitude_str = args[3]
|
||||
if args.size() >= 5:
|
||||
ticks_str = args[4]
|
||||
|
||||
if not ticks_str.is_empty():
|
||||
if not ticks_str.is_valid_int():
|
||||
_append_text("econ inject: invalid ticks '%s'" % ticks_str, ERROR_COLOR)
|
||||
return
|
||||
duration_ticks = int(ticks_str)
|
||||
|
||||
if not magnitude_str.is_valid_float():
|
||||
_append_text("econ inject: invalid magnitude '%s'" % magnitude_str, ERROR_COLOR)
|
||||
return
|
||||
var magnitude: float = float(magnitude_str)
|
||||
|
||||
var payload := {
|
||||
"system_id": system_id,
|
||||
"effect": effect,
|
||||
"magnitude": magnitude,
|
||||
}
|
||||
if not commodity_id.is_empty():
|
||||
payload["commodity_id"] = commodity_id
|
||||
if duration_ticks > 0:
|
||||
payload["duration_ticks"] = duration_ticks
|
||||
|
||||
_append_text(
|
||||
(
|
||||
"injecting %s on %s%s (mag=%.2f, ticks=%d)"
|
||||
% [
|
||||
effect,
|
||||
system_id,
|
||||
" / " + commodity_id if not commodity_id.is_empty() else "",
|
||||
magnitude,
|
||||
duration_ticks
|
||||
]
|
||||
),
|
||||
TEXT_COLOR,
|
||||
)
|
||||
_send_debug({"InjectEconEvent": payload})
|
||||
|
||||
|
||||
# econ param <alpha|beta|friction> <value> [system_a] [system_b]
|
||||
func _econ_param(parts: Array) -> void:
|
||||
var args := parts.slice(2)
|
||||
if args.size() < 2:
|
||||
_append_text(
|
||||
"usage: econ param <alpha|beta|friction> <value> [system_a] [system_b]",
|
||||
ERROR_COLOR,
|
||||
)
|
||||
return
|
||||
|
||||
var param_name: String = args[0].to_lower()
|
||||
if not param_name in ["alpha", "beta", "friction"]:
|
||||
_append_text(
|
||||
"econ param: must be alpha, beta, or friction — got '%s'" % param_name, ERROR_COLOR
|
||||
)
|
||||
return
|
||||
|
||||
if not args[1].is_valid_float():
|
||||
_append_text("econ param: invalid value '%s'" % args[1], ERROR_COLOR)
|
||||
return
|
||||
var value: float = float(args[1])
|
||||
|
||||
var payload := {"param": param_name, "value": value}
|
||||
if args.size() >= 3:
|
||||
payload["system_a"] = args[2]
|
||||
if args.size() >= 4:
|
||||
payload["system_b"] = args[3]
|
||||
|
||||
var scope := "global"
|
||||
if payload.has("system_a") and payload.has("system_b"):
|
||||
scope = "%s ↔ %s" % [payload["system_a"], payload["system_b"]]
|
||||
elif payload.has("system_a"):
|
||||
scope = payload["system_a"]
|
||||
|
||||
_append_text("setting %s = %.4f (%s)" % [param_name, value, scope], TEXT_COLOR)
|
||||
_send_debug({"SetEconParam": payload})
|
||||
|
||||
|
||||
# econ inspect <system_id> — request all 7 D-181 signals for a system
|
||||
func _econ_inspect(parts: Array) -> void:
|
||||
if parts.size() < 3:
|
||||
_append_text("usage: econ inspect <system_id>", ERROR_COLOR)
|
||||
return
|
||||
var system_id: String = parts[2]
|
||||
_append_text("inspecting economy: %s" % system_id, TEXT_COLOR)
|
||||
_send_debug({"GetEconState": system_id})
|
||||
|
||||
|
||||
func _send_debug(kind: Variant) -> void:
|
||||
var err := (
|
||||
SimBridge
|
||||
. send_input(
|
||||
{
|
||||
"action": InputMapper.Action.DEBUG_COMMAND,
|
||||
"action_data": kind,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
}
|
||||
)
|
||||
)
|
||||
if err != OK:
|
||||
_append_text("send error: %s" % error_string(err), ERROR_COLOR)
|
||||
|
||||
|
||||
# -- Response display --
|
||||
|
||||
|
||||
## Append a server debug response to the output log. Auto-opens console if closed
|
||||
## (only if console is enabled — respect user's settings toggle).
|
||||
func append_response(response: Dictionary) -> void:
|
||||
var success: bool = response.get("success", false)
|
||||
var text: String = response.get("text", "")
|
||||
var color := SUCCESS_COLOR if success else ERROR_COLOR
|
||||
_append_text(text, color)
|
||||
if not is_open() and _enabled:
|
||||
MetaStack.push(self)
|
||||
open()
|
||||
|
||||
|
||||
# -- Log rendering --
|
||||
|
||||
|
||||
func _append_text(text: String, color: Color) -> void:
|
||||
var escaped := text.replace("[", "[lb]").replace("]", "[rb]")
|
||||
_log_lines.append("[color=%s]%s[/color]" % [color.to_html(false), escaped])
|
||||
if _log_lines.size() > MAX_LOG_LINES:
|
||||
_log_lines = _log_lines.slice(_log_lines.size() - MAX_LOG_LINES)
|
||||
if _output_log:
|
||||
_output_log.text = "\n".join(_log_lines)
|
||||
|
||||
|
||||
func _print_help() -> void:
|
||||
_append_text(
|
||||
(
|
||||
"Commands:\n"
|
||||
+ " ticks <n> — fast-forward N ticks\n"
|
||||
+ " contaminate — skip to contamination phase\n"
|
||||
+ " tp <x> <y> [z] — teleport to tile position\n"
|
||||
+ " tp <location> — teleport to named location\n"
|
||||
+ " activate — force contamination activate\n"
|
||||
+ " triangle <id> — force triangle activation\n"
|
||||
+ " npc <entity_id> — inspect NPC state\n"
|
||||
+ " triangles — list all triangles\n"
|
||||
+ " pop — list active NPCs\n"
|
||||
+ " status — contamination status\n"
|
||||
+ "\n"
|
||||
+ "Economics (D-178/D-180/D-181):\n"
|
||||
+ " econ inject <sys> [commodity] <shock|boost> <mag> [ticks]\n"
|
||||
+ " — fire an EconEvent at a system\n"
|
||||
+ " econ param <alpha|beta|friction> <val> [sys_a] [sys_b]\n"
|
||||
+ " — set tâtonnement parameter\n"
|
||||
+ " econ inspect <sys> — show all 7 signals for a system\n"
|
||||
+ "\n"
|
||||
+ " help — this list"
|
||||
),
|
||||
TEXT_COLOR
|
||||
)
|
||||
|
||||
|
||||
# -- Command history --
|
||||
|
||||
|
||||
func _history_up() -> void:
|
||||
if _history.is_empty():
|
||||
return
|
||||
_history_idx = mini(_history_idx + 1, _history.size() - 1)
|
||||
_input_line.text = _history[_history_idx]
|
||||
_input_line.caret_column = _input_line.text.length()
|
||||
|
||||
|
||||
func _history_down() -> void:
|
||||
if _history_idx <= 0:
|
||||
_history_idx = -1
|
||||
_input_line.clear()
|
||||
return
|
||||
_history_idx -= 1
|
||||
_input_line.text = _history[_history_idx]
|
||||
_input_line.caret_column = _input_line.text.length()
|
||||
|
||||
|
||||
# -- Settings --
|
||||
|
||||
|
||||
func set_enabled(enabled: bool) -> void:
|
||||
_enabled = enabled
|
||||
if not _enabled and is_open():
|
||||
close()
|
||||
_save_prefs()
|
||||
|
||||
|
||||
func is_enabled() -> bool:
|
||||
return _enabled
|
||||
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
return
|
||||
_enabled = cfg.get_value(PREFS_SECTION, PREFS_KEY_ENABLED, true)
|
||||
|
||||
|
||||
func _save_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.load(PREFS_PATH) # load existing (may have other sections like "audio")
|
||||
cfg.set_value(PREFS_SECTION, PREFS_KEY_ENABLED, _enabled)
|
||||
var err := cfg.save(PREFS_PATH)
|
||||
if err != OK:
|
||||
push_warning("DebugConsole: failed to save prefs (%d)" % err)
|
||||
@@ -0,0 +1,86 @@
|
||||
extends MetaScreen
|
||||
## #257: Loading screen overlay — blocks input during save/load round-trip.
|
||||
## Shown when LOAD_GAME fires; hidden when save_result arrives (success or failure).
|
||||
## Full-screen, dark overlay with centered status text.
|
||||
## #724: Shows client version (from project.yaml) and protocol version below the status text.
|
||||
|
||||
const BG_COLOR := Color(0.0, 0.0, 0.0, 0.75)
|
||||
const TEXT_COLOR := Color("#c8d0e0")
|
||||
const VERSION_COLOR := Color("#667788")
|
||||
const FONT_SIZE := 18
|
||||
const VERSION_FONT_SIZE := 11
|
||||
|
||||
var _label: Label = null
|
||||
var _version_label: Label = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
closable_by_escape = false
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_ui()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var bg := ColorRect.new()
|
||||
bg.color = BG_COLOR
|
||||
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(bg)
|
||||
|
||||
_label = Label.new()
|
||||
_label.text = UIStrings.get_text("notifications.loading")
|
||||
_label.add_theme_font_size_override("font_size", FONT_SIZE)
|
||||
_label.add_theme_color_override("font_color", TEXT_COLOR)
|
||||
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_label)
|
||||
|
||||
# client_ver and proto_ver are independent — project.yaml version is the client release,
|
||||
# Protocol.PROTOCOL_VERSION is the wire protocol. Mismatches between builds are visible
|
||||
# only to the observer reading the loading-screen label; a future ticket will surface them.
|
||||
var client_ver := _read_client_version()
|
||||
var proto_ver: int = Protocol.PROTOCOL_VERSION
|
||||
_version_label = Label.new()
|
||||
_version_label.text = "v%s · protocol %d" % [client_ver, proto_ver]
|
||||
_version_label.add_theme_font_size_override("font_size", VERSION_FONT_SIZE)
|
||||
_version_label.add_theme_color_override("font_color", VERSION_COLOR)
|
||||
_version_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_version_label.anchor_left = 0.0
|
||||
_version_label.anchor_right = 1.0
|
||||
_version_label.anchor_top = 1.0
|
||||
_version_label.anchor_bottom = 1.0
|
||||
_version_label.offset_top = -32.0
|
||||
_version_label.offset_bottom = -8.0
|
||||
_version_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_version_label)
|
||||
|
||||
|
||||
func _read_client_version() -> String:
|
||||
var yaml_path := ProjectSettings.globalize_path("res://") + "/../project.yaml"
|
||||
if not FileAccess.file_exists(yaml_path):
|
||||
return "?.?.?"
|
||||
var file := FileAccess.open(yaml_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return "?.?.?"
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
for line: String in content.split("\n"):
|
||||
if line.begins_with("version:"):
|
||||
var parts := line.split(":", false, 1)
|
||||
if parts.size() >= 2:
|
||||
return parts[1].strip_edges()
|
||||
return "?.?.?"
|
||||
|
||||
|
||||
func show_loading() -> void:
|
||||
MetaStack.push(self)
|
||||
open()
|
||||
|
||||
|
||||
## Hide the loading overlay. success=false is reserved for future failure-state UI.
|
||||
func hide_loading(_success: bool = true) -> void:
|
||||
close()
|
||||
@@ -0,0 +1,170 @@
|
||||
extends MetaScreen
|
||||
## #258: Main menu — New Game / Continue / Load Game / Quit.
|
||||
## New Game: opens character creation screen, then starts game.
|
||||
## Continue: loads most recent save directory.
|
||||
## Load Game: shows sorted save list for manual selection (#257).
|
||||
|
||||
const GAME_SCENE := "res://scenes/main.tscn"
|
||||
const CHARACTER_CREATION_SCENE := "res://scenes/character_creation.tscn"
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 1.0)
|
||||
const TITLE_COLOR := Color("#c8d0e0")
|
||||
const SUBTITLE_COLOR := Color("#8890a0")
|
||||
const BTN_NORMAL_COLOR := Color("#e8c547")
|
||||
const BTN_DISABLED_COLOR := Color("#4a5060")
|
||||
const FONT_SIZE_TITLE := 36
|
||||
const FONT_SIZE_SUBTITLE := 14
|
||||
const FONT_SIZE_BTN := 15
|
||||
|
||||
var _char_creation: Control = null
|
||||
var _list_built: bool = false
|
||||
|
||||
@onready var _new_game_btn: Button = $VBox/NewGameBtn
|
||||
@onready var _continue_btn: Button = $VBox/ContinueBtn
|
||||
@onready var _load_game_btn: Button = $VBox/LoadGameBtn
|
||||
@onready var _quit_btn: Button = $VBox/QuitBtn
|
||||
@onready var _load_panel: Control = $LoadGamePanel
|
||||
@onready var _saves_list: VBoxContainer = $LoadGamePanel/VBox/SavesScroll/SavesList
|
||||
@onready var _load_back_btn: Button = $LoadGamePanel/VBox/BackBtn
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_new_game_btn.pressed.connect(_on_new_game)
|
||||
_continue_btn.pressed.connect(_on_continue)
|
||||
_load_game_btn.pressed.connect(_on_load_game_browse)
|
||||
_quit_btn.pressed.connect(_on_quit)
|
||||
_load_back_btn.pressed.connect(_on_load_back)
|
||||
_load_panel.visible = false
|
||||
_refresh_continue_state()
|
||||
|
||||
|
||||
func _refresh_continue_state() -> void:
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
_continue_btn.disabled = saves.is_empty()
|
||||
_load_game_btn.disabled = saves.is_empty()
|
||||
|
||||
|
||||
func _on_new_game() -> void:
|
||||
GameState.pending_load_path = ""
|
||||
_show_character_creation()
|
||||
|
||||
|
||||
func _show_character_creation() -> void:
|
||||
if _char_creation != null and is_instance_valid(_char_creation):
|
||||
_char_creation.queue_free()
|
||||
var scene := load(CHARACTER_CREATION_SCENE) as PackedScene
|
||||
if scene == null:
|
||||
push_error("MainMenu: failed to load character_creation.tscn — skipping to game")
|
||||
_start_game_with_defaults()
|
||||
return
|
||||
_char_creation = scene.instantiate()
|
||||
add_child(_char_creation)
|
||||
_char_creation.creation_confirmed.connect(_on_creation_confirmed)
|
||||
_char_creation.creation_cancelled.connect(_on_creation_cancelled)
|
||||
|
||||
|
||||
func _on_creation_confirmed(descriptor) -> void:
|
||||
if _char_creation != null and is_instance_valid(_char_creation):
|
||||
_char_creation.queue_free()
|
||||
_char_creation = null
|
||||
GameState.character_visual_descriptor = descriptor
|
||||
_start_game_with_defaults()
|
||||
|
||||
|
||||
func _on_creation_cancelled() -> void:
|
||||
if _char_creation != null and is_instance_valid(_char_creation):
|
||||
_char_creation.queue_free()
|
||||
_char_creation = null
|
||||
|
||||
|
||||
func _start_game_with_defaults() -> void:
|
||||
var game_id := SessionManager.new_game()
|
||||
if game_id.is_empty():
|
||||
push_error("MainMenu: new_game() failed to create save directory — cannot start")
|
||||
return
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_continue() -> void:
|
||||
GameState.pending_load_path = ""
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
if saves.is_empty():
|
||||
return
|
||||
SessionManager.resume_game(saves[0].game_id)
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
func _on_load_game_browse() -> void:
|
||||
if not _list_built:
|
||||
_build_saves_list()
|
||||
_list_built = true
|
||||
_load_panel.visible = true
|
||||
|
||||
|
||||
func _on_load_back() -> void:
|
||||
_load_panel.visible = false
|
||||
_list_built = false
|
||||
|
||||
|
||||
func _on_quit() -> void:
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
## Build the saves list panel from existing save directories, sorted by date (newest first).
|
||||
func _build_saves_list() -> void:
|
||||
for child in _saves_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var saves := SessionManager.list_game_dirs()
|
||||
if saves.is_empty():
|
||||
var lbl := Label.new()
|
||||
lbl.text = UIStrings.get_text("menu.load_game_empty")
|
||||
lbl.add_theme_color_override("font_color", Color(BTN_DISABLED_COLOR))
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_saves_list.add_child(lbl)
|
||||
return
|
||||
|
||||
for save in saves:
|
||||
var btn := Button.new()
|
||||
btn.text = _format_save_entry(save)
|
||||
btn.add_theme_font_size_override("font_size", FONT_SIZE_BTN)
|
||||
var has_save_file: bool = not save.get("newest_save", "").is_empty()
|
||||
if has_save_file:
|
||||
btn.add_theme_color_override("font_color", BTN_NORMAL_COLOR)
|
||||
btn.pressed.connect(_on_save_selected.bind(save))
|
||||
else:
|
||||
btn.add_theme_color_override("font_color", BTN_DISABLED_COLOR)
|
||||
btn.disabled = true
|
||||
_saves_list.add_child(btn)
|
||||
|
||||
|
||||
func _on_save_selected(save: Dictionary) -> void:
|
||||
var game_id: String = save.get("game_id", "")
|
||||
var save_file: String = save.get("newest_save", "")
|
||||
if save_file.is_empty():
|
||||
push_error("MainMenu: save entry '%s' has no newest_save — load cancelled" % game_id)
|
||||
return
|
||||
SessionManager.resume_game(game_id)
|
||||
GameState.pending_load_path = "user://saves/" + game_id + "/" + save_file
|
||||
get_tree().change_scene_to_file(GAME_SCENE)
|
||||
|
||||
|
||||
## Format a save entry for display. game_id format: YYYYMMDD-HHMMSS-hex6.
|
||||
static func _format_save_entry(save: Dictionary) -> String:
|
||||
var game_id: String = save.get("game_id", "")
|
||||
var parts := game_id.split("-")
|
||||
if parts.size() >= 2 and parts[0].length() == 8 and parts[1].length() == 6:
|
||||
var d := parts[0]
|
||||
var t := parts[1]
|
||||
return (
|
||||
"%s-%s-%s %s:%s:%s"
|
||||
% [
|
||||
d.substr(0, 4),
|
||||
d.substr(4, 2),
|
||||
d.substr(6, 2),
|
||||
t.substr(0, 2),
|
||||
t.substr(2, 2),
|
||||
t.substr(4, 2),
|
||||
]
|
||||
)
|
||||
return game_id
|
||||
@@ -0,0 +1,375 @@
|
||||
extends MetaScreen
|
||||
|
||||
## #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 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 _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_IGNORE
|
||||
|
||||
|
||||
func on_open() -> void:
|
||||
_build_ui()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func on_close() -> void:
|
||||
_destroy_ui()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
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/MetaLayer/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 is_open():
|
||||
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)
|
||||
Reference in New Issue
Block a user