diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 0a866f02b..5362fca82 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -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")] diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 5fc354777..0afcb9106 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -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) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 454c5fc44..a681daabf 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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() diff --git a/client/tests/test_time_display_sprint17.gd b/client/tests/test_time_display_sprint17.gd new file mode 100644 index 000000000..5fd51ee05 --- /dev/null +++ b/client/tests/test_time_display_sprint17.gd @@ -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") diff --git a/client/ui/time_display.gd b/client/ui/time_display.gd new file mode 100644 index 000000000..bcf11a8ce --- /dev/null +++ b/client/ui/time_display.gd @@ -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) diff --git a/client/ui/time_display.tscn b/client/ui/time_display.tscn new file mode 100644 index 000000000..6134f8fc3 --- /dev/null +++ b/client/ui/time_display.tscn @@ -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")