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:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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