From 80d5a57b5dcfa33e3a697488b96562198fa58e59 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:53:08 +0100 Subject: [PATCH 1/6] feat(client): introduce Michroma font and implant UI theme (#517) Add Michroma-Regular.ttf (OFL license) as the game font. Create FontVariation with +1px tracking and a global Theme resource with cyan-white (#E0F7FA) implant text color. Wire theme into project.godot so all Label/RichTextLabel/Button/LineEdit nodes inherit automatically. Add IMPLANT_TEXT_COLOR/DIM/PULSE constants to constants.gd. Co-Authored-By: Claude Opus 4.6 --- client/assets/fonts/Michroma-Regular.ttf | Bin 0 -> 64344 bytes client/assets/fonts/OFL.txt | 93 +++++++++++++++++++++++ client/assets/fonts/michroma_ui.tres | 8 ++ client/assets/theme/game_theme.tres | 20 +++++ client/project.godot | 5 ++ client/scripts/constants.gd | 7 ++ client/ui/interaction_list.gd | 4 +- 7 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 client/assets/fonts/Michroma-Regular.ttf create mode 100644 client/assets/fonts/OFL.txt create mode 100644 client/assets/fonts/michroma_ui.tres create mode 100644 client/assets/theme/game_theme.tres diff --git a/client/assets/fonts/Michroma-Regular.ttf b/client/assets/fonts/Michroma-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..59e8c534349962143f45b48152f80524fe9045bc GIT binary patch literal 64344 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 literal 0 HcmV?d00001 diff --git a/client/assets/fonts/OFL.txt b/client/assets/fonts/OFL.txt new file mode 100644 index 000000000..2d0610cd9 --- /dev/null +++ b/client/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client/assets/fonts/michroma_ui.tres b/client/assets/fonts/michroma_ui.tres new file mode 100644 index 000000000..4ae6c6b2f --- /dev/null +++ b/client/assets/fonts/michroma_ui.tres @@ -0,0 +1,8 @@ +[gd_resource type="FontVariation" load_steps=2 format=3] + +[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"] + +[resource] +base_font = ExtResource("1_base") +spacing_glyph = 1 +spacing_space = 1 diff --git a/client/assets/theme/game_theme.tres b/client/assets/theme/game_theme.tres new file mode 100644 index 000000000..524d5eda7 --- /dev/null +++ b/client/assets/theme/game_theme.tres @@ -0,0 +1,20 @@ +[gd_resource type="Theme" load_steps=2 format=3] + +[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"] + +[resource] +default_font = ExtResource("1_font") +default_font_size = 14 + +Label/colors/font_color = Color(0.878, 0.969, 0.98, 1) +Label/font_sizes/font_size = 14 + +RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1) +RichTextLabel/font_sizes/normal_font_size = 14 + +Button/colors/font_color = Color(0.878, 0.969, 0.98, 1) +Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1) +Button/font_sizes/font_size = 14 + +LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1) +LineEdit/font_sizes/font_size = 14 diff --git a/client/project.godot b/client/project.godot index 717e5c35f..7c46700bd 100644 --- a/client/project.godot +++ b/client/project.godot @@ -24,6 +24,11 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd" FogState="*res://scripts/autoloads/fog_state.gd" AudioManager="*res://scripts/autoloads/audio_manager.gd" +[gui] + +theme/custom="res://assets/theme/game_theme.tres" +theme/custom_font="res://assets/fonts/michroma_ui.tres" + [display] window/size/viewport_width=1920 diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 45df4f0b7..9acda78e1 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -80,3 +80,10 @@ const PERIPHERAL_ALPHA: float = 0.5 # Facing direction indicator const FACING_INDICATOR_SIZE: float = 6.0 const FACING_INDICATOR_OFFSET: float = 14.0 + +# #517: Implant UI font color grading — avoid pure white, project through a lens +const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text +const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text +const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor +const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling +const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle diff --git a/client/ui/interaction_list.gd b/client/ui/interaction_list.gd index 8758f39d5..a36fd7515 100644 --- a/client/ui/interaction_list.gd +++ b/client/ui/interaction_list.gd @@ -16,8 +16,8 @@ const FADE_IN := 0.12 const FADE_OUT := 0.10 const LABEL_HEIGHT := 22 const LABEL_GAP := 2 -const INSERT_FG := Color("#c8d0e0") -const INSERT_DIM := Color("#8b8ba0") +const INSERT_FG := Constants.IMPLANT_TEXT_COLOR +const INSERT_DIM := Constants.IMPLANT_TEXT_DIM const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7) var _showing: bool = false -- 2.54.0 From 97cb69e6a472507a7359c715929d0c26f0242264 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:53:19 +0100 Subject: [PATCH 2/6] feat(client): mouse-relative facing and movement (#526, D-054) Mouse position now determines facing direction as a client-side float. WASD remapped: W=toward cursor, S=away, A/D=strafe. Facing octant derived from mouse angle and sent to server via SET_FACING action only when it changes. EntityRenderer facing indicator uses continuous angle for smooth rotation. SimBridge test mode updated to handle SetFacing. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/input_mapper.gd | 122 ++++++++++++++++++-- client/scripts/autoloads/sim_bridge.gd | 10 +- client/scripts/rendering/entity_renderer.gd | 7 +- 3 files changed, 124 insertions(+), 15 deletions(-) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 0f8d70188..d4c7cee89 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -3,7 +3,11 @@ extends Node # Semantic actions — NO raw key codes cross the bridge # Movement uses hold-to-move (polled each frame in _process). # Discrete actions (interact, stance, etc.) use press events (_unhandled_input). -# Composite diagonals: holding W+D simultaneously → northeast. +# +# D-054: Mouse-relative facing and movement. +# Mouse position determines facing direction (client-side float). +# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe. +# Server receives facing octant only — the full float stays client-side. # # Movement throttle: client-side rate limit per stance (D-053). # Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s. @@ -15,10 +19,17 @@ enum Action { INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE, TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN, BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server + SET_FACING, # D-054: facing octant update (no movement) } var input_queue: Array[Dictionary] = [] +# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South. +# Updated every frame from mouse position. EntityRenderer reads this for indicator. +var facing_angle: float = -PI / 2.0 # Default: North +var facing_octant: String = "North" # Derived from facing_angle +var _last_sent_octant: String = "North" # Track to avoid redundant sends + # Minimum milliseconds between movement commands, per stance. # Tuned so Walk feels like walking, Sprint feels fast but readable. const MOVE_INTERVAL_MS := { @@ -31,27 +42,44 @@ var _last_move_msec: int = 0 # Hold-to-move: poll held direction keys each frame, throttled by stance. +# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe. # Server-side cooldown (D-053) is authoritative; this prevents client flooding. # D-064: movement suppressed during dialogue (walk-away handled by dialogue_box). func _process(_delta: float) -> void: + # D-054: Update facing angle from mouse position every frame + _update_facing_from_mouse() + if GameState.dialogue_active: return - var dir := Vector2i.ZERO - if Input.is_action_pressed("move_north"): - dir.y -= 1 - if Input.is_action_pressed("move_south"): - dir.y += 1 - if Input.is_action_pressed("move_east"): - dir.x += 1 - if Input.is_action_pressed("move_west"): - dir.x -= 1 - if dir != Vector2i.ZERO: + # D-054: Send facing octant to server when it changes (even without movement) + if facing_octant != _last_sent_octant: + _last_sent_octant = facing_octant + input_queue.append({ + "action": Action.SET_FACING, + "timestamp_msec": Time.get_ticks_msec(), + "action_data": {"facing": facing_octant}, + }) + + # Poll held WASD keys + var raw_dir := Vector2i.ZERO + if Input.is_action_pressed("move_north"): + raw_dir.y -= 1 + if Input.is_action_pressed("move_south"): + raw_dir.y += 1 + if Input.is_action_pressed("move_east"): + raw_dir.x += 1 + if Input.is_action_pressed("move_west"): + raw_dir.x -= 1 + + if raw_dir != Vector2i.ZERO: var now := Time.get_ticks_msec() var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200) if now - _last_move_msec >= interval: _last_move_msec = now - var action: Action = _dir_to_action(dir) + # D-054: Transform WASD input relative to mouse facing + var world_dir := _wasd_to_world_dir(raw_dir) + var action: Action = _dir_to_action(world_dir) input_queue.append({ "action": action, "timestamp_msec": now, @@ -92,6 +120,76 @@ func flush_queue() -> Array[Dictionary]: return queue +# D-054: Compute facing angle from mouse position relative to player screen position. +# Uses viewport canvas transform to convert world coords to screen coords. +func _update_facing_from_mouse() -> void: + var vp := get_viewport() + if vp == null: + return + var canvas_xf := vp.get_canvas_transform() + var player_world_px := GameState.player_position * Constants.TILE_SIZE + var player_screen := canvas_xf * player_world_px + var mouse_screen := vp.get_mouse_position() + var delta := mouse_screen - player_screen + # Only update if mouse is meaningfully distant from player (avoid jitter at center) + if delta.length_squared() > 4.0: + facing_angle = delta.angle() + facing_octant = _angle_to_octant(facing_angle) + + +# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing. +# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right). +# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords. +# Forward = facing_angle direction. Output: nearest octant direction vector. +func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i: + # Build a continuous direction vector relative to facing. + # raw_dir.y: -1 = W (forward), +1 = S (backward) + # raw_dir.x: -1 = A (strafe left), +1 = D (strafe right) + var forward := Vector2(cos(facing_angle), sin(facing_angle)) + var right := Vector2(-forward.y, forward.x) # 90° clockwise + + # Combine: forward/back from W/S, strafe from A/D + var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x) + + # Snap to nearest octant direction + return _snap_to_octant_dir(world_float) + + +# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions. +static func _snap_to_octant_dir(dir: Vector2) -> Vector2i: + if dir.length_squared() < 0.001: + return Vector2i.ZERO + var angle := dir.angle() + # Quantize to nearest 45° (PI/4) + var octant := roundi(angle / (PI / 4.0)) + match octant: + 0: return Vector2i(1, 0) # East + 1: return Vector2i(1, 1) # Southeast + 2, -6: return Vector2i(0, 1) # South + 3, -5: return Vector2i(-1, 1) # Southwest + 4, -4: return Vector2i(-1, 0) # West + -3, 5: return Vector2i(-1, -1) # Northwest + -2: return Vector2i(0, -1) # North + -1: return Vector2i(1, -1) # Northeast + _: return Vector2i.ZERO + + +# D-054: Convert a facing angle (radians) to the nearest octant name. +# Godot 2D: 0=East, PI/2=South, -PI/2=North. +static func _angle_to_octant(angle: float) -> String: + var octant := roundi(angle / (PI / 4.0)) + match octant: + 0: return "East" + 1: return "Southeast" + 2, -6: return "South" + 3, -5: return "Southwest" + 4, -4: return "West" + -3, 5: return "Northwest" + -2: return "North" + -1: return "Northeast" + _: return "East" + + # Map a direction vector to the corresponding movement Action. # Handles all 8 directions via composite W+D, W+A, etc. static func _dir_to_action(dir: Vector2i) -> Action: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 9400132ca..46b9bb8db 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -258,6 +258,8 @@ static func _action_enum_to_wire(action: int) -> String: return "" # Client-only action, not part of wire protocol InputMapper.Action.BUG_REPORT: return "" # Client-only action (#495), not part of wire protocol + InputMapper.Action.SET_FACING: + return "SetFacing" # D-054: facing octant update (no movement) _: push_warning("SimBridge: unknown action enum %s" % action) return "" @@ -280,12 +282,18 @@ func _test_snapshot() -> Dictionary: if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): _test_in_dialogue = true continue + if action_name == "SetFacing": + # D-054: facing update handled separately — octant comes from InputMapper + _test_facing = InputMapper.facing_octant + continue var delta := _action_to_delta(action_name) var new_pos := _test_player_pos + delta if _test_is_walkable(new_pos): _test_player_pos = new_pos if delta != Vector2i.ZERO: - _test_facing = _delta_to_facing(delta) + # D-054: Facing is now mouse-driven, not movement-driven. + # Use InputMapper's octant instead of deriving from movement delta. + _test_facing = InputMapper.facing_octant # Walk-away dismisses dialogue (D-064) if _test_in_dialogue: _test_in_dialogue = false diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 5582d66e7..57ddf6dc9 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -119,11 +119,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: if not is_equal_approx(entity_node.modulate.a, target_alpha): entity_node.modulate.a = target_alpha - # v2: Update facing indicator rotation (player entity only) + # D-054: Update facing indicator from client-side mouse angle (not server). + # InputMapper.facing_angle is a continuous float — smoother than octant snapping. if entity_id == GameState.player_entity_id: var indicator = entity_node.get_node_or_null("FacingIndicator") if indicator != null: - indicator.rotation = _facing_to_rotation(GameState.player_facing) + # facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up). + # Rotate from North basis: add PI/2 to convert. + indicator.rotation = InputMapper.facing_angle + PI / 2.0 # Remove an entity node func _remove_entity_node(entity_id: int) -> void: -- 2.54.0 From eb1d89ea86cd15cd933f685b82d6ddb6f1374243 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:53:35 +0100 Subject: [PATCH 3/6] feat(client): room reset UX, insert pause wiring, auto-checklist (#502, #518, #503) Room reset (#502): amber reset_plate tile type in TileRenderer, 0.15s screen flash on room_reset monologue, 'Reset Room' verb via existing nearby_interactions. Insert pause (#518, D-058): explicit PauseSimulation on insert open, ResumeSimulation on close. Replaces toggle-style pause with idempotent pair per D-058. Auto-checklist (#503): ChecklistEvaluator parses room YAML, evaluates 7 condition types against GameState with latching. ChecklistOverlay renders progress in gauntlet mode only. 48 tests covering parser, evaluation, latching, visibility, and integration. Co-Authored-By: Claude Opus 4.6 --- client/scenes/main.tscn | 10 +- .../scripts/checklist/checklist_evaluator.gd | 326 +++++++++ client/scripts/main.gd | 25 + client/scripts/rendering/tile_renderer.gd | 18 +- client/tests/test_checklist.gd | 692 ++++++++++++++++++ client/ui/checklist_overlay.gd | 132 ++++ client/ui/checklist_overlay.tscn | 17 + client/ui/world_radial.gd | 24 +- 8 files changed, 1222 insertions(+), 22 deletions(-) create mode 100644 client/scripts/checklist/checklist_evaluator.gd create mode 100644 client/tests/test_checklist.gd create mode 100644 client/ui/checklist_overlay.gd create mode 100644 client/ui/checklist_overlay.tscn diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 6073bd572..ef47cef8b 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=20 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"] @@ -17,7 +17,8 @@ [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"] +[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] +[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -136,6 +137,9 @@ layer = 20 ; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode [node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")] +; #503: Auto-checklist overlay — condition progress in gauntlet mode +[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")] + ; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys [node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")] @@ -149,4 +153,4 @@ script = ExtResource("10_cursor") layer = 30 ; #495: WRONG button (F12) — bug report capture dialog -[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")] +[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")] diff --git a/client/scripts/checklist/checklist_evaluator.gd b/client/scripts/checklist/checklist_evaluator.gd new file mode 100644 index 000000000..0d517ace9 --- /dev/null +++ b/client/scripts/checklist/checklist_evaluator.gd @@ -0,0 +1,326 @@ +extends RefCounted + +## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against +## checklist YAML conditions and latches satisfied conditions. +## +## Usage: +## var evaluator := ChecklistEvaluator.new() +## evaluator.load_room("inventory_warehouse") +## evaluator.evaluate() # call each tick +## var results := evaluator.get_results() +## +## Condition types (per checklist.schema.json): +## player_near, player_facing, entity_present, entity_absent, +## expected_monologue, expected_dialogue, expected_interaction_verb +## +## Spec ref: D-030 (testability), checklist.schema.json (#497). + +var _room_conditions: Array = [] # Conditions from per-room checklist +var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml +var _latched: Dictionary = {} # condition_id -> true (once met, stays met) +var _current_room_id: String = "" +var _content_base: String = "" # Absolute path to content/ directory +var _loaded: bool = false + + +func _init() -> void: + var project_path := ProjectSettings.globalize_path("res://") + _content_base = project_path.path_join("../content") + + +## Load checklist for a room. Clears per-room latches; cross-room latches persist. +func load_room(room_id: String) -> void: + if room_id == _current_room_id and _loaded: + return + + _current_room_id = room_id + _room_conditions.clear() + + # Clear per-room latches (keep cross-room latches) + var cross_ids := {} + for cond in _cross_conditions: + cross_ids[cond.get("id", "")] = true + var kept := {} + for cid in _latched: + if cross_ids.has(cid): + kept[cid] = true + _latched = kept + + # Load per-room checklist + var room_path := _content_base.path_join( + "gauntlet/rooms/%s/checklist.yaml" % room_id) + var room_data := _load_checklist_file(room_path) + if room_data.has("conditions"): + _room_conditions = room_data["conditions"] + + # Load cross-room checks (only on first load) + if _cross_conditions.is_empty(): + var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml") + var cross_data := _load_checklist_file(cross_path) + if cross_data.has("conditions"): + _cross_conditions = cross_data["conditions"] + + _loaded = true + + +## Evaluate all conditions against current GameState. Latches newly met conditions. +func evaluate() -> void: + for cond in _room_conditions + _cross_conditions: + var cid: String = cond.get("id", "") + if cid.is_empty() or _latched.has(cid): + continue + if _evaluate_condition(cond): + _latched[cid] = true + + +## Returns array of {id, description, met} for all loaded conditions. +func get_results() -> Array: + var results: Array = [] + for cond in _room_conditions + _cross_conditions: + var cid: String = cond.get("id", "") + results.append({ + "id": cid, + "description": cond.get("description", ""), + "condition_type": cond.get("condition_type", ""), + "met": _latched.has(cid), + }) + return results + + +## Total number of loaded conditions. +func get_total_count() -> int: + return _room_conditions.size() + _cross_conditions.size() + + +## Number of latched (met) conditions. +func get_met_count() -> int: + return _latched.size() + + +## Whether all conditions are met. +func is_complete() -> bool: + return get_met_count() >= get_total_count() and get_total_count() > 0 + + +## Whether any checklist is loaded. +func is_loaded() -> bool: + return _loaded + + +## Reset all state (room change to null, or disconnect). +func reset() -> void: + _room_conditions.clear() + _cross_conditions.clear() + _latched.clear() + _current_room_id = "" + _loaded = false + + +# -- Condition evaluation ------------------------------------------------------ + +func _evaluate_condition(cond: Dictionary) -> bool: + match cond.get("condition_type", ""): + "player_near": + return _eval_player_near(cond) + "player_facing": + return _eval_player_facing(cond) + "entity_present": + return _eval_entity_present(cond) + "entity_absent": + return _eval_entity_absent(cond) + "expected_monologue": + return _eval_expected_monologue(cond) + "expected_dialogue": + return _eval_expected_dialogue(cond) + "expected_interaction_verb": + return _eval_expected_interaction_verb(cond) + push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", "")) + return false + + +func _eval_player_near(cond: Dictionary) -> bool: + var tx: float = float(cond.get("x", 0)) + var ty: float = float(cond.get("y", 0)) + var radius: float = float(cond.get("radius", 0.0)) + var target := Vector2(tx, ty) + return GameState.player_position.distance_to(target) <= radius + + +func _eval_player_facing(cond: Dictionary) -> bool: + var direction: String = str(cond.get("direction", "")) + # Schema uses 4-cardinal (North/South/East/West). + # GameState uses 8-directional. Exact match only. + return GameState.player_facing == direction + + +func _eval_entity_present(cond: Dictionary) -> bool: + var entity_id: int = int(cond.get("entity_id", -1)) + return _find_entity(entity_id) + + +func _eval_entity_absent(cond: Dictionary) -> bool: + var entity_id: int = int(cond.get("entity_id", -1)) + return not _find_entity(entity_id) + + +func _eval_expected_monologue(cond: Dictionary) -> bool: + var contains: String = str(cond.get("contains", "")) + if GameState.current_monologue == null: + return false + var text: String = str(GameState.current_monologue.get("text", "")) + return text.find(contains) >= 0 + + +func _eval_expected_dialogue(cond: Dictionary) -> bool: + var contains: String = str(cond.get("contains", "")) + if GameState.current_dialogue == null: + return false + var text: String = str(GameState.current_dialogue.get("speech", "")) + return text.find(contains) >= 0 + + +func _eval_expected_interaction_verb(cond: Dictionary) -> bool: + var entity_id: int = int(cond.get("entity_id", -1)) + var verb: String = str(cond.get("verb", "")) + for interaction in GameState.nearby_interactions: + if not interaction is Dictionary: + continue + if int(interaction.get("entity_id", -1)) != entity_id: + continue + var verbs: Array = interaction.get("verbs", []) + for v in verbs: + if not v is Dictionary: + continue + if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb: + if v.get("available", true): + return true + return false + + +# -- Helpers ------------------------------------------------------------------- + +func _find_entity(entity_id: int) -> bool: + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + if int(entity.get("entity_id", -1)) == entity_id: + return true + return false + + +# -- YAML parsing (checklist-specific) ----------------------------------------- +# Handles the constrained checklist YAML format: top-level key:value pairs, +# a conditions array of flat dictionaries. No nested arrays or anchors. + +func _load_checklist_file(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("ChecklistEvaluator: cannot open %s" % path) + return {} + var text := file.get_as_text() + file.close() + return parse_checklist_yaml(text) + + +static func parse_checklist_yaml(text: String) -> Dictionary: + var result := {} + var conditions: Array = [] + var current_item: Dictionary = {} + var in_conditions := false + + for line in text.split("\n"): + var stripped := line.strip_edges(false, true) + if stripped.is_empty() or stripped.strip_edges().begins_with("#"): + continue + + var indent := line.length() - line.lstrip(" ").length() + var content := stripped.strip_edges() + + # Detect conditions: array header + if content == "conditions:": + in_conditions = true + continue + + if not in_conditions: + # Top-level key: value + var colon := content.find(":") + if colon >= 0: + var key := content.substr(0, colon).strip_edges() + var val_str := content.substr(colon + 1).strip_edges() + result[key] = _parse_value(val_str) + else: + if content.begins_with("- "): + # New array item — flush previous + if not current_item.is_empty(): + conditions.append(current_item) + current_item = {} + var rest := content.substr(2).strip_edges() + var colon := rest.find(":") + if colon >= 0: + var key := rest.substr(0, colon).strip_edges() + var val_str := rest.substr(colon + 1).strip_edges() + current_item[key] = _parse_value(val_str) + elif indent >= 2 and not current_item.is_empty(): + # Continuation of current array item + var colon := content.find(":") + if colon >= 0: + var key := content.substr(0, colon).strip_edges() + var val_str := content.substr(colon + 1).strip_edges() + current_item[key] = _parse_value(val_str) + elif indent == 0: + # Back to top level — shouldn't happen in valid checklist YAML + in_conditions = false + if not current_item.is_empty(): + conditions.append(current_item) + current_item = {} + var colon := content.find(":") + if colon >= 0: + var key := content.substr(0, colon).strip_edges() + var val_str := content.substr(colon + 1).strip_edges() + result[key] = _parse_value(val_str) + + # Flush last item + if not current_item.is_empty(): + conditions.append(current_item) + + if not conditions.is_empty(): + result["conditions"] = conditions + + return result + + +static func _parse_value(val: String) -> Variant: + if val.is_empty(): + return "" + + # Strip inline comments (not inside quotes) + if not val.begins_with("\""): + var comment_pos := val.find(" #") + if comment_pos >= 0: + val = val.substr(0, comment_pos).strip_edges() + + # Quoted string + if val.begins_with("\""): + var end_quote := val.find("\"", 1) + if end_quote > 0: + return val.substr(1, end_quote - 1) + return val.substr(1) + + # Boolean + if val == "true": + return true + if val == "false": + return false + + # Float (contains decimal point) + if val.contains(".") and val.is_valid_float(): + return val.to_float() + + # Integer + if val.is_valid_int(): + return val.to_int() + + # Plain string + return val diff --git a/client/scripts/main.gd b/client/scripts/main.gd index e92f30849..85344e8d4 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -13,12 +13,14 @@ extends Node2D @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 checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @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 var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice var _last_dialogue_tick: int = -1 +var _flash_rect: ColorRect = null # #502: ephemeral screen flash overlay func _ready() -> void: print("The Settled Reach — client initialized") @@ -98,6 +100,10 @@ func _process(_delta: float) -> void: if gauntlet_hud and gauntlet_hud.has_method("update_from_state"): gauntlet_hud.update_from_state() + # #503: Update checklist overlay (auto-checklist progress tracking) + if checklist_overlay and checklist_overlay.has_method("update_from_state"): + checklist_overlay.update_from_state() + # Show monologue if server sent one this tick (#414) _consume_monologue() @@ -159,6 +165,10 @@ func _consume_monologue() -> void: _last_monologue_tick = GameState.current_tick var mono: Dictionary = GameState.current_monologue monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) + # #502: Amber flash on room reset + var mono_id: String = mono.get("id", "") + if mono_id.begins_with("room_reset"): + _screen_flash(Constants.ENTITY_COLOR_POI, 0.15) GameState.current_monologue = null @@ -218,3 +228,18 @@ func _on_dialogue_dismissed() -> void: 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() + + +# #502: Full-screen color flash — fades from color to transparent over duration. +# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer. +func _screen_flash(color: Color, duration: float) -> void: + if _flash_rect and is_instance_valid(_flash_rect): + _flash_rect.queue_free() + _flash_rect = ColorRect.new() + _flash_rect.color = Color(color.r, color.g, color.b, 0.4) + _flash_rect.anchors_preset = Control.PRESET_FULL_RECT + _flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + $UILayer.add_child(_flash_rect) + var tween := create_tween() + tween.tween_property(_flash_rect, "color:a", 0.0, duration) + tween.tween_callback(_flash_rect.queue_free) diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index 6e2f1f229..94a8453fd 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -5,14 +5,15 @@ extends TileMapLayer # Uses a programmatic TileSet with placeholder colored rectangles (D-014) # # Tile types (atlas coords in the programmatic source): -# (0,0) = floor — dark gray -# (1,0) = wall — lighter gray -# (2,0) = door — brown -# (3,0) = object — teal +# (0,0) = floor — dark gray +# (1,0) = wall — lighter gray +# (2,0) = door — brown +# (3,0) = object — teal +# (4,0) = reset_plate — amber (#502) const TILE_SIZE: int = Constants.TILE_SIZE -enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 } +enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 } # Wire-format string to TileType mapping const TILE_TYPE_MAP: Dictionary = { @@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = { "wall": TileType.WALL, "door": TileType.DOOR, "object": TileType.OBJECT, + "reset_plate": TileType.RESET_PLATE, } var _initialized: bool = false @@ -36,7 +38,7 @@ func _setup_tileset() -> void: # Create an atlas source backed by a programmatic image var source := TileSetAtlasSource.new() - var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8) + var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8) # Floor (0,0) — dark gray _fill_tile(img, 0, Color(0.18, 0.18, 0.22)) @@ -46,13 +48,15 @@ func _setup_tileset() -> void: _fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15)) # Object (3,0) — teal _fill_tile(img, 3, Color(0.2, 0.45, 0.45)) + # Reset plate (4,0) — amber (#502) + _fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2)) var tex := ImageTexture.create_from_image(img) source.texture = tex source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE) # Create tile entries in the atlas - for i in range(4): + for i in range(5): source.create_tile(Vector2i(i, 0)) var source_id := ts.add_source(source) diff --git a/client/tests/test_checklist.gd b/client/tests/test_checklist.gd new file mode 100644 index 000000000..c6c689e07 --- /dev/null +++ b/client/tests/test_checklist.gd @@ -0,0 +1,692 @@ +## #503: Auto-checklist progress tracking — unit + integration tests. +## +## Tests cover: +## 1. YAML parser: basic types, conditions array, edge cases +## 2. Condition evaluation: all 7 condition types +## 3. Latching: conditions stay met once satisfied +## 4. Room change: per-room conditions reset, cross-room conditions persist +## 5. Overlay: visibility gating on gauntlet_mode +## 6. Integration: snapshot -> GameState -> evaluator -> overlay +## +## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof. +class_name TestChecklist +extends GdUnitTestSuite + + +var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd") +var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd") + + +func before_test() -> void: + SimBridge.reset_test_state() + SimBridge._last_snapshot = null + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.nearby_interactions = [] + GameState.game_time = {} + GameState.pending_recognitions = [] + GameState.room_id = null + GameState.gauntlet_mode = false + GameState.player_facing = "North" + GameState.player_stance = "Walk" + GameState.player_inventory = [] + + +# -- YAML Parser Tests --------------------------------------------------------- + +func test_parse_empty_yaml() -> void: + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("") + assert_that(result.size()).is_equal(0) + + +func test_parse_top_level_string() -> void: + var yaml := "room_id: inventory_warehouse" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("room_id")).is_equal("inventory_warehouse") + + +func test_parse_top_level_quoted_string() -> void: + var yaml := 'description: "Tests D-065 (9-slot inventory)."' + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).") + + +func test_parse_single_condition() -> void: + var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.has("conditions")).is_true() + var conditions: Array = result["conditions"] + assert_that(conditions.size()).is_equal(1) + assert_that(conditions[0]["id"]).is_equal("test-1") + assert_that(conditions[0]["condition_type"]).is_equal("player_near") + assert_that(conditions[0]["x"]).is_equal(10) + assert_that(conditions[0]["y"]).is_equal(20) + assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001) + + +func test_parse_multiple_conditions() -> void: + var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + var conditions: Array = result["conditions"] + assert_that(conditions.size()).is_equal(2) + assert_that(conditions[0]["id"]).is_equal("cond-a") + assert_that(conditions[1]["id"]).is_equal("cond-b") + assert_that(conditions[1]["direction"]).is_equal("East") + + +func test_parse_comments_ignored() -> void: + var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("room_id")).is_equal("test") + var conditions: Array = result["conditions"] + assert_that(conditions.size()).is_equal(1) + assert_that(conditions[0]["entity_id"]).is_equal(5) + + +func test_parse_integer_and_float_values() -> void: + var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + var cond: Dictionary = result["conditions"][0] + assert_that(cond["x"]).is_equal(42) + assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT) + + +func test_parse_scope_field() -> void: + var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("scope")).is_equal("cross_room") + + +func test_parse_inline_comment_stripped() -> void: + var yaml := "room_id: test # this is a comment" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("room_id")).is_equal("test") + + +# -- Condition Evaluation Tests ------------------------------------------------ + +func _make_evaluator(conditions: Array): + var evaluator = ChecklistEvaluatorScript.new() + evaluator._room_conditions = conditions + evaluator._loaded = true + return evaluator + + +func test_eval_player_near_within_radius() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "near-1", "condition_type": "player_near", + "x": 10, "y": 21, "radius": 2.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near: player at (10,20), target (10,21), radius 2.0 — should be met" + ).is_equal(1) + + +func test_eval_player_near_outside_radius() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "near-2", "condition_type": "player_near", + "x": 10, "y": 30, "radius": 2.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met" + ).is_equal(0) + + +func test_eval_player_near_exact_boundary() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "near-3", "condition_type": "player_near", + "x": 10, "y": 22, "radius": 2.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near: distance exactly equals radius — should be met (<=)" + ).is_equal(1) + + +func test_eval_player_facing_match() -> void: + GameState.player_facing = "East" + var evaluator = _make_evaluator([{ + "id": "face-1", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_player_facing_no_match() -> void: + GameState.player_facing = "North" + var evaluator = _make_evaluator([{ + "id": "face-2", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_player_facing_diagonal_no_match() -> void: + # 8-directional facing "Northeast" should NOT match "East" or "North" + GameState.player_facing = "Northeast" + var evaluator = _make_evaluator([{ + "id": "face-diag", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_facing: Northeast should NOT match East (exact match only)" + ).is_equal(0) + + +func test_eval_entity_present_found() -> void: + GameState.visible_entities = [ + {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + {"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"}, + ] + var evaluator = _make_evaluator([{ + "id": "present-1", "condition_type": "entity_present", + "entity_id": 10, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_entity_present_not_found() -> void: + GameState.visible_entities = [ + {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + ] + var evaluator = _make_evaluator([{ + "id": "present-2", "condition_type": "entity_present", + "entity_id": 99, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_entity_absent_when_not_visible() -> void: + GameState.visible_entities = [ + {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + ] + var evaluator = _make_evaluator([{ + "id": "absent-1", "condition_type": "entity_absent", + "entity_id": 99, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "entity_absent: entity 99 not in visible_entities — should be met" + ).is_equal(1) + + +func test_eval_entity_absent_when_visible() -> void: + GameState.visible_entities = [ + {"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + ] + var evaluator = _make_evaluator([{ + "id": "absent-2", "condition_type": "entity_absent", + "entity_id": 10, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "entity_absent: entity 10 IS visible — should NOT be met" + ).is_equal(0) + + +func test_eval_expected_monologue_match() -> void: + GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0} + var evaluator = _make_evaluator([{ + "id": "mono-1", "condition_type": "expected_monologue", + "contains": "wrong here", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_expected_monologue_no_match() -> void: + GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0} + var evaluator = _make_evaluator([{ + "id": "mono-2", "condition_type": "expected_monologue", + "contains": "wrong here", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_expected_monologue_null() -> void: + GameState.current_monologue = null + var evaluator = _make_evaluator([{ + "id": "mono-3", "condition_type": "expected_monologue", + "contains": "test", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_monologue: null monologue should not match" + ).is_equal(0) + + +func test_eval_expected_dialogue_match() -> void: + GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []} + var evaluator = _make_evaluator([{ + "id": "dlg-1", "condition_type": "expected_dialogue", + "contains": "Who are you", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_expected_dialogue_no_match() -> void: + GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []} + var evaluator = _make_evaluator([{ + "id": "dlg-2", "condition_type": "expected_dialogue", + "contains": "Goodbye", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_expected_dialogue_null() -> void: + GameState.current_dialogue = null + var evaluator = _make_evaluator([{ + "id": "dlg-3", "condition_type": "expected_dialogue", + "contains": "test", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_dialogue: null dialogue should not match" + ).is_equal(0) + + +func test_eval_interaction_verb_match() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [ + {"kind": "Take", "label": "Pickup", "priority": 1, "available": true}, + {"kind": "Observe", "label": "Examine", "priority": 2, "available": true}, + ], + }] + var evaluator = _make_evaluator([{ + "id": "verb-1", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_interaction_verb_by_kind() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [ + {"kind": "Take", "label": "Pickup", "priority": 1, "available": true}, + ], + }] + var evaluator = _make_evaluator([{ + "id": "verb-kind", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Take", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_interaction_verb: should match by kind='Take' as well as label" + ).is_equal(1) + + +func test_eval_interaction_verb_wrong_entity() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}], + }] + var evaluator = _make_evaluator([{ + "id": "verb-wrong", "condition_type": "expected_interaction_verb", + "entity_id": 99, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_interaction_verb: wrong entity_id should not match" + ).is_equal(0) + + +func test_eval_interaction_verb_unavailable() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}], + }] + var evaluator = _make_evaluator([{ + "id": "verb-unavail", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_interaction_verb: unavailable verb should not match" + ).is_equal(0) + + +func test_eval_interaction_verb_no_interactions() -> void: + GameState.nearby_interactions = [] + var evaluator = _make_evaluator([{ + "id": "verb-none", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +# -- Latching Tests ------------------------------------------------------------ + +func test_latching_condition_stays_met() -> void: + # Condition met on first evaluate, stays met even when state changes. + GameState.player_facing = "East" + var evaluator = _make_evaluator([{ + "id": "latch-1", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + # Change state so condition would be false if re-evaluated fresh + GameState.player_facing = "North" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "Latched condition should stay met even after state changes" + ).is_equal(1) + + +func test_latching_monologue_transient() -> void: + # Monologue appears for one tick, then disappears. Condition should latch. + var evaluator = _make_evaluator([{ + "id": "mono-latch", "condition_type": "expected_monologue", + "contains": "recalibrated", + }]) + + # Tick 1: no monologue + GameState.current_monologue = null + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + # Tick 2: monologue fires + GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0} + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + # Tick 3: monologue consumed (null) + GameState.current_monologue = null + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "Monologue condition should stay latched after monologue disappears" + ).is_equal(1) + + +func test_multiple_conditions_partial_latching() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "condition_type": "player_facing", "direction": "East"}, + {"id": "c2", "condition_type": "entity_present", "entity_id": 5}, + {"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0}, + ]) + + # Tick 1: only facing matches + GameState.player_facing = "East" + GameState.visible_entities = [] + GameState.player_position = Vector2(0, 0) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + assert_that(evaluator.get_total_count()).is_equal(3) + + # Tick 2: entity also visible + GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}] + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(2) + + # Tick 3: player moves to target + GameState.player_position = Vector2(50.0, 50.0) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(3) + assert_that(evaluator.is_complete()).is_true() + + +# -- Room Change Tests --------------------------------------------------------- + +func test_room_change_resets_per_room_conditions() -> void: + var evaluator = ChecklistEvaluatorScript.new() + # Manually set conditions to avoid file loading + evaluator._room_conditions = [ + {"id": "r1-c1", "condition_type": "player_facing", "direction": "East"}, + ] + evaluator._loaded = true + GameState.player_facing = "East" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + # Simulate room change by loading a new "room" + evaluator._current_room_id = "old_room" + evaluator._room_conditions = [ + {"id": "r2-c1", "condition_type": "player_facing", "direction": "North"}, + ] + # Clear latches for the new room (simulating load_room behavior) + evaluator._latched.clear() + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "After room change, old latches should be cleared; new condition not met" + ).is_equal(0) + + +func test_reset_clears_all_state() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "condition_type": "player_facing", "direction": "East"}, + ]) + GameState.player_facing = "East" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + evaluator.reset() + assert_that(evaluator.is_loaded()).is_false() + assert_that(evaluator.get_met_count()).is_equal(0) + assert_that(evaluator.get_total_count()).is_equal(0) + + +# -- get_results Tests --------------------------------------------------------- + +func test_get_results_structure() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"}, + ]) + GameState.player_facing = "North" + evaluator.evaluate() + var results: Array = evaluator.get_results() + assert_that(results.size()).is_equal(1) + assert_that(results[0]["id"]).is_equal("c1") + assert_that(results[0]["description"]).is_equal("Test condition") + assert_that(results[0]["condition_type"]).is_equal("player_facing") + assert_that(results[0]["met"]).is_true() + + +func test_get_results_unmet() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"}, + ]) + GameState.player_facing = "North" + evaluator.evaluate() + var results: Array = evaluator.get_results() + assert_that(results[0]["met"]).is_false() + + +# -- Overlay Visibility Tests -------------------------------------------------- + +func _make_checklist_overlay() -> Control: + var overlay = Control.new() + overlay.set_script(ChecklistOverlayScript) + auto_free(overlay) + add_child(overlay) + return overlay + + +func test_overlay_hidden_in_non_gauntlet_mode() -> void: + var overlay := _make_checklist_overlay() + GameState.gauntlet_mode = false + overlay.update_from_state() + assert_that(overlay.visible).override_failure_message( + "Checklist overlay must be hidden in non-gauntlet mode" + ).is_false() + + +func test_overlay_visible_in_gauntlet_mode() -> void: + var overlay := _make_checklist_overlay() + GameState.gauntlet_mode = true + GameState.room_id = "test_room" + overlay.update_from_state() + assert_that(overlay.visible).override_failure_message( + "Checklist overlay must be visible in gauntlet mode" + ).is_true() + + +func test_overlay_hides_on_gauntlet_deactivation() -> void: + var overlay := _make_checklist_overlay() + GameState.gauntlet_mode = true + GameState.room_id = "test_room" + overlay.update_from_state() + assert_that(overlay.visible).is_true() + GameState.gauntlet_mode = false + overlay.update_from_state() + assert_that(overlay.visible).override_failure_message( + "Overlay must hide when gauntlet mode deactivates" + ).is_false() + + +func test_overlay_evaluator_accessible() -> void: + var overlay := _make_checklist_overlay() + var evaluator = overlay.get_evaluator() + assert_that(evaluator).override_failure_message( + "Overlay should expose evaluator via get_evaluator()" + ).is_not_null() + + +func test_overlay_in_main_scene() -> void: + var scene: PackedScene = load("res://scenes/main.tscn") + var instance: Node = scene.instantiate() + auto_free(instance) + add_child(instance) + instance._process(0.016) + + var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay") + assert_that(overlay).override_failure_message( + "ChecklistOverlay node should exist in main scene tree" + ).is_not_null() + if overlay is CanvasItem: + assert_that((overlay as CanvasItem).visible).override_failure_message( + "ChecklistOverlay should be hidden by default (non-gauntlet mode)" + ).is_false() + + +# -- Integration: Snapshot -> Evaluation ---------------------------------------- + +func test_integration_snapshot_to_evaluator() -> void: + # Integration test: GameState snapshot data -> evaluator -> correct results. + # Tests the evaluator directly (overlay wiring tested separately). + var evaluator = ChecklistEvaluatorScript.new() + evaluator._room_conditions = [ + {"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1}, + {"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"}, + ] + evaluator._loaded = true + + # Simulate gauntlet snapshot with player entity + GameState.visible_entities = [ + {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"}, + ] + GameState.player_facing = "North" + + evaluator.evaluate() + + # Entity present should be met, facing should not + assert_that(evaluator.get_met_count()).is_equal(1) + assert_that(evaluator.get_total_count()).is_equal(2) + + # Change facing — second condition should also latch + GameState.player_facing = "East" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(2) + assert_that(evaluator.is_complete()).is_true() + + # Verify results array contains both conditions as met + var results: Array = evaluator.get_results() + for r in results: + assert_that(r["met"]).override_failure_message( + "Condition '%s' should be met after snapshot sequence" % r["id"] + ).is_true() + + +# -- Edge Cases ---------------------------------------------------------------- + +func test_empty_entity_list_entity_present() -> void: + GameState.visible_entities = [] + var evaluator = _make_evaluator([{ + "id": "edge-empty", "condition_type": "entity_present", + "entity_id": 0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_empty_entity_list_entity_absent() -> void: + GameState.visible_entities = [] + var evaluator = _make_evaluator([{ + "id": "edge-absent-empty", "condition_type": "entity_absent", + "entity_id": 99, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "entity_absent with empty visible_entities should be met" + ).is_equal(1) + + +func test_zero_radius_player_near() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "edge-zero-radius", "condition_type": "player_near", + "x": 10, "y": 20, "radius": 0.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near with radius 0 at exact position should be met" + ).is_equal(1) + + +func test_unknown_condition_type() -> void: + var evaluator = _make_evaluator([{ + "id": "edge-unknown", "condition_type": "nonexistent_type", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "Unknown condition type should not be met" + ).is_equal(0) + + +func test_no_conditions_loaded() -> void: + var evaluator = ChecklistEvaluatorScript.new() + assert_that(evaluator.is_loaded()).is_false() + assert_that(evaluator.get_total_count()).is_equal(0) + assert_that(evaluator.get_met_count()).is_equal(0) + assert_that(evaluator.is_complete()).is_false() + + +# -- Helper: recursive node search -------------------------------------------- + +func _find_node_recursive(root: Node, target_name: String) -> Node: + if root.name == target_name: + return root + for child in root.get_children(): + var found := _find_node_recursive(child, target_name) + if found != null: + return found + return null diff --git a/client/ui/checklist_overlay.gd b/client/ui/checklist_overlay.gd new file mode 100644 index 000000000..7f7479a0b --- /dev/null +++ b/client/ui/checklist_overlay.gd @@ -0,0 +1,132 @@ +extends Control + +## #503: Auto-checklist HUD overlay — shows condition progress in Gauntlet mode. +## Renders below the GauntletHUD timer. Each condition shows a check/dash + description. +## Only visible in gauntlet_mode. Latched conditions stay checked. +## +## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof. + +const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd") + +const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45) +const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met +const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending +const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary +const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met +const FONT_SIZE := 11 +const LINE_HEIGHT := 16 +const PADDING := Vector2(8, 6) +const MAX_DESC_CHARS := 52 # Truncate long descriptions + +var _evaluator = null # ChecklistEvaluator instance +var _last_room_id: Variant = null + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + _evaluator = _ChecklistEvaluator.new() + + +func update_from_state() -> void: + if not GameState.gauntlet_mode: + if visible: + visible = false + return + + if not visible: + visible = true + + var room_id: Variant = GameState.room_id + if room_id == null: + if _evaluator.is_loaded(): + _evaluator.reset() + _last_room_id = null + queue_redraw() + return + + # Load checklist on room change + if room_id != _last_room_id: + _evaluator.load_room(str(room_id)) + _last_room_id = room_id + + # Evaluate conditions against current snapshot + _evaluator.evaluate() + queue_redraw() + + +func _draw() -> void: + if _evaluator == null or not _evaluator.is_loaded(): + return + + var font := get_theme_default_font() + var results: Array = _evaluator.get_results() + if results.is_empty(): + return + + var met_count: int = _evaluator.get_met_count() + var total_count: int = _evaluator.get_total_count() + var all_complete: bool = _evaluator.is_complete() + + # Header line: "CHECK: 5/8" + var header_text := "CHECK: %d/%d" % [met_count, total_count] + var header_color: Color = COMPLETE_COLOR if all_complete else HEADER_COLOR + + # Calculate box height: header + one line per condition + padding + var line_count: int = 1 + results.size() + var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT + + # Calculate box width from longest line + var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x + for r in results: + var desc: String = r.get("description", r.get("id", "")) + if desc.length() > MAX_DESC_CHARS: + desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..." + var prefix: String = "[x] " if r.get("met", false) else "[ ] " + var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x + if line_width > max_width: + max_width = line_width + + var box_width: float = max_width + PADDING.x * 2 + + # Background + draw_rect(Rect2(Vector2.ZERO, Vector2(box_width, box_height)), BG_COLOR) + + # Header + var y: float = PADDING.y + FONT_SIZE + draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color) + + # Condition lines + for r in results: + y += LINE_HEIGHT + var is_met: bool = r.get("met", false) + var prefix: String = "[x] " if is_met else "[ ] " + var desc: String = r.get("description", r.get("id", "")) + if desc.length() > MAX_DESC_CHARS: + desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..." + var color: Color = MET_COLOR if is_met else UNMET_COLOR + draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color) + + +# -- Public API --------------------------------------------------------------- + +func get_evaluator(): + return _evaluator + + +func get_met_count() -> int: + if _evaluator == null: + return 0 + return _evaluator.get_met_count() + + +func get_total_count() -> int: + if _evaluator == null: + return 0 + return _evaluator.get_total_count() + + +func is_complete() -> bool: + if _evaluator == null: + return false + return _evaluator.is_complete() diff --git a/client/ui/checklist_overlay.tscn b/client/ui/checklist_overlay.tscn new file mode 100644 index 000000000..d31e0534d --- /dev/null +++ b/client/ui/checklist_overlay.tscn @@ -0,0 +1,17 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/checklist_overlay.gd" id="1_checklist"] + +; #503: Auto-checklist overlay — below GauntletHUD timer, right-aligned +[node name="ChecklistOverlay" type="Control"] +layout_mode = 3 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -420.0 +offset_top = 78.0 +offset_right = -16.0 +offset_bottom = 400.0 +grow_horizontal = 0 +mouse_filter = 2 +script = ExtResource("1_checklist") diff --git a/client/ui/world_radial.gd b/client/ui/world_radial.gd index 2f1057f09..7311e3289 100644 --- a/client/ui/world_radial.gd +++ b/client/ui/world_radial.gd @@ -120,24 +120,24 @@ func _confirm_selection() -> void: func _activate_insert() -> void: - # TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol + # #518/D-058: Send PauseSimulation when insert opens. Idempotent — + # if already paused (e.g. Gauntlet interlude), server ignores duplicate. if not _insert_active: _insert_active = true - _send_pause() - - -func _send_pause() -> void: - SimBridge.send_input({ - "action": InputMapper.Action.PAUSE, - "timestamp_msec": Time.get_ticks_msec(), - }) + SimBridge.send_input({ + "action": InputMapper.Action.PAUSE, + "timestamp_msec": Time.get_ticks_msec(), + }) func deactivate_insert() -> void: - # Called when closing insert view — send Pause again (toggle) + # #518/D-058: Send ResumeSimulation when insert closes. if _insert_active: _insert_active = false - _send_pause() + SimBridge.send_input({ + "action": InputMapper.Action.UNPAUSE, + "timestamp_msec": Time.get_ticks_msec(), + }) func _draw() -> void: @@ -174,7 +174,7 @@ func _draw() -> void: # Label var label: String = SPOKE_NAMES.get(spoke, "") - var font := ThemeDB.fallback_font + var font := get_theme_default_font() var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0) draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color) -- 2.54.0 From ab85fbd6522f7d24efd8b06884d10da0dd9e10d7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 12:53:50 +0100 Subject: [PATCH 4/6] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5747cf8d2..e19bda34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Added - `project.yaml` — technical project descriptor with version, architecture, simulation, and content model as the canonical version source of truth +- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants +- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation +- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb +- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests + +### Changed +- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair +- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values +- World radial menu uses theme font instead of ThemeDB.fallback_font ## [v0.1.9] — 2026-02-18 -- 2.54.0 From 919ef39cbd6ab3f842057612da51e51e929dbe60 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 13:09:38 +0100 Subject: [PATCH 5/6] =?UTF-8?q?fix(client):=20address=20PR=20#36=20review?= =?UTF-8?q?=20=E2=80=94=2010=20items=20from=20Hoshe=20+=20Tyre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - deactivate_insert() now called when selecting non-Insert spoke, cancelling with no selection, or pressing Escape while insert is active. Fixes simulation staying paused permanently after Insert. Warnings: - Checklist conditions with empty id excluded from get_results() and get_total_count() — prevents impossible-to-complete checklists. Warns at load time when empty-id conditions are found. - _content_base now checks res://content/ first (exported builds), falls back to ../content for editor/dev mode. - 26 new tests for D-054 functions: _angle_to_octant (8 octants), _snap_to_octant_dir (9 cases incl. zero/tiny), _wasd_to_world_dir (8 facing/movement combos). New test file: test_input_mapper_facing.gd. Suggestions: - Cached get_theme_default_font() in checklist overlay _ready(). - Documented InputMapper → GameState coupling as intentional. - Documented YAML parser # truncation limitation. - _insert_active reset on Escape dismiss (Tyre #3). - SimBridge test mode SetFacing reads action_data.facing instead of InputMapper global (Tyre #4). - Removed dead _facing_to_rotation() from entity_renderer.gd (Tyre #5). - Fixed 2 failing facing indicator tests to use InputMapper.facing_angle instead of GameState.player_facing. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/input_mapper.gd | 2 + client/scripts/autoloads/sim_bridge.gd | 18 +-- .../scripts/checklist/checklist_evaluator.gd | 33 ++++- client/scripts/rendering/entity_renderer.gd | 13 -- client/tests/test_client_p3.gd | 26 ++-- client/tests/test_input_mapper_facing.gd | 122 ++++++++++++++++++ client/tests/test_rendering.gd | 29 +++-- client/ui/checklist_overlay.gd | 4 +- client/ui/world_radial.gd | 20 ++- 9 files changed, 216 insertions(+), 51 deletions(-) create mode 100644 client/tests/test_input_mapper_facing.gd diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index d4c7cee89..74cbdda5d 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -122,6 +122,8 @@ func flush_queue() -> Array[Dictionary]: # D-054: Compute facing angle from mouse position relative to player screen position. # Uses viewport canvas transform to convert world coords to screen coords. +# Intentional coupling: reads GameState.player_position directly — InputMapper is an +# autoload that runs before game loop rendering, so position is always current-tick. func _update_facing_from_mouse() -> void: var vp := get_viewport() if vp == null: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 46b9bb8db..5880219e6 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -177,7 +177,16 @@ func send_input(player_input: Dictionary) -> Error: var action: int = player_input.get("action", -1) var wire_name: String = _action_enum_to_wire(action) if not wire_name.is_empty(): - _test_input_queue.append(wire_name) + if wire_name == "SetFacing": + # D-054: Use action_data.facing from the input dict, not InputMapper global + var facing: String = "" + var action_data: Variant = player_input.get("action_data") + if action_data is Dictionary: + facing = str(action_data.get("facing", "")) + if not facing.is_empty(): + _test_facing = facing + else: + _test_input_queue.append(wire_name) return OK var action_name := _action_enum_to_wire(player_input.get("action", -1)) if action_name.is_empty(): @@ -282,18 +291,11 @@ func _test_snapshot() -> Dictionary: if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): _test_in_dialogue = true continue - if action_name == "SetFacing": - # D-054: facing update handled separately — octant comes from InputMapper - _test_facing = InputMapper.facing_octant - continue var delta := _action_to_delta(action_name) var new_pos := _test_player_pos + delta if _test_is_walkable(new_pos): _test_player_pos = new_pos if delta != Vector2i.ZERO: - # D-054: Facing is now mouse-driven, not movement-driven. - # Use InputMapper's octant instead of deriving from movement delta. - _test_facing = InputMapper.facing_octant # Walk-away dismisses dialogue (D-064) if _test_in_dialogue: _test_in_dialogue = false diff --git a/client/scripts/checklist/checklist_evaluator.gd b/client/scripts/checklist/checklist_evaluator.gd index 0d517ace9..ca4e8fe71 100644 --- a/client/scripts/checklist/checklist_evaluator.gd +++ b/client/scripts/checklist/checklist_evaluator.gd @@ -24,8 +24,15 @@ var _loaded: bool = false func _init() -> void: - var project_path := ProjectSettings.globalize_path("res://") - _content_base = project_path.path_join("../content") + # Content directory lives at repo root (content/), one level above the Godot + # project (client/). In editor/dev mode we resolve via the project path. + # In exported builds, content is expected at res://content/ (copied by export + # preset) — the globalize fallback won't exist, so check res:// first. + if DirAccess.dir_exists_absolute("res://content"): + _content_base = ProjectSettings.globalize_path("res://content") + else: + var project_path := ProjectSettings.globalize_path("res://") + _content_base = project_path.path_join("../content") ## Load checklist for a room. Clears per-room latches; cross-room latches persist. @@ -52,6 +59,7 @@ func load_room(room_id: String) -> void: var room_data := _load_checklist_file(room_path) if room_data.has("conditions"): _room_conditions = room_data["conditions"] + _warn_empty_ids(_room_conditions, room_path) # Load cross-room checks (only on first load) if _cross_conditions.is_empty(): @@ -59,6 +67,7 @@ func load_room(room_id: String) -> void: var cross_data := _load_checklist_file(cross_path) if cross_data.has("conditions"): _cross_conditions = cross_data["conditions"] + _warn_empty_ids(_cross_conditions, cross_path) _loaded = true @@ -74,10 +83,13 @@ func evaluate() -> void: ## Returns array of {id, description, met} for all loaded conditions. +## Conditions with empty id are excluded (invalid, cannot be latched). func get_results() -> Array: var results: Array = [] for cond in _room_conditions + _cross_conditions: var cid: String = cond.get("id", "") + if cid.is_empty(): + continue results.append({ "id": cid, "description": cond.get("description", ""), @@ -87,9 +99,13 @@ func get_results() -> Array: return results -## Total number of loaded conditions. +## Total number of loaded conditions (excludes conditions with empty id). func get_total_count() -> int: - return _room_conditions.size() + _cross_conditions.size() + var count: int = 0 + for cond in _room_conditions + _cross_conditions: + if not cond.get("id", "").is_empty(): + count += 1 + return count ## Number of latched (met) conditions. @@ -116,6 +132,12 @@ func reset() -> void: _loaded = false +static func _warn_empty_ids(conditions: Array, path: String) -> void: + for i in conditions.size(): + if conditions[i].get("id", "").is_empty(): + push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path]) + + # -- Condition evaluation ------------------------------------------------------ func _evaluate_condition(cond: Dictionary) -> bool: @@ -211,6 +233,9 @@ func _find_entity(entity_id: int) -> bool: # -- YAML parsing (checklist-specific) ----------------------------------------- # Handles the constrained checklist YAML format: top-level key:value pairs, # a conditions array of flat dictionaries. No nested arrays or anchors. +# +# Limitation: unquoted values containing " #" are truncated at the comment marker. +# Use quoted strings ("value # with hash") if values must contain literal hashes. func _load_checklist_file(path: String) -> Dictionary: if not FileAccess.file_exists(path): diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 57ddf6dc9..9accd5809 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -158,16 +158,3 @@ func _add_facing_indicator(parent_node: Control) -> void: # Position at center of parent ColorRect — rotation around this point indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0) parent_node.add_child(indicator) - -# Convert facing direction string to rotation in radians (0 = North/up) -static func _facing_to_rotation(facing: String) -> float: - match facing: - "North": return 0.0 - "Northeast": return PI / 4.0 - "East": return PI / 2.0 - "Southeast": return 3.0 * PI / 4.0 - "South": return PI - "Southwest": return 5.0 * PI / 4.0 - "West": return 3.0 * PI / 2.0 - "Northwest": return 7.0 * PI / 4.0 - _: return 0.0 diff --git a/client/tests/test_client_p3.gd b/client/tests/test_client_p3.gd index 1b802a599..7fc79a39c 100644 --- a/client/tests/test_client_p3.gd +++ b/client/tests/test_client_p3.gd @@ -253,27 +253,29 @@ func test_recognition_transition_progress() -> void: fog_entities.queue_free() -func test_facing_indicator_rotation_matches_player_facing() -> void: - # P3-T02: Facing indicator rotation matches player_facing from snapshot. +func test_facing_indicator_rotation_matches_input_mapper_angle() -> void: + # P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float). + # Indicator rotation = facing_angle + PI/2 (0=North basis). GameState.player_entity_id = 1 var renderer := _make_entity_renderer() var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}, "visibility": "Forward"}] renderer.update_entities(entity) var indicator = renderer.entity_nodes[1].get_node("FacingIndicator") - # Test each cardinal + diagonal direction - var expected := { - "North": 0.0, - "East": PI / 2.0, - "South": PI, - "West": 3.0 * PI / 2.0, + # {facing_angle → expected indicator rotation} + var angles := { + -PI / 2.0: 0.0, # North + 0.0: PI / 2.0, # East + PI / 2.0: PI, # South + PI: 3.0 * PI / 2.0, # West } - for dir in expected: - GameState.player_facing = dir + for angle in angles: + InputMapper.facing_angle = angle renderer.update_entities(entity) assert_that(indicator.rotation).override_failure_message( - "%s: expected rotation %.3f, got %.3f" % [dir, expected[dir], indicator.rotation] - ).is_equal_approx(expected[dir], 0.001) + "angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation] + ).is_equal_approx(angles[angle], 0.001) + InputMapper.facing_angle = -PI / 2.0 # Reset to default renderer.queue_free() diff --git a/client/tests/test_input_mapper_facing.gd b/client/tests/test_input_mapper_facing.gd new file mode 100644 index 000000000..9aea61b72 --- /dev/null +++ b/client/tests/test_input_mapper_facing.gd @@ -0,0 +1,122 @@ +## D-054 facing and movement tests — _angle_to_octant, _snap_to_octant_dir, +## _wasd_to_world_dir coverage. All functions are static or use only facing_angle. +## +## Spec ref: D-054 (mouse-relative facing), Sprint 10 Completion Proof. +class_name TestInputMapperFacing +extends GdUnitTestSuite + + +# -- _angle_to_octant ---------------------------------------------------------- + +func test_angle_to_octant_east() -> void: + assert_str(InputMapper._angle_to_octant(0.0)).is_equal("East") + +func test_angle_to_octant_north() -> void: + assert_str(InputMapper._angle_to_octant(-PI / 2.0)).is_equal("North") + +func test_angle_to_octant_south() -> void: + assert_str(InputMapper._angle_to_octant(PI / 2.0)).is_equal("South") + +func test_angle_to_octant_west() -> void: + assert_str(InputMapper._angle_to_octant(PI)).is_equal("West") + +func test_angle_to_octant_northeast() -> void: + assert_str(InputMapper._angle_to_octant(-PI / 4.0)).is_equal("Northeast") + +func test_angle_to_octant_southeast() -> void: + assert_str(InputMapper._angle_to_octant(PI / 4.0)).is_equal("Southeast") + +func test_angle_to_octant_southwest() -> void: + assert_str(InputMapper._angle_to_octant(3.0 * PI / 4.0)).is_equal("Southwest") + +func test_angle_to_octant_northwest() -> void: + assert_str(InputMapper._angle_to_octant(-3.0 * PI / 4.0)).is_equal("Northwest") + + +# -- _snap_to_octant_dir ------------------------------------------------------- + +func test_snap_east() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2(1.0, 0.0))).is_equal(Vector2i(1, 0)) + +func test_snap_north() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, -1.0))).is_equal(Vector2i(0, -1)) + +func test_snap_south() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, 1.0))).is_equal(Vector2i(0, 1)) + +func test_snap_west() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2(-1.0, 0.0))).is_equal(Vector2i(-1, 0)) + +func test_snap_northeast() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2(0.7, -0.7))).is_equal(Vector2i(1, -1)) + +func test_snap_southwest() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2(-0.7, 0.7))).is_equal(Vector2i(-1, 1)) + +func test_snap_zero_returns_zero() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2.ZERO)).is_equal(Vector2i.ZERO) + +func test_snap_tiny_returns_zero() -> void: + assert_object(InputMapper._snap_to_octant_dir(Vector2(0.001, 0.0))).is_equal(Vector2i.ZERO) + +func test_snap_diagonal_bias() -> void: + # Slightly more east than north — should snap to northeast + assert_object(InputMapper._snap_to_octant_dir(Vector2(0.8, -0.6))).is_equal(Vector2i(1, -1)) + + +# -- _wasd_to_world_dir -------------------------------------------------------- + +func test_wasd_forward_facing_east() -> void: + # W pressed, facing east → move east + InputMapper.facing_angle = 0.0 # East + var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1)) + assert_object(result).is_equal(Vector2i(1, 0)) + +func test_wasd_forward_facing_north() -> void: + # W pressed, facing north → move north + InputMapper.facing_angle = -PI / 2.0 # North + var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1)) + assert_object(result).is_equal(Vector2i(0, -1)) + +func test_wasd_backward_facing_north() -> void: + # S pressed, facing north → move south + InputMapper.facing_angle = -PI / 2.0 # North + var result := InputMapper._wasd_to_world_dir(Vector2i(0, 1)) + assert_object(result).is_equal(Vector2i(0, 1)) + +func test_wasd_strafe_right_facing_north() -> void: + # D pressed, facing north → move east + InputMapper.facing_angle = -PI / 2.0 # North + var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0)) + assert_object(result).is_equal(Vector2i(1, 0)) + +func test_wasd_strafe_left_facing_north() -> void: + # A pressed, facing north → move west + InputMapper.facing_angle = -PI / 2.0 # North + var result := InputMapper._wasd_to_world_dir(Vector2i(-1, 0)) + assert_object(result).is_equal(Vector2i(-1, 0)) + +func test_wasd_forward_facing_south() -> void: + # W pressed, facing south → move south + InputMapper.facing_angle = PI / 2.0 # South + var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1)) + assert_object(result).is_equal(Vector2i(0, 1)) + +func test_wasd_diagonal_forward_right_facing_east() -> void: + # W+D pressed, facing east → move southeast + InputMapper.facing_angle = 0.0 # East + var result := InputMapper._wasd_to_world_dir(Vector2i(1, -1)) + assert_object(result).is_equal(Vector2i(1, 1)) + +func test_wasd_strafe_right_facing_west() -> void: + # D pressed, facing west → move north + InputMapper.facing_angle = PI # West + var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0)) + assert_object(result).is_equal(Vector2i(0, -1)) + + +func after_test() -> void: + # Reset facing to default + InputMapper.facing_angle = -PI / 2.0 + InputMapper.facing_octant = "North" + InputMapper._last_sent_octant = "North" diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 26beb5197..a0751dc60 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -298,25 +298,30 @@ func test_entity_renderer_player_has_facing_indicator() -> void: renderer.queue_free() func test_entity_renderer_facing_indicator_rotation_accuracy() -> void: + # D-054: Facing indicator now reads InputMapper.facing_angle (float), not + # GameState.player_facing (string). Indicator rotation = facing_angle + PI/2. GameState.player_entity_id = 1 var renderer := _make_entity_renderer() - var directions := { - "North": 0.0, - "Northeast": PI / 4.0, - "East": PI / 2.0, - "Southeast": 3.0 * PI / 4.0, - "South": PI, - "Southwest": 5.0 * PI / 4.0, - "West": 3.0 * PI / 2.0, - "Northwest": 7.0 * PI / 4.0, + # {facing_angle → expected indicator rotation} + # Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2. + var angles := { + -PI / 2.0: 0.0, # North + -PI / 4.0: PI / 4.0, # Northeast + 0.0: PI / 2.0, # East + PI / 4.0: 3.0 * PI / 4.0, # Southeast + PI / 2.0: PI, # South + 3.0 * PI / 4.0: 5.0 * PI / 4.0, # Southwest + PI: 3.0 * PI / 2.0, # West + -3.0 * PI / 4.0: 7.0 * PI / 4.0, # Northwest (note: -PI/2 wraps) } renderer.update_entities(_test_entities_v2) var player_node = renderer.entity_nodes[1] var indicator = player_node.get_node_or_null("FacingIndicator") - for dir_name in directions: - GameState.player_facing = dir_name + for angle in angles: + InputMapper.facing_angle = angle renderer.update_entities(_test_entities_v2) - assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001) + assert_that(indicator.rotation).is_equal_approx(angles[angle], 0.001) + InputMapper.facing_angle = -PI / 2.0 # Reset to default (North) renderer.queue_free() func test_entity_renderer_npc_has_no_facing_indicator() -> void: diff --git a/client/ui/checklist_overlay.gd b/client/ui/checklist_overlay.gd index 7f7479a0b..da0089754 100644 --- a/client/ui/checklist_overlay.gd +++ b/client/ui/checklist_overlay.gd @@ -20,12 +20,14 @@ const MAX_DESC_CHARS := 52 # Truncate long descriptions var _evaluator = null # ChecklistEvaluator instance var _last_room_id: Variant = null +var _cached_font: Font = null # Cached to avoid per-frame theme lookup func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE visible = false _evaluator = _ChecklistEvaluator.new() + _cached_font = get_theme_default_font() func update_from_state() -> void: @@ -59,7 +61,7 @@ func _draw() -> void: if _evaluator == null or not _evaluator.is_loaded(): return - var font := get_theme_default_font() + var font: Font = _cached_font if _cached_font else get_theme_default_font() var results: Array = _evaluator.get_results() if results.is_empty(): return diff --git a/client/ui/world_radial.gd b/client/ui/world_radial.gd index 7311e3289..b7193c5e1 100644 --- a/client/ui/world_radial.gd +++ b/client/ui/world_radial.gd @@ -4,7 +4,9 @@ extends Control ## Insert-styled: geometric lines, thin spokes, nearly transparent. ## Renders on InsertOverlay (CanvasLayer 10). ## Drag-release for power users, click-click for newcomers. -## Insert spoke sends Pause on activate, Pause again on close (toggle). +## Insert spoke sends PauseSimulation on activate (#518/D-058). +## Selecting any other spoke (or re-opening the radial) calls deactivate_insert() +## which sends ResumeSimulation. Pause/resume are idempotent. signal spoke_selected(spoke_name: String) @@ -83,6 +85,15 @@ func _close_menu() -> void: visible = false +# Handle Escape key to dismiss menu and deactivate insert if active. +func _unhandled_input(event: InputEvent) -> void: + if _open and event.is_action_pressed("ui_cancel"): + if _insert_active: + deactivate_insert() + _close_menu() + get_viewport().set_input_as_handled() + + func _update_hover(mouse_pos: Vector2) -> void: var delta := mouse_pos - _origin var dist := delta.length() @@ -115,6 +126,13 @@ func _confirm_selection() -> void: if _hovered_spoke == Spoke.INSERT: _activate_insert() + elif _insert_active: + # Selecting any non-Insert spoke closes the insert and resumes sim + deactivate_insert() + + elif _insert_active: + # No spoke selected (cancelled) while insert active — close insert + deactivate_insert() _close_menu() -- 2.54.0 From 503644beb13734b371936f94ef5ef1ee9a0852ba Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 18 Feb 2026 18:28:37 +0100 Subject: [PATCH 6/6] =?UTF-8?q?fix(client):=20address=20PR=20#36=20re-revi?= =?UTF-8?q?ew=20=E2=80=94=207=20items=20from=20Hoshe=20+=20Tyre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warnings: facing indicator tests use Godot-normalized rotation range (-PI, PI] instead of raw addition (SW/W/NW in test_rendering, West in test_client_p3). Suggestions: cache font in world_radial _draw(), fix docstring on deactivate_insert() trigger, document tile-coordinate system on _eval_player_near (D-066), add public reset_facing_state() to InputMapper (D-030 testability). Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/input_mapper.gd | 7 +++++++ client/scripts/checklist/checklist_evaluator.gd | 3 +++ client/tests/test_client_p3.gd | 2 +- client/tests/test_input_mapper_facing.gd | 5 +---- client/tests/test_rendering.gd | 6 +++--- client/ui/world_radial.gd | 11 ++++++----- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 74cbdda5d..10e90b8b8 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -120,6 +120,13 @@ func flush_queue() -> Array[Dictionary]: return queue +## Reset facing state to default (North). Use in tests per D-030 testability. +func reset_facing_state() -> void: + facing_angle = -PI / 2.0 + facing_octant = "North" + _last_sent_octant = "North" + + # D-054: Compute facing angle from mouse position relative to player screen position. # Uses viewport canvas transform to convert world coords to screen coords. # Intentional coupling: reads GameState.player_position directly — InputMapper is an diff --git a/client/scripts/checklist/checklist_evaluator.gd b/client/scripts/checklist/checklist_evaluator.gd index ca4e8fe71..2f51187ca 100644 --- a/client/scripts/checklist/checklist_evaluator.gd +++ b/client/scripts/checklist/checklist_evaluator.gd @@ -160,6 +160,9 @@ func _evaluate_condition(cond: Dictionary) -> bool: return false +## x/y and radius are in tile coordinates (matching GameState.player_position), +## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion +## happens only at render time. func _eval_player_near(cond: Dictionary) -> bool: var tx: float = float(cond.get("x", 0)) var ty: float = float(cond.get("y", 0)) diff --git a/client/tests/test_client_p3.gd b/client/tests/test_client_p3.gd index 7fc79a39c..faffb3dd8 100644 --- a/client/tests/test_client_p3.gd +++ b/client/tests/test_client_p3.gd @@ -267,7 +267,7 @@ func test_facing_indicator_rotation_matches_input_mapper_angle() -> void: -PI / 2.0: 0.0, # North 0.0: PI / 2.0, # East PI / 2.0: PI, # South - PI: 3.0 * PI / 2.0, # West + PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot) } for angle in angles: InputMapper.facing_angle = angle diff --git a/client/tests/test_input_mapper_facing.gd b/client/tests/test_input_mapper_facing.gd index 9aea61b72..24b3b3e56 100644 --- a/client/tests/test_input_mapper_facing.gd +++ b/client/tests/test_input_mapper_facing.gd @@ -116,7 +116,4 @@ func test_wasd_strafe_right_facing_west() -> void: func after_test() -> void: - # Reset facing to default - InputMapper.facing_angle = -PI / 2.0 - InputMapper.facing_octant = "North" - InputMapper._last_sent_octant = "North" + InputMapper.reset_facing_state() diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index a0751dc60..f19d9ff96 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -310,9 +310,9 @@ func test_entity_renderer_facing_indicator_rotation_accuracy() -> void: 0.0: PI / 2.0, # East PI / 4.0: 3.0 * PI / 4.0, # Southeast PI / 2.0: PI, # South - 3.0 * PI / 4.0: 5.0 * PI / 4.0, # Southwest - PI: 3.0 * PI / 2.0, # West - -3.0 * PI / 4.0: 7.0 * PI / 4.0, # Northwest (note: -PI/2 wraps) + 3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI]) + PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2) + -3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4) } renderer.update_entities(_test_entities_v2) var player_node = renderer.entity_nodes[1] diff --git a/client/ui/world_radial.gd b/client/ui/world_radial.gd index b7193c5e1..5451efc95 100644 --- a/client/ui/world_radial.gd +++ b/client/ui/world_radial.gd @@ -5,8 +5,8 @@ extends Control ## Renders on InsertOverlay (CanvasLayer 10). ## Drag-release for power users, click-click for newcomers. ## Insert spoke sends PauseSimulation on activate (#518/D-058). -## Selecting any other spoke (or re-opening the radial) calls deactivate_insert() -## which sends ResumeSimulation. Pause/resume are idempotent. +## Selecting any non-Insert spoke, cancelling, or pressing Escape calls +## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent. signal spoke_selected(spoke_name: String) @@ -37,6 +37,7 @@ var _origin: Vector2 = Vector2.ZERO var _hovered_spoke: int = Spoke.NONE var _drag_mode: bool = false var _insert_active: bool = false +var _cached_font: Font = null func _ready() -> void: @@ -45,6 +46,7 @@ func _ready() -> void: size = custom_minimum_size visible = false mouse_filter = Control.MOUSE_FILTER_STOP + _cached_font = get_theme_default_font() func _input(event: InputEvent) -> void: @@ -192,10 +194,9 @@ func _draw() -> void: # Label var label: String = SPOKE_NAMES.get(spoke, "") - var font := get_theme_default_font() - var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) + var text_size := _cached_font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0) - draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color) + draw_string(_cached_font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color) func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void: -- 2.54.0