From bc073c891f8e42c3546a61d8d0485865445321b9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 10:53:05 +0100 Subject: [PATCH] feat(client): add gauntlet room timer + personal bests (#496) GauntletHUD in UILayer shows TIMER: MM:SS (PB: MM:SS). Timer starts on room entry, resets on room change, records personal bests to user://gauntlet-stats.json. Session summary printed on disconnect. Hidden in non-gauntlet mode (anti-tedium guard). Adds room_id and gauntlet_mode fields to GameState, parsed from ObserverSnapshot. Main.gd wires update_from_state() and finalize(). Co-Authored-By: Claude Opus 4.6 --- client/scenes/main.tscn | 11 +- client/scripts/autoloads/game_state.gd | 14 ++ client/scripts/main.gd | 21 +++ client/ui/gauntlet_hud.gd | 180 +++++++++++++++++++++++++ client/ui/gauntlet_hud.tscn | 17 +++ 5 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 client/ui/gauntlet_hud.gd create mode 100644 client/ui/gauntlet_hud.tscn diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e2caed8cb..6073bd572 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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")] diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index cc5856d91..78866ba2f 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -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: diff --git a/client/scripts/main.gd b/client/scripts/main.gd index a6f56ae33..e92f30849 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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() diff --git a/client/ui/gauntlet_hud.gd b/client/ui/gauntlet_hud.gd new file mode 100644 index 000000000..aaf13f711 --- /dev/null +++ b/client/ui/gauntlet_hud.gd @@ -0,0 +1,180 @@ +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 tests/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://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, wait + _timer_running = false + return + + if new_room_id != _current_room_id: + _on_room_change(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) diff --git a/client/ui/gauntlet_hud.tscn b/client/ui/gauntlet_hud.tscn new file mode 100644 index 000000000..bd90467e8 --- /dev/null +++ b/client/ui/gauntlet_hud.tscn @@ -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")