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 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 10:53:05 +01:00
co-authored by Claude Opus 4.6
parent 318a1e8b07
commit bc073c891f
5 changed files with 241 additions and 2 deletions
+9 -2
View File
@@ -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")]
+14
View File
@@ -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:
+21
View File
@@ -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()
+180
View File
@@ -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)
+17
View File
@@ -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")