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()