Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
209 lines
5.6 KiB
GDScript
209 lines
5.6 KiB
GDScript
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)
|