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 diff --git a/client/assets/fonts/Michroma-Regular.ttf b/client/assets/fonts/Michroma-Regular.ttf new file mode 100644 index 000000000..59e8c5343 Binary files /dev/null and b/client/assets/fonts/Michroma-Regular.ttf differ 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/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/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 0f8d70188..10e90b8b8 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,85 @@ 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 +# 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: + 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..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(): @@ -258,6 +267,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 "" @@ -285,7 +296,6 @@ func _test_snapshot() -> Dictionary: if _test_is_walkable(new_pos): _test_player_pos = new_pos if delta != Vector2i.ZERO: - _test_facing = _delta_to_facing(delta) # 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 new file mode 100644 index 000000000..2f51187ca --- /dev/null +++ b/client/scripts/checklist/checklist_evaluator.gd @@ -0,0 +1,354 @@ +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: + # 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. +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"] + _warn_empty_ids(_room_conditions, room_path) + + # 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"] + _warn_empty_ids(_cross_conditions, cross_path) + + _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. +## 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", ""), + "condition_type": cond.get("condition_type", ""), + "met": _latched.has(cid), + }) + return results + + +## Total number of loaded conditions (excludes conditions with empty id). +func get_total_count() -> int: + 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. +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 + + +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: + 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 + + +## 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)) + 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. +# +# 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): + 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/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/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/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 5582d66e7..9accd5809 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: @@ -155,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/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/tests/test_client_p3.gd b/client/tests/test_client_p3.gd index 1b802a599..faffb3dd8 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: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot) } - 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..24b3b3e56 --- /dev/null +++ b/client/tests/test_input_mapper_facing.gd @@ -0,0 +1,119 @@ +## 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: + InputMapper.reset_facing_state() diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 26beb5197..f19d9ff96 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: -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] 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 new file mode 100644 index 000000000..da0089754 --- /dev/null +++ b/client/ui/checklist_overlay.gd @@ -0,0 +1,134 @@ +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 +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: + 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: Font = _cached_font if _cached_font else 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/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 diff --git a/client/ui/world_radial.gd b/client/ui/world_radial.gd index 2f1057f09..5451efc95 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 non-Insert spoke, cancelling, or pressing Escape calls +## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent. signal spoke_selected(spoke_name: String) @@ -35,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: @@ -43,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: @@ -83,6 +87,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,29 +128,36 @@ 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() 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,10 +194,9 @@ func _draw() -> void: # Label var label: String = SPOKE_NAMES.get(spoke, "") - var font := ThemeDB.fallback_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: