feat(ui): add diegetic time display on insert HUD (#263)

Time display on InsertOverlay (CanvasLayer 10) shows station local
time (HH:MM), day phase with cycle-tinted color, and day number.
Reads SimulationTime from GameState.game_time via update_from_state().
Adds Constants.format_game_time() helper for testability.
Placeholder layout — position refines when #314 wireframe lands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 11:16:23 +01:00
co-authored by Claude Opus 4.6
parent 381526ff74
commit 66a435e201
6 changed files with 425 additions and 1 deletions
+5 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=23 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=24 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"]
@@ -22,6 +22,7 @@
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
[ext_resource type="PackedScene" path="res://ui/time_display.tscn" id="23_tdisplay"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
@@ -118,6 +119,9 @@ zoom = Vector2(2, 2)
[node name="InsertOverlay" type="CanvasLayer" parent="."]
layer = 10
; #263: Time display — diegetic insert clock, top-left placeholder (D-013, D-031)
[node name="TimeDisplay" parent="InsertOverlay" instance=ExtResource("23_tdisplay")]
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
+6
View File
@@ -96,6 +96,12 @@ const FACING_INDICATOR_OFFSET: float = 14.0
# two columns of text comfortably, leaves world game visible alongside.
const DIALOGUE_MAX_WIDTH: int = 1200
# D-031: Format game-minutes (0..1439) as station local time string "HH:MM".
static func format_game_time(time_of_day: int) -> String:
var hours: int = time_of_day / 60
var minutes: int = time_of_day % 60
return "%02d:%02d" % [hours, minutes]
# Default camera zoom — used as fallback when get_camera_2d() returns null
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
+5
View File
@@ -14,6 +14,7 @@ extends Node2D
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
@onready var time_display = $InsertOverlay/TimeDisplay # #263: diegetic time display (D-013, D-031)
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
@@ -131,6 +132,10 @@ func _process(delta: float) -> void:
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
checklist_overlay.update_from_state()
# #263: Update time display (D-013, D-031)
if time_display and time_display.has_method("update_from_state"):
time_display.update_from_state()
# #511: Update debug overlay (F3 toggle, dev tool)
if debug_overlay and debug_overlay.has_method("update_from_state"):
debug_overlay.update_from_state()
+307
View File
@@ -0,0 +1,307 @@
## Sprint 17 — Time display on insert HUD (#263)
## Tests for Constants.format_game_time(), InsertClock wiring, and GameState integration.
##
## Spec refs:
## D-031 (game time: 10 ticks = 1 game-minute, 1440 min/day, HH:MM display)
## D-051 (diegetic insert display)
##
## Implementation: client/ui/insert_clock.gd — draw-based Control at UILayer/InsertClock.
## Format function: Constants.format_game_time(time_of_day: int) -> String (extracted for
## testability from insert_clock.gd:43 inline `"%02d:%02d" % [tod/60, tod%60]`).
class_name TestTimeDisplaySprint17
extends GdUnitTestSuite
var _clock: Control = null
func before_test() -> void:
SimBridge.reset_test_state()
GameState.game_time = {}
var ClockScript = load("res://ui/insert_clock.gd")
_clock = Control.new()
_clock.set_script(ClockScript)
add_child(_clock)
func after_test() -> void:
if _clock and is_instance_valid(_clock):
_clock.queue_free()
_clock = null
GameState.game_time = {}
# -------------------------------------------------------------------------
# Constants.format_game_time() — pure logic, D-031
# -------------------------------------------------------------------------
func test_format_midnight() -> void:
assert_that(Constants.format_game_time(0)).is_equal("00:00")
func test_format_morning_start() -> void:
# 360 game-minutes = 6 h exactly (Morning phase boundary, D-031)
assert_that(Constants.format_game_time(360)).is_equal("06:00")
func test_format_noon() -> void:
assert_that(Constants.format_game_time(720)).is_equal("12:00")
func test_format_evening_start() -> void:
assert_that(Constants.format_game_time(1080)).is_equal("18:00")
func test_format_end_of_day() -> void:
# Last valid minute — must not wrap or overflow
assert_that(Constants.format_game_time(1439)).is_equal("23:59")
func test_format_pads_single_digit_hour() -> void:
# 30 min = 00:30
assert_that(Constants.format_game_time(30)).is_equal("00:30")
func test_format_pads_single_digit_minute() -> void:
# 121 min = 02:01
assert_that(Constants.format_game_time(121)).is_equal("02:01")
func test_format_half_past_hour() -> void:
assert_that(Constants.format_game_time(90)).is_equal("01:30")
func test_format_arbitrary_midday() -> void:
# 835 min = 13:55
assert_that(Constants.format_game_time(835)).is_equal("13:55")
# -------------------------------------------------------------------------
# InsertClock initial state
# -------------------------------------------------------------------------
func test_insert_clock_initial_time_str_is_placeholder() -> void:
# Before any snapshot, _time_str must be "--:--" (not shown by _draw)
assert_that(_clock._time_str).is_equal("--:--")
func test_insert_clock_initial_phase_str_is_empty() -> void:
assert_that(_clock._phase_str).is_equal("")
func test_insert_clock_initial_day_str_is_empty() -> void:
assert_that(_clock._day_str).is_equal("")
# -------------------------------------------------------------------------
# InsertClock.update_from_state() — reads GameState.game_time
# -------------------------------------------------------------------------
func test_update_from_state_formats_time_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("12:00")
func test_update_from_state_sets_phase_str() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1080, "day_phase": "Evening", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_str).is_equal("Evening")
func test_update_from_state_sets_day_str_one_indexed() -> void:
# Day 0 from server → "D1" display (1-indexed)
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D1")
func test_update_from_state_day_2() -> void:
GameState.game_time = {
"day": 1, "time_of_day": 50, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._day_str).is_equal("D2")
func test_update_from_state_midnight() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("00:00")
func test_update_from_state_end_of_day() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 1439, "day_phase": "Night", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("23:59")
func test_update_from_state_skips_empty_game_time() -> void:
# Empty game_time must not overwrite --:-- (guard in update_from_state)
GameState.game_time = {}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("--:--")
func test_update_from_state_deduplicates_same_tick() -> void:
# Calling twice with identical data must produce same result (signature cache)
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
# Call again — result unchanged, no crash
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("06:00")
func test_update_from_state_updates_on_new_tick() -> void:
# time_of_day changes → signature changes → _time_str updates
GameState.game_time = {
"day": 0, "time_of_day": 60, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("01:00")
GameState.game_time = {
"day": 0, "time_of_day": 120, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._time_str).is_equal("02:00")
# -------------------------------------------------------------------------
# InsertClock.PHASE_COLORS — all four D-031 phases have colors
# -------------------------------------------------------------------------
func test_phase_colors_has_morning() -> void:
assert_that(_clock.PHASE_COLORS.has("Morning")).is_true()
func test_phase_colors_has_afternoon() -> void:
assert_that(_clock.PHASE_COLORS.has("Afternoon")).is_true()
func test_phase_colors_has_evening() -> void:
assert_that(_clock.PHASE_COLORS.has("Evening")).is_true()
func test_phase_colors_has_night() -> void:
assert_that(_clock.PHASE_COLORS.has("Night")).is_true()
func test_phase_color_applied_after_update() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(_clock.PHASE_COLORS["Morning"])
func test_phase_color_unknown_phase_uses_dim_fallback() -> void:
# Unknown phase string → Constants.IMPLANT_TEXT_DIM
GameState.game_time = {
"day": 0, "time_of_day": 100, "day_phase": "Twilight", "tick_rate": "Full",
}
_clock.update_from_state()
assert_that(_clock._phase_color).is_equal(Constants.IMPLANT_TEXT_DIM)
# -------------------------------------------------------------------------
# GameState: game_time field parsing (confirms apply_snapshot wiring)
# -------------------------------------------------------------------------
func test_game_time_populated_from_snapshot() -> void:
GameState.apply_snapshot({
"tick": 5, "entities": [],
"game_time": {
"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(720)
func test_game_time_all_four_phases_store_correctly() -> void:
for phase in ["Morning", "Afternoon", "Evening", "Night"]:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {
"day": 0, "time_of_day": 100, "day_phase": phase, "tick_rate": "Full",
},
})
assert_that(GameState.game_time.get("day_phase")).is_equal(phase)
func test_game_time_missing_from_snapshot_preserves_previous() -> void:
GameState.game_time = {
"day": 0, "time_of_day": 360, "day_phase": "Morning", "tick_rate": "Full",
}
GameState.apply_snapshot({"tick": 2, "entities": []})
assert_that(GameState.game_time.get("time_of_day")).is_equal(360)
func test_game_time_zero_time_of_day_stored() -> void:
# time_of_day = 0 (midnight) must not be treated as falsy/missing
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 0, "day_phase": "Night", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("time_of_day")).is_equal(0)
# -------------------------------------------------------------------------
# SimBridge test mode: game_time fields are valid
# -------------------------------------------------------------------------
func test_sim_bridge_snapshot_has_game_time() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.has("game_time")).is_true()
assert_that(snap.game_time is Dictionary).is_true()
func test_sim_bridge_game_time_has_required_fields() -> void:
var snap = SimBridge._test_snapshot()
var gt: Dictionary = snap.game_time
assert_that(gt.has("day")).is_true()
assert_that(gt.has("time_of_day")).is_true()
assert_that(gt.has("day_phase")).is_true()
assert_that(gt.has("tick_rate")).is_true()
func test_sim_bridge_time_of_day_is_non_negative() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.game_time.get("time_of_day", -1) as int).is_greater_equal(0)
func test_sim_bridge_day_phase_is_valid() -> void:
var snap = SimBridge._test_snapshot()
var phase: String = snap.game_time.get("day_phase", "")
assert_that(["Morning", "Afternoon", "Evening", "Night"].has(phase)).is_true()
# -------------------------------------------------------------------------
# Scene: InsertClock node at UILayer/InsertClock
# -------------------------------------------------------------------------
func test_insert_clock_exists_in_ui_layer() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
assert_that(instance.get_node_or_null("UILayer/InsertClock")).is_not_null()
func test_insert_clock_time_str_updates_after_process() -> void:
var scene := load("res://scenes/main.tscn")
var instance = scene.instantiate()
auto_free(instance)
add_child(instance)
GameState.apply_snapshot({
"tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [],
"game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"},
})
instance._process(0.016)
var clock = instance.get_node_or_null("UILayer/InsertClock")
assert_that(clock).is_not_null()
assert_that(clock._time_str).is_equal("12:00")
# -------------------------------------------------------------------------
# Regression: debug_overlay still reads game_time correctly (#511)
# -------------------------------------------------------------------------
func test_debug_overlay_reads_game_time_day_phase() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"},
})
assert_that(GameState.game_time.get("day_phase")).is_equal("Morning")
func test_debug_overlay_reads_game_time_tick_rate() -> void:
GameState.apply_snapshot({
"tick": 1, "entities": [],
"game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Half"},
})
assert_that(GameState.game_time.get("tick_rate")).is_equal("Half")
+82
View File
@@ -0,0 +1,82 @@
extends Control
## #263: Time display — diegetic time readout on the player's neural insert (D-013, D-031).
## Shows station local time (HH:MM), day phase, and day number.
## Lives on InsertOverlay (CanvasLayer 10) per D-051 diegetic insert principle.
## Draw-based for implant visual aesthetic. Updated via update_from_state() from main.gd.
##
## Placeholder layout — position and style will be refined when #314 wireframe lands.
const FONT_SIZE_TIME: int = 15
const FONT_SIZE_META: int = 10
const PADDING := Vector2(10, 7)
const BG_COLOR := Color(0.04, 0.05, 0.08, 0.70)
const BORDER_COLOR := Color(0.10, 0.20, 0.26, 0.65)
# Day phase colors — station lighting cycle (D-031)
const PHASE_COLORS := {
"Morning": Color("#aed6dc"), # pale cyan-blue — early light
"Afternoon": Color("#E0F7FA"), # bright cyan-white — full day
"Evening": Color("#9EBFC4"), # dimmed — dusk transition
"Night": Color("#4a7080"), # dark teal — station nightwatch
}
var _time_str: String = "--:--"
var _phase_str: String = ""
var _day_str: String = ""
var _phase_color: Color = Constants.IMPLANT_TEXT_DIM
var _last_signature: String = ""
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
var gt: Dictionary = GameState.game_time
if gt.is_empty():
return
var tod: int = int(gt.get("time_of_day", 0))
var day: int = int(gt.get("day", 0))
var phase: String = str(gt.get("day_phase", ""))
var sig: String = "%d:%d:%s" % [tod, day, phase]
if sig == _last_signature:
return
_last_signature = sig
_time_str = Constants.format_game_time(tod)
_phase_str = phase
_day_str = "D%d" % (day + 1)
_phase_color = PHASE_COLORS.get(phase, Constants.IMPLANT_TEXT_DIM)
queue_redraw()
func _draw() -> void:
if _time_str == "--:--":
return
var font := ThemeDB.fallback_font
# Measure
var time_size := font.get_string_size(_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME)
var phase_size := font.get_string_size(_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
var day_text := " " + _day_str
var day_size := font.get_string_size(day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META)
var meta_w := phase_size.x + day_size.x
var content_w := max(time_size.x, meta_w)
var box_w := content_w + PADDING.x * 2
var meta_h := font.get_string_size("A", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META).y
var box_h := PADDING.y * 2 + time_size.y + 3 + meta_h
# Background
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BORDER_COLOR, false, 1.0)
# HH:MM (primary, full brightness)
draw_string(font, Vector2(PADDING.x, PADDING.y + time_size.y),
_time_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_TIME, Constants.IMPLANT_TEXT_COLOR)
# Phase + day number (secondary, dimmed + phase-tinted)
var meta_y := PADDING.y + time_size.y + 3 + meta_h
draw_string(font, Vector2(PADDING.x, meta_y),
_phase_str, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, _phase_color)
draw_string(font, Vector2(PADDING.x + phase_size.x, meta_y),
day_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE_META, Constants.IMPLANT_TEXT_DIM)
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3 uid="uid://b4timedisplay1"]
[ext_resource type="Script" path="res://ui/time_display.gd" id="1_tdisplay"]
; #263: Time display — top-left placeholder per D-013/D-051.
; Position will be updated when #314 wireframe lands.
[node name="TimeDisplay" type="Control"]
anchors_preset = 0
anchor_left = 0.0
anchor_top = 0.0
anchor_right = 0.0
anchor_bottom = 0.0
offset_left = 16.0
offset_top = 16.0
offset_right = 170.0
offset_bottom = 60.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_tdisplay")