fix(client): address PR #36 review — 10 items from Hoshe + Tyre
Critical: - deactivate_insert() now called when selecting non-Insert spoke, cancelling with no selection, or pressing Escape while insert is active. Fixes simulation staying paused permanently after Insert. Warnings: - Checklist conditions with empty id excluded from get_results() and get_total_count() — prevents impossible-to-complete checklists. Warns at load time when empty-id conditions are found. - _content_base now checks res://content/ first (exported builds), falls back to ../content for editor/dev mode. - 26 new tests for D-054 functions: _angle_to_octant (8 octants), _snap_to_octant_dir (9 cases incl. zero/tiny), _wasd_to_world_dir (8 facing/movement combos). New test file: test_input_mapper_facing.gd. Suggestions: - Cached get_theme_default_font() in checklist overlay _ready(). - Documented InputMapper → GameState coupling as intentional. - Documented YAML parser # truncation limitation. - _insert_active reset on Escape dismiss (Tyre #3). - SimBridge test mode SetFacing reads action_data.facing instead of InputMapper global (Tyre #4). - Removed dead _facing_to_rotation() from entity_renderer.gd (Tyre #5). - Fixed 2 failing facing indicator tests to use InputMapper.facing_angle instead of GameState.player_facing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -177,6 +177,15 @@ 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():
|
||||
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))
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,13 @@ 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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user