feat(client): Sprint 12 — renderer fix, sound pipeline, medium-range indicators
#345: entity_renderer.gd already used entity_id; added regression tests confirming old "id" field is rejected and "entity_id" is accepted. #447 (OQ-29): DIALOGUE_MAX_WIDTH = 1920 added to constants.gd. Full viewport width at target resolution (60 × TILE_SIZE), per D-061 Lead directive "max-width". Recorded as D-076 in decisions/perception.md. #126: SoundIndicatorRenderer — fog-edge directional arrows for medium-range sound events (D-018). Node2D at z:951 in World scene. Color-coded per D-018/D-069 (neutral/voice/danger). GameState.medium_sound_events partitions Medium events from snapshot sound_events field. Tests added to test_rendering.gd; Hoshe's test_sound_indicators.gd stubs updated. #125: Close-range stereo audio pipeline wired. AudioManager.play_sound_event() maps event_type to D-038 asset key (Footstep/FootstepSprint → sfx_footstep_*). GameState.close_sound_events partitions Close events. main.gd calls _play_close_sound_events() each snapshot tick. test_audio_bus_routing.gd Layer 4 stubs upgraded to real tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
|
||||
var SoundIndicatorScript = load("res://scripts/rendering/sound_indicator_renderer.gd")
|
||||
|
||||
# -- Test data matching Protocol decoded format --
|
||||
|
||||
@@ -230,6 +231,19 @@ func test_entity_renderer_skips_missing_entity_id() -> void:
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
# Regression test for #345: protocol uses entity_id (not id).
|
||||
# An entity dict keyed with "id" (old/wrong format) must be silently dropped.
|
||||
func test_entity_renderer_rejects_old_id_field_format() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
# Old (wrong) format: "id" instead of "entity_id"
|
||||
renderer.update_entities([{"id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
# Correct format: "entity_id" — should be accepted
|
||||
renderer.update_entities([{"entity_id": 99, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(99)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_player_color_differs_from_npc() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities)
|
||||
@@ -334,6 +348,181 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- EntityRenderer: regression #345 (entity_id field name bug) --
|
||||
# Bug: entity_renderer.gd checked entity_data.has("id") instead of
|
||||
# entity_data.has("entity_id"), causing all entities to be silently dropped.
|
||||
# Fix: use entity_id (Protocol v2 canonical field name).
|
||||
|
||||
func test_regression_345_entity_id_field_renders() -> void:
|
||||
# Regression: entity with correct "entity_id" field MUST be rendered.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(99)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_old_id_field_not_rendered() -> void:
|
||||
# Negative regression: entity using OLD field name "id" (not "entity_id")
|
||||
# must be silently dropped. Pre-fix code accepted "id"; this verifies the fix.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"id": 99, "x": 4.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_mixed_batch_only_entity_id_renders() -> void:
|
||||
# Mixed batch: one entity with correct "entity_id", one with old "id" only.
|
||||
# Only the entity_id entity should appear — no cross-contamination.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 1, "x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
{"id": 2, "x": 2.0, "y": 2.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(1)).is_true()
|
||||
assert_that(renderer.entity_nodes.has(2)).is_false()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_entity_id_node_keyed_by_id_value() -> void:
|
||||
# Regression: entity_nodes dict must be keyed by the entity_id VALUE,
|
||||
# not by a string "entity_id" or by the old "id" value.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 42, "x": 3.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
assert_that(renderer.entity_nodes.has(42)).is_true()
|
||||
assert_that(renderer.entity_nodes.has("entity_id")).is_false()
|
||||
assert_that(renderer.entity_nodes.has(0)).is_false()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_regression_345_entity_position_set_from_entity_id_entity() -> void:
|
||||
# Regression: entity rendered via entity_id must have correct pixel position.
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([
|
||||
{"entity_id": 5, "x": 6.0, "y": 7.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
])
|
||||
var node = renderer.entity_nodes[5]
|
||||
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
|
||||
assert_that(node.position.x).is_equal_approx(6.0 * Constants.TILE_SIZE + offset, 0.01)
|
||||
assert_that(node.position.y).is_equal_approx(7.0 * Constants.TILE_SIZE + offset, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- SoundIndicatorRenderer: #126 D-018 medium-range fog-edge indicators --
|
||||
|
||||
func _make_sound_indicator_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(SoundIndicatorScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
func test_sound_indicator_accepts_medium_events() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([
|
||||
{"x": 10.0, "y": 10.0, "event_type": "Footstep"},
|
||||
{"x": 15.0, "y": 15.0, "event_type": "Voice"},
|
||||
])
|
||||
assert_that(renderer._indicators.size()).is_equal(2)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_drops_events_without_position() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([
|
||||
{"event_type": "Footstep"}, # missing x, y
|
||||
{"x": 5.0, "event_type": "Voice"}, # missing y
|
||||
{"x": 8.0, "y": 3.0, "event_type": "Footstep"}, # valid
|
||||
])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_replaces_events_on_update() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([
|
||||
{"x": 1.0, "y": 1.0, "event_type": "Voice"},
|
||||
{"x": 2.0, "y": 2.0, "event_type": "Footstep"},
|
||||
])
|
||||
assert_that(renderer._indicators.size()).is_equal(2)
|
||||
# New update replaces all previous events
|
||||
renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Footstep"}])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_empty_update_clears_indicators() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([{"x": 3.0, "y": 3.0, "event_type": "Voice"}])
|
||||
assert_that(renderer._indicators.size()).is_equal(1)
|
||||
renderer.update_sound_events([])
|
||||
assert_that(renderer._indicators.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_color_voice() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
assert_that(renderer._color_for_type("voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
|
||||
assert_that(renderer._color_for_type("Voice")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
|
||||
assert_that(renderer._color_for_type("speech")).is_equal(SoundIndicatorRenderer.COLOR_VOICE)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_color_danger() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
assert_that(renderer._color_for_type("Gunshot")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
|
||||
assert_that(renderer._color_for_type("alert")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
|
||||
assert_that(renderer._color_for_type("danger")).is_equal(SoundIndicatorRenderer.COLOR_DANGER)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_color_neutral_for_unknown() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
assert_that(renderer._color_for_type("Footstep")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
|
||||
assert_that(renderer._color_for_type("")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
|
||||
assert_that(renderer._color_for_type("Unknown")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_elapsed_starts_at_zero() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Voice"}])
|
||||
assert_that(renderer._indicators[0].elapsed).is_equal_approx(0.0, 0.001)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_sound_indicator_events_expire_after_lifetime() -> void:
|
||||
var renderer := _make_sound_indicator_renderer()
|
||||
renderer.update_sound_events([{"x": 10.0, "y": 5.0, "event_type": "Footstep"}])
|
||||
# Manually age the indicator past its lifetime
|
||||
renderer._indicators[0].elapsed = SoundIndicatorRenderer.INDICATOR_LIFETIME + 0.01
|
||||
renderer._process(0.0) # zero delta so no additional aging
|
||||
assert_that(renderer._indicators.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
# -- GameState: medium_sound_events from snapshot --
|
||||
|
||||
func test_game_state_filters_medium_sound_events() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
|
||||
{"x": 8.0, "y": 8.0, "event_type": "Voice", "range_category": "Medium"},
|
||||
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Long"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Voice")
|
||||
|
||||
func test_game_state_medium_sound_events_empty_when_no_field() -> void:
|
||||
GameState.apply_snapshot({"tick": 1})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(0)
|
||||
|
||||
func test_game_state_medium_sound_events_cleared_between_ticks() -> void:
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [{"x": 5.0, "y": 5.0, "event_type": "Voice", "range_category": "Medium"}]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
# Next tick without sound_events clears them
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- TileRenderer: tile type constants --
|
||||
|
||||
func test_tile_type_map_covers_required_types() -> void:
|
||||
|
||||
Reference in New Issue
Block a user