Merge remote-tracking branch 'origin/client'
This commit is contained in:
@@ -12,6 +12,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
### Added
|
||||
- `make golden-diff` + `make golden-update` targets (#486) — developer workflow for golden file comparison and regeneration; safe restore on cargo failure
|
||||
- Gauntlet checklist YAML schema (#497) — 7 condition types evaluable from ObserverSnapshot, per-room checklists for 3 rooms, `make checklist-validate` and `make checklist-generate` targets, wired into `pre-pr-content` gate
|
||||
- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://dev/gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode
|
||||
- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel
|
||||
- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI
|
||||
- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only)
|
||||
- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases)
|
||||
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
|
||||
- Domain-action naming convention for skills documented in create-skill guide
|
||||
- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features`
|
||||
|
||||
@@ -106,6 +106,11 @@ stance_down={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
bug_report={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=17 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
@@ -16,6 +16,8 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
|
||||
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
|
||||
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -131,6 +133,9 @@ layer = 20
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
|
||||
|
||||
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
|
||||
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
|
||||
|
||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
@@ -140,6 +145,8 @@ script = ExtResource("10_cursor")
|
||||
|
||||
; --- Modal layer (CanvasLayer 30) ---
|
||||
; Full-screen overlays: pause menu, inventory modal, death screen.
|
||||
; Empty for Sprint 6 — exists so the layer is reserved in the tree.
|
||||
[node name="ModalLayer" type="CanvasLayer" parent="."]
|
||||
layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")]
|
||||
|
||||
@@ -36,6 +36,10 @@ var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, option
|
||||
# InputMapper suppresses movement when this is true.
|
||||
var dialogue_active: bool = false
|
||||
|
||||
# v8 fields (#496): Gauntlet mode — room timer + personal bests
|
||||
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
|
||||
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
@@ -118,6 +122,16 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
pending_recognitions = []
|
||||
|
||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
gauntlet_mode = true
|
||||
else:
|
||||
gauntlet_mode = false
|
||||
if snapshot.has("room_id") and snapshot.room_id is String:
|
||||
room_id = snapshot.room_id
|
||||
else:
|
||||
room_id = null
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
|
||||
@@ -14,6 +14,7 @@ enum Action {
|
||||
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
@@ -74,6 +75,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
action = Action.TOGGLE_STANCE_UP
|
||||
elif event.is_action_pressed("stance_down"):
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
|
||||
if action != -1:
|
||||
input_queue.append({
|
||||
|
||||
@@ -255,9 +255,9 @@ static func _action_enum_to_wire(action: int) -> String:
|
||||
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
|
||||
InputMapper.Action.OPEN_MENU:
|
||||
# Client-only action, not part of wire protocol
|
||||
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
|
||||
return ""
|
||||
return "" # Client-only action, not part of wire protocol
|
||||
InputMapper.Action.BUG_REPORT:
|
||||
return "" # Client-only action (#495), not part of wire protocol
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
|
||||
@@ -12,6 +12,8 @@ extends Node2D
|
||||
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
|
||||
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
@@ -49,6 +51,10 @@ func _ready() -> void:
|
||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
@@ -88,6 +94,10 @@ func _process(_delta: float) -> void:
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
@@ -109,6 +119,11 @@ func _process(_delta: float) -> void:
|
||||
# Send queued input to simulation
|
||||
var inputs = InputMapper.flush_queue()
|
||||
for input in inputs:
|
||||
# #495: F12 WRONG button — client-only, trigger bug report capture
|
||||
if input.action == InputMapper.Action.BUG_REPORT:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
if input.action == InputMapper.Action.INTERACT:
|
||||
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
||||
var target_id: int = -1
|
||||
@@ -197,3 +212,9 @@ func _on_dialogue_dismissed() -> void:
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
@@ -14,6 +14,8 @@ extends GdUnitTestSuite
|
||||
|
||||
|
||||
var _instance: Node = null
|
||||
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
@@ -28,6 +30,8 @@ func before_test() -> void:
|
||||
GameState.current_dialogue = null
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.room_id = null
|
||||
GameState.gauntlet_mode = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
@@ -77,91 +81,85 @@ func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
|
||||
return result.value
|
||||
|
||||
|
||||
# -- Test 1: F12 Bug Report Capture -------------------------------------------
|
||||
# Regression guard: F12 press must not crash or cause unintended side effects.
|
||||
# When #495 lands, this test verifies the full capture flow:
|
||||
# 1. Game pauses (tick_rate → Paused)
|
||||
# 2. Capture dialog appears
|
||||
# 3. Three files saved to tests/bug-reports/YYYY-MM-DD_HH-MM-SS/
|
||||
# 4. Game unpauses
|
||||
#
|
||||
# Until #495: verifies F12 is inert — no crash, no state corruption.
|
||||
func _make_gauntlet_hud() -> Control:
|
||||
var hud = Control.new()
|
||||
hud.set_script(GauntletHUDScript)
|
||||
auto_free(hud)
|
||||
add_child(hud)
|
||||
hud._personal_bests = {} # Clear any stats loaded from disk
|
||||
return hud
|
||||
|
||||
func test_f12_press_no_crash_without_handler() -> void:
|
||||
# Load main scene — full game tree
|
||||
|
||||
func _make_bug_report_dialog() -> Control:
|
||||
var dialog = Control.new()
|
||||
dialog.set_script(BugReportDialogScript)
|
||||
auto_free(dialog)
|
||||
add_child(dialog)
|
||||
return dialog
|
||||
|
||||
|
||||
# -- Test 1: F12 Bug Report Capture -------------------------------------------
|
||||
# #495: BUG_REPORT action triggers the WRONG button capture flow.
|
||||
# Verifies: dialog exists, activates on action, no state corruption.
|
||||
# Note: Input.parse_input_event + _unhandled_input is unreliable in headless
|
||||
# test mode. Tests drive the input queue directly for deterministic coverage.
|
||||
|
||||
func test_bug_report_dialog_exists_in_scene() -> void:
|
||||
# Verify the BugReportDialog node is present and hidden by default.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
# Process one frame to let _ready() and initial snapshot settle.
|
||||
# The test snapshot at tick 1 includes a monologue that gets consumed here.
|
||||
_instance._process(0.016)
|
||||
|
||||
# Record state AFTER initialization — this is the stable baseline.
|
||||
var tick_rate_before: String = GameState.game_time.get("tick_rate", "Full")
|
||||
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
|
||||
assert_that(dialog).override_failure_message(
|
||||
"BugReportDialog node should exist in scene tree"
|
||||
).is_not_null()
|
||||
if dialog is CanvasItem:
|
||||
assert_that((dialog as CanvasItem).visible).override_failure_message(
|
||||
"BugReportDialog should be hidden by default"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_bug_report_activates_on_action() -> void:
|
||||
# Inject BUG_REPORT action directly into the queue and verify main.gd
|
||||
# triggers the dialog. This tests the full main._process() handling path.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
_instance._process(0.016)
|
||||
|
||||
# Record baseline state
|
||||
var mono_before: Variant = GameState.current_monologue
|
||||
var dialogue_before: Variant = GameState.current_dialogue
|
||||
|
||||
# Simulate F12 key press via the Godot input system.
|
||||
# InputMapper._unhandled_input() checks action bindings — F12 is not bound
|
||||
# to any action yet, so the event should pass through harmlessly.
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = KEY_F12
|
||||
event.pressed = true
|
||||
event.key_label = KEY_F12
|
||||
Input.parse_input_event(event)
|
||||
# Inject BUG_REPORT action into InputMapper queue
|
||||
InputMapper.input_queue.append({
|
||||
"action": InputMapper.Action.BUG_REPORT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
# Process a frame to let the event propagate
|
||||
# Process a frame — main.gd flushes the queue and triggers dialog
|
||||
_instance._process(0.016)
|
||||
|
||||
# Assert: no state corruption from unhandled F12
|
||||
assert_that(GameState.game_time.get("tick_rate", "Full")).override_failure_message(
|
||||
"F12 press should not change tick_rate (no handler yet)"
|
||||
).is_equal(tick_rate_before)
|
||||
# Assert: dialog should be active
|
||||
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
|
||||
assert_that(dialog).is_not_null()
|
||||
if dialog and dialog.has_method("is_active"):
|
||||
assert_that(dialog.is_active()).override_failure_message(
|
||||
"BugReportDialog should be active after BUG_REPORT action"
|
||||
).is_true()
|
||||
|
||||
# Assert: no unintended state corruption
|
||||
assert_that(GameState.current_monologue).override_failure_message(
|
||||
"F12 press should not spawn a monologue"
|
||||
"BUG_REPORT should not spawn a monologue"
|
||||
).is_equal(mono_before)
|
||||
assert_that(GameState.current_dialogue).override_failure_message(
|
||||
"F12 press should not spawn a dialogue"
|
||||
"BUG_REPORT should not spawn a dialogue"
|
||||
).is_equal(dialogue_before)
|
||||
|
||||
# Release the key
|
||||
var release := InputEventKey.new()
|
||||
release.keycode = KEY_F12
|
||||
release.pressed = false
|
||||
release.key_label = KEY_F12
|
||||
Input.parse_input_event(release)
|
||||
|
||||
|
||||
func test_f12_does_not_queue_input_action() -> void:
|
||||
# Verify F12 does not produce any action in InputMapper's queue.
|
||||
# When #495 adds the "bug_report" action, this test will be updated
|
||||
# to verify the correct action IS queued.
|
||||
InputMapper.input_queue.clear()
|
||||
|
||||
var event := InputEventKey.new()
|
||||
event.keycode = KEY_F12
|
||||
event.pressed = true
|
||||
event.key_label = KEY_F12
|
||||
Input.parse_input_event(event)
|
||||
|
||||
# Give InputMapper a frame to process
|
||||
InputMapper._process(0.016)
|
||||
|
||||
# F12 is not bound to any InputMapper.Action — queue should remain empty
|
||||
assert_that(InputMapper.input_queue.size()).override_failure_message(
|
||||
"F12 should not produce any input action (no binding exists yet)"
|
||||
).is_equal(0)
|
||||
|
||||
# Cleanup
|
||||
var release := InputEventKey.new()
|
||||
release.keycode = KEY_F12
|
||||
release.pressed = false
|
||||
release.key_label = KEY_F12
|
||||
Input.parse_input_event(release)
|
||||
InputMapper.flush_queue()
|
||||
|
||||
|
||||
# -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode -----------------
|
||||
# When #496 lands, it adds a room timer and personal-bests overlay.
|
||||
@@ -214,17 +212,14 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
|
||||
|
||||
# Apply to GameState — gauntlet-related state should not exist
|
||||
GameState.apply_snapshot(snapshot)
|
||||
# Guard: when #496 adds GameState.room_id / gauntlet_mode properties,
|
||||
# these assertions become falsifiable — they'll catch any code path that
|
||||
# sets gauntlet state from a non-gauntlet snapshot. Currently Object.get()
|
||||
# returns null for nonexistent properties, so this passes trivially until
|
||||
# the properties are defined.
|
||||
assert_that(GameState.get("room_id")).override_failure_message(
|
||||
"GameState.room_id should not exist or be null in non-gauntlet mode"
|
||||
).is_null()
|
||||
assert_that(GameState.get("gauntlet_mode")).override_failure_message(
|
||||
"GameState.gauntlet_mode should not exist or be null in non-gauntlet mode"
|
||||
# #496: GameState now has room_id (Variant, null) and gauntlet_mode (bool, false).
|
||||
# A non-gauntlet snapshot must leave these at their defaults.
|
||||
assert_that(GameState.room_id).override_failure_message(
|
||||
"GameState.room_id should be null in non-gauntlet mode"
|
||||
).is_null()
|
||||
assert_that(GameState.gauntlet_mode).override_failure_message(
|
||||
"GameState.gauntlet_mode should be false in non-gauntlet mode"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
@@ -253,6 +248,358 @@ func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
|
||||
# Timer lifecycle, personal bests, room change, visibility.
|
||||
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
|
||||
|
||||
func test_format_time_zero() -> void:
|
||||
# Static utility: 0 seconds → "00:00"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(0.0)).is_equal("00:00")
|
||||
|
||||
|
||||
func test_format_time_sub_minute() -> void:
|
||||
# 47.9 seconds (truncates, no rounding) → "00:47"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(47.9)).is_equal("00:47")
|
||||
|
||||
|
||||
func test_format_time_over_minute() -> void:
|
||||
# 98.3 seconds → 1 min 38 sec → "01:38"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(98.3)).is_equal("01:38")
|
||||
|
||||
|
||||
func test_gauntlet_hud_shows_when_gauntlet_mode_active() -> void:
|
||||
# HUD must become visible when gauntlet_mode is true in the snapshot.
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud.visible).is_false()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"GauntletHUD must be visible when gauntlet_mode is true"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_hud_hides_when_gauntlet_mode_deactivates() -> void:
|
||||
# HUD must hide when gauntlet_mode transitions from true to false.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).is_true()
|
||||
# Switch to non-gauntlet
|
||||
GameState.gauntlet_mode = false
|
||||
GameState.room_id = null
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"GauntletHUD must hide when gauntlet_mode becomes false"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_starts_on_room_entry() -> void:
|
||||
# Timer starts running and tracks room_id after room entry.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should be running after room entry"
|
||||
).is_true()
|
||||
assert_that(hud.get_current_room_id()).is_equal("room_1")
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_increments_with_delta() -> void:
|
||||
# Timer accumulates real time via _process(delta).
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(0.5)
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should increment by delta (0.5s)"
|
||||
).is_equal_approx(0.5, 0.01)
|
||||
hud._process(1.0)
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should accumulate (0.5 + 1.0 = 1.5s)"
|
||||
).is_equal_approx(1.5, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_resets_on_room_change() -> void:
|
||||
# Changing room_id resets the timer to 0 and starts counting for the new room.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(5.0)
|
||||
assert_that(hud.get_timer_seconds() > 4.0).is_true()
|
||||
# Change room
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should reset to 0 on room change"
|
||||
).is_equal_approx(0.0, 0.01)
|
||||
assert_that(hud.get_current_room_id()).is_equal("room_2")
|
||||
assert_that(hud.is_timer_running()).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_hud_records_personal_best() -> void:
|
||||
# Completing a room (by entering a new room) records a personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(10.0)
|
||||
# Entering room_2 triggers _record_room_completion for room_1
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB for room_1 should be ~10.0s after first completion"
|
||||
).is_equal_approx(10.0, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_non_pb_does_not_overwrite() -> void:
|
||||
# A worse time must not overwrite an existing personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud._personal_bests["room_1"] = 5.0 # Existing PB of 5 seconds
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(10.0) # Worse time than existing PB
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB should remain 5.0 when new time (10.0) is worse"
|
||||
).is_equal_approx(5.0, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_pb_overwrites_when_faster() -> void:
|
||||
# A faster time must overwrite an existing personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud._personal_bests["room_1"] = 15.0 # Existing PB of 15 seconds
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(8.0) # Better time
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB should update to 8.0 when new time beats old PB (15.0)"
|
||||
).is_equal_approx(8.0, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_null_room_in_gauntlet_mode() -> void:
|
||||
# Gauntlet mode active but no room_id yet — HUD visible, timer stopped.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = null
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"HUD should be visible in gauntlet mode even without room_id"
|
||||
).is_true()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should NOT run when room_id is null"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_paused_when_not_visible() -> void:
|
||||
# Timer should not accumulate when HUD is not visible (non-gauntlet mode).
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(2.0)
|
||||
assert_that(hud.get_timer_seconds()).is_equal_approx(2.0, 0.01)
|
||||
# Switch to non-gauntlet (hides HUD)
|
||||
GameState.gauntlet_mode = false
|
||||
hud.update_from_state()
|
||||
hud._process(5.0) # Should NOT accumulate
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer must not accumulate when HUD is hidden"
|
||||
).is_equal_approx(2.0, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_finalize_records_current_room() -> void:
|
||||
# finalize() records the current room's time and stops the timer.
|
||||
# Called on disconnect via _on_connection_state_changed.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(7.5)
|
||||
hud.finalize()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should stop after finalize"
|
||||
).is_false()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"Finalize should record current room's time as PB"
|
||||
).is_equal_approx(7.5, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_session_tracks_attempts() -> void:
|
||||
# Each room entry increments the attempt counter in _session_rooms.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(3.0)
|
||||
# Re-enter same room (via room change and back)
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud._session_rooms.has("room_1")).is_true()
|
||||
assert_that(hud._session_rooms["room_1"]["attempts"]).override_failure_message(
|
||||
"Room should show 2 attempts after two entries"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
|
||||
# Snapshot with gauntlet fields applied to GameState, consumed by HUD.
|
||||
# End-to-end: bytes → decode → apply → update_from_state.
|
||||
var bytes := _make_snapshot_bytes({
|
||||
"tick": 10,
|
||||
"gauntlet_mode": true,
|
||||
"room_id": "warehouse_01",
|
||||
})
|
||||
SimBridge.receive_bytes(bytes)
|
||||
GameState.apply_snapshot(SimBridge._last_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_true()
|
||||
assert_that(GameState.room_id).is_equal("warehouse_01")
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).is_true()
|
||||
assert_that(hud.get_current_room_id()).is_equal("warehouse_01")
|
||||
assert_that(hud.is_timer_running()).is_true()
|
||||
|
||||
|
||||
# -- BugReportDialog Feature Tests (#495) ------------------------------------
|
||||
# Pause/unpause lifecycle, wire guard, text render, state machine.
|
||||
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
|
||||
|
||||
func test_bug_report_sends_pause_on_open() -> void:
|
||||
# start_capture() must send Pause to the server.
|
||||
SimBridge.connect_to_sim()
|
||||
SimBridge._test_input_queue.clear()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
assert_that(dialog.is_active()).is_true()
|
||||
assert_that(SimBridge._test_input_queue.has("Pause")).override_failure_message(
|
||||
"Opening bug report should send Pause to server"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_sends_unpause_on_close() -> void:
|
||||
# _close() must send Unpause to the server.
|
||||
SimBridge.connect_to_sim()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
SimBridge._test_input_queue.clear()
|
||||
dialog._close()
|
||||
assert_that(dialog.is_active()).is_false()
|
||||
assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message(
|
||||
"Closing bug report should send Unpause to server"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_not_double_activatable() -> void:
|
||||
# Calling start_capture() twice must not double-activate or send duplicate Pause.
|
||||
SimBridge.connect_to_sim()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
assert_that(dialog.is_active()).is_true()
|
||||
SimBridge._test_input_queue.clear()
|
||||
dialog.start_capture() # Second call — should be blocked
|
||||
assert_that(SimBridge._test_input_queue.size()).override_failure_message(
|
||||
"Double-activation should be blocked (no second Pause sent)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_bug_report_action_not_on_wire() -> void:
|
||||
# BUG_REPORT is client-only — must not produce a wire-format action name.
|
||||
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.BUG_REPORT)
|
||||
assert_that(wire_name).override_failure_message(
|
||||
"BUG_REPORT must not produce a wire action name (client-only)"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
func test_bug_report_render_text_contains_tick_and_entities() -> void:
|
||||
# _render_snapshot_text() must include tick number and entity data from GameState.
|
||||
GameState.current_tick = 42
|
||||
GameState.player_position = Vector2(5.0, 7.0)
|
||||
GameState.player_facing = "South"
|
||||
GameState.player_stance = "Crouch"
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1, "x": 5.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Forward",
|
||||
}]
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {"day": 1, "day_phase": "Evening", "tick_rate": "Full"}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.contains("t42")).override_failure_message(
|
||||
"Render text should contain tick number"
|
||||
).is_true()
|
||||
assert_that(text.contains("Entities (1)")).override_failure_message(
|
||||
"Render text should show entity count"
|
||||
).is_true()
|
||||
assert_that(text.contains("Player")).override_failure_message(
|
||||
"Render text should contain entity kind"
|
||||
).is_true()
|
||||
assert_that(text.contains("South")).override_failure_message(
|
||||
"Render text should contain player facing"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_render_text_with_monologue_and_dialogue() -> void:
|
||||
# _render_snapshot_text() should include active monologue and dialogue.
|
||||
GameState.current_tick = 10
|
||||
GameState.player_position = Vector2(3.0, 3.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {"day": 0, "day_phase": "Morning", "tick_rate": "Full"}
|
||||
GameState.current_monologue = {"text": "Something is wrong here."}
|
||||
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?"}
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.contains("Something is wrong here.")).override_failure_message(
|
||||
"Render text should include monologue text"
|
||||
).is_true()
|
||||
assert_that(text.contains("Kael")).override_failure_message(
|
||||
"Render text should include dialogue NPC name"
|
||||
).is_true()
|
||||
assert_that(text.contains("Who are you?")).override_failure_message(
|
||||
"Render text should include dialogue speech"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_render_text_empty_snapshot() -> void:
|
||||
# Edge case: F12 pressed before any snapshot data arrives.
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
# Should not crash, should produce some output
|
||||
assert_that(text.length() > 0).override_failure_message(
|
||||
"Render text should not be empty even with no snapshot data"
|
||||
).is_true()
|
||||
assert_that(text.contains("Entities (0)")).override_failure_message(
|
||||
"Empty snapshot should show 0 entities"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Helper: recursive node search --------------------------------------------
|
||||
|
||||
func _find_node_recursive(root: Node, target_name: String) -> Node:
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
extends Control
|
||||
|
||||
## #495: WRONG button (F12) MVP — bug report capture dialog.
|
||||
## On F12: pause sim, show one-line prompt, save snapshot + render + description, unpause.
|
||||
## Output: user://bug-reports/gauntlet-t{tick}-{timestamp}/
|
||||
|
||||
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
|
||||
|
||||
var _line_edit: LineEdit = null
|
||||
var _active: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
|
||||
func start_capture() -> void:
|
||||
if _active:
|
||||
return
|
||||
_active = true
|
||||
visible = true
|
||||
|
||||
# Pause the simulation
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.PAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
# Create the LineEdit dynamically
|
||||
_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_text_submitted(text: String) -> void:
|
||||
_save_report(text)
|
||||
_close()
|
||||
capture_completed.emit()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _active:
|
||||
return
|
||||
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
_close()
|
||||
capture_cancelled.emit()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _close() -> void:
|
||||
_active = false
|
||||
visible = false
|
||||
if _line_edit:
|
||||
_line_edit.queue_free()
|
||||
_line_edit = null
|
||||
|
||||
# Unpause the simulation
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.UNPAUSE,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
|
||||
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 — full current snapshot as JSON
|
||||
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
|
||||
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.close()
|
||||
files_saved += 1
|
||||
else:
|
||||
push_error("BugReport: failed to write %s" % desc_path)
|
||||
|
||||
push_warning("BugReport: saved %d/3 files to %s" % [files_saved, base_path])
|
||||
|
||||
|
||||
## 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 _active:
|
||||
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 _active
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/bug_report_dialog.gd" id="1_bugreport"]
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog, ModalLayer
|
||||
[node name="BugReportDialog" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_bugreport")
|
||||
@@ -0,0 +1,182 @@
|
||||
extends Control
|
||||
|
||||
## #496: Gauntlet HUD — room timer + personal bests.
|
||||
## Shows TIMER: MM:SS (PB: MM:SS) in top-right, below StanceIndicator.
|
||||
## Hidden in non-gauntlet mode. Stats persisted to user://dev/gauntlet-stats.json.
|
||||
|
||||
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
|
||||
const TIMER_COLOR := Color("#c8d0e0") # Default insert text
|
||||
const PB_COLOR := Color("#6bc9a6") # Friendly green — personal best
|
||||
const NEW_PB_COLOR := Color("#e8c547") # Amber flash on new PB
|
||||
const FONT_SIZE := 13
|
||||
const PADDING := Vector2(10, 6)
|
||||
const STATS_PATH := "user://dev/gauntlet-stats.json"
|
||||
|
||||
var _timer_seconds: float = 0.0
|
||||
var _timer_running: bool = false
|
||||
var _current_room_id: Variant = null
|
||||
var _personal_bests: Dictionary = {} # room_id -> float (seconds)
|
||||
var _session_rooms: Dictionary = {} # room_id -> {attempts: int, best: float}
|
||||
var _new_pb_flash: float = 0.0 # Countdown for PB flash effect
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
visible = false
|
||||
_load_stats()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if _timer_running:
|
||||
_timer_seconds += delta
|
||||
queue_redraw()
|
||||
if _new_pb_flash > 0.0:
|
||||
_new_pb_flash -= delta
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not GameState.gauntlet_mode:
|
||||
if visible:
|
||||
visible = false
|
||||
return
|
||||
|
||||
if not visible:
|
||||
visible = true
|
||||
queue_redraw()
|
||||
|
||||
var new_room_id: Variant = GameState.room_id
|
||||
if new_room_id == null:
|
||||
# Gauntlet mode but no room yet — stop timer, clear tracked room
|
||||
# so re-entry to the same room after null triggers a restart.
|
||||
_timer_running = false
|
||||
_current_room_id = null
|
||||
return
|
||||
|
||||
if new_room_id != _current_room_id:
|
||||
_on_room_change(str(new_room_id))
|
||||
|
||||
|
||||
func _on_room_change(new_room_id: String) -> void:
|
||||
# Record completion of previous room
|
||||
if _current_room_id != null and _timer_running:
|
||||
_record_room_completion(_current_room_id, _timer_seconds)
|
||||
|
||||
# Start timer for new room
|
||||
_current_room_id = new_room_id
|
||||
_timer_seconds = 0.0
|
||||
_timer_running = true
|
||||
|
||||
# Track session stats
|
||||
if not _session_rooms.has(new_room_id):
|
||||
_session_rooms[new_room_id] = {"attempts": 0, "best": INF}
|
||||
_session_rooms[new_room_id]["attempts"] += 1
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _record_room_completion(completed_room_id: String, seconds: float) -> void:
|
||||
var old_pb: float = _personal_bests.get(completed_room_id, INF)
|
||||
if seconds < old_pb:
|
||||
_personal_bests[completed_room_id] = seconds
|
||||
_new_pb_flash = 2.0 # Flash for 2 seconds
|
||||
_save_stats()
|
||||
|
||||
# Update session tracking
|
||||
if _session_rooms.has(completed_room_id):
|
||||
var entry: Dictionary = _session_rooms[completed_room_id]
|
||||
if seconds < entry["best"]:
|
||||
entry["best"] = seconds
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
|
||||
var timer_text := "TIMER: " + _format_time(_timer_seconds)
|
||||
var pb_text := ""
|
||||
if _current_room_id != null and _personal_bests.has(_current_room_id):
|
||||
pb_text = " (PB: " + _format_time(_personal_bests[_current_room_id]) + ")"
|
||||
|
||||
var full_text := timer_text + pb_text
|
||||
var text_size := font.get_string_size(full_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
|
||||
var box_size := text_size + PADDING * 2
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR)
|
||||
|
||||
# Timer text
|
||||
var y_offset := PADDING.y + text_size.y
|
||||
var timer_size := font.get_string_size(timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
|
||||
draw_string(font, Vector2(PADDING.x, y_offset), timer_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, TIMER_COLOR)
|
||||
|
||||
# PB text (different color)
|
||||
if not pb_text.is_empty():
|
||||
var pb_color: Color = NEW_PB_COLOR if _new_pb_flash > 0.0 else PB_COLOR
|
||||
draw_string(font, Vector2(PADDING.x + timer_size.x, y_offset), pb_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, pb_color)
|
||||
|
||||
|
||||
static func _format_time(seconds: float) -> String:
|
||||
var total_secs := int(seconds)
|
||||
var mins := total_secs / 60
|
||||
var secs := total_secs % 60
|
||||
return "%02d:%02d" % [mins, secs]
|
||||
|
||||
|
||||
func _load_stats() -> void:
|
||||
if not FileAccess.file_exists(STATS_PATH):
|
||||
return
|
||||
var file := FileAccess.open(STATS_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
return
|
||||
var json := JSON.new()
|
||||
var err := json.parse(file.get_as_text())
|
||||
file.close()
|
||||
if err == OK and json.data is Dictionary:
|
||||
_personal_bests = json.data
|
||||
|
||||
|
||||
func _save_stats() -> void:
|
||||
var file := FileAccess.open(STATS_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("GauntletHUD: cannot write stats to %s" % STATS_PATH)
|
||||
return
|
||||
file.store_string(JSON.stringify(_personal_bests, "\t"))
|
||||
file.close()
|
||||
|
||||
|
||||
func print_session_summary() -> void:
|
||||
if _session_rooms.is_empty():
|
||||
return
|
||||
print("=== Gauntlet Session Summary ===")
|
||||
for room_id in _session_rooms:
|
||||
var entry: Dictionary = _session_rooms[room_id]
|
||||
var best_str := _format_time(entry["best"]) if entry["best"] != INF else "--:--"
|
||||
var pb_str := _format_time(_personal_bests[room_id]) if _personal_bests.has(room_id) else "--:--"
|
||||
print(" Room %s: %d attempts, session best %s, all-time PB %s" % [
|
||||
room_id, entry["attempts"], best_str, pb_str])
|
||||
print("================================")
|
||||
|
||||
|
||||
# Record current room if timer is running (called on disconnect)
|
||||
func finalize() -> void:
|
||||
if _current_room_id != null and _timer_running:
|
||||
_record_room_completion(_current_room_id, _timer_seconds)
|
||||
_timer_running = false
|
||||
print_session_summary()
|
||||
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
func get_timer_seconds() -> float:
|
||||
return _timer_seconds
|
||||
|
||||
func is_timer_running() -> bool:
|
||||
return _timer_running
|
||||
|
||||
func get_current_room_id() -> Variant:
|
||||
return _current_room_id
|
||||
|
||||
func get_personal_best(for_room_id: String) -> float:
|
||||
return _personal_bests.get(for_room_id, INF)
|
||||
@@ -0,0 +1,17 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/gauntlet_hud.gd" id="1_gauntlet"]
|
||||
|
||||
; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator
|
||||
[node name="GauntletHUD" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 1
|
||||
anchor_left = 1.0
|
||||
anchor_right = 1.0
|
||||
offset_left = -160.0
|
||||
offset_top = 48.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = 74.0
|
||||
grow_horizontal = 0
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_gauntlet")
|
||||
Reference in New Issue
Block a user