- sound_indicator_renderer: draw_colored_polygon → draw_polygon (runtime crash) - sound_indicator_renderer: append+dedup instead of clear — indicators now survive 3.5s instead of dying after one server tick - dialogue_box: wire Constants.DIALOGUE_MAX_WIDTH (640px) instead of hardcoded 832px MAX_WIDTH_PX - dialogue_box.tscn: update default offsets to ±320 (was ±416) - decisions/perception.md: fix footer typo (1920px → 640px) - tests updated for new append/dedup indicator behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
237 lines
10 KiB
GDScript
237 lines
10 KiB
GDScript
## Test suite for #126: Medium-range visual indicators (D-018)
|
|
## Spec refs:
|
|
## D-018: Medium-range (outside LOS, nearby) → fog-edge directional indicators
|
|
## D-049: Z-stack — indicators must render above fog layer (z:900)
|
|
## D-069: Color palette for indicators (neutral/voice/danger)
|
|
##
|
|
## Color spec (sprint brief + D-018/D-069):
|
|
## Neutral: #c8d0e0 (matches INSERT_COLOR_TEXT — insert visual language)
|
|
## Voices: #e8c547 (matches ENTITY_COLOR_POI — amber, voice context)
|
|
## Danger: #d45d5d (matches ENTITY_COLOR_HOSTILE — muted red)
|
|
##
|
|
## Test layers:
|
|
## 1. D-018 Indicator color spec — verifies Constants match the spec
|
|
## 2. D-018 Range routing — Medium vs Close events belong to different systems
|
|
## 3. Direction geometry — angle from player position to sound source
|
|
## 4. Snapshot integration stubs — graceful skip until #126 lands
|
|
## 5. Indicator script API — graceful skip until Stig creates the script
|
|
class_name TestSoundIndicators
|
|
extends GdUnitTestSuite
|
|
|
|
|
|
# ==============================================================================
|
|
# Layer 1: D-018/D-069 Indicator Color Spec
|
|
# Sound indicators reuse existing Constants to avoid palette drift.
|
|
# ==============================================================================
|
|
|
|
func test_d018_indicator_color_neutral_is_insert_color() -> void:
|
|
## D-018: Neutral indicator = #c8d0e0 = INSERT_COLOR_TEXT.
|
|
## Indicators use the insert visual language (diegetic, not world-layer UI).
|
|
assert_that(Constants.INSERT_COLOR_TEXT).is_equal(Color("#c8d0e0"))
|
|
|
|
func test_d018_indicator_color_voices_is_poi_amber() -> void:
|
|
## D-018/D-069: Voice indicator = #e8c547 = ENTITY_COLOR_POI (amber).
|
|
assert_that(Constants.ENTITY_COLOR_POI).is_equal(Color("#e8c547"))
|
|
|
|
func test_d018_indicator_color_danger_is_hostile_red() -> void:
|
|
## D-018/D-069: Danger indicator = #d45d5d = ENTITY_COLOR_HOSTILE (muted red).
|
|
assert_that(Constants.ENTITY_COLOR_HOSTILE).is_equal(Color("#d45d5d"))
|
|
|
|
func test_d018_three_indicator_colors_are_distinct() -> void:
|
|
## D-018: All three indicator states must be visually distinct.
|
|
var neutral: Color = Constants.INSERT_COLOR_TEXT
|
|
var voice: Color = Constants.ENTITY_COLOR_POI
|
|
var danger: Color = Constants.ENTITY_COLOR_HOSTILE
|
|
assert_that(neutral != voice).is_true()
|
|
assert_that(neutral != danger).is_true()
|
|
assert_that(voice != danger).is_true()
|
|
|
|
func test_d049_fog_layer_z_index() -> void:
|
|
## D-049: Fog overlay is at z:900. Indicators must render above it (z > 900).
|
|
## This verifies the z constant — indicator placement depends on it.
|
|
assert_that(Constants.Z_FOG).is_equal(900)
|
|
|
|
|
|
# ==============================================================================
|
|
# Layer 2: D-018 Range Routing — Medium vs Close
|
|
# Close-range events → AudioManager.play_at() (spatial audio, no indicator)
|
|
# Medium-range events → fog-edge indicators (no direct audio)
|
|
# ==============================================================================
|
|
|
|
func test_d018_medium_range_events_stored_in_snapshot() -> void:
|
|
## GameState.medium_sound_events receives Medium-range events (#126 implemented).
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"sound_events": [
|
|
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
|
|
]
|
|
})
|
|
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
|
assert_that(GameState.medium_sound_events[0].event_type).is_equal("Footstep")
|
|
|
|
func test_d018_close_range_events_do_not_appear_in_medium() -> void:
|
|
## D-018: Close events must NOT appear in medium_sound_events.
|
|
## They go to close_sound_events for 2D positional audio.
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"sound_events": [
|
|
{"x": 3.0, "y": 3.0, "event_type": "Voice", "range_category": "Close"},
|
|
{"x": 15.0, "y": 15.0, "event_type": "Footstep", "range_category": "Medium"},
|
|
]
|
|
})
|
|
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
|
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
|
|
|
func test_d018_medium_range_event_has_position_fields() -> void:
|
|
## Medium-range events must include source position for direction calculation.
|
|
GameState.apply_snapshot({
|
|
"tick": 1,
|
|
"sound_events": [
|
|
{"x": 12.0, "y": 8.0, "event_type": "Footstep", "range_category": "Medium"},
|
|
]
|
|
})
|
|
var ev: Dictionary = GameState.medium_sound_events[0]
|
|
assert_that(ev.has("x")).is_true()
|
|
assert_that(ev.has("y")).is_true()
|
|
assert_that(float(ev["x"])).is_equal_approx(12.0, 0.001)
|
|
assert_that(float(ev["y"])).is_equal_approx(8.0, 0.001)
|
|
|
|
|
|
# ==============================================================================
|
|
# Layer 3: Direction Geometry
|
|
# The fog-edge indicator must point toward the sound source from the player.
|
|
# These tests validate the math used by the indicator rendering — if the
|
|
# indicator uses Vector2.angle() or atan2, these confirm expected output.
|
|
# ==============================================================================
|
|
|
|
## Compute directional angle (radians) from player tile pos to source tile pos.
|
|
## 0 = East, -PI/2 = North, PI/2 = South, ±PI = West (Godot Y-down convention).
|
|
func _direction_angle(player: Vector2, source: Vector2) -> float:
|
|
return player.direction_to(source).angle()
|
|
|
|
func test_indicator_direction_north() -> void:
|
|
## Source directly north of player (lower y in Godot) → angle -PI/2.
|
|
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
|
|
assert_that(angle).is_equal_approx(-PI / 2.0, 0.01)
|
|
|
|
func test_indicator_direction_east() -> void:
|
|
## Source directly east → angle 0.
|
|
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
|
|
assert_that(angle).is_equal_approx(0.0, 0.01)
|
|
|
|
func test_indicator_direction_south() -> void:
|
|
## Source directly south (higher y) → angle PI/2.
|
|
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
|
|
assert_that(angle).is_equal_approx(PI / 2.0, 0.01)
|
|
|
|
func test_indicator_direction_west() -> void:
|
|
## Source directly west → angle ≈ ±PI.
|
|
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 10.0))
|
|
assert_that(absf(angle)).is_equal_approx(PI, 0.01)
|
|
|
|
func test_indicator_direction_northeast() -> void:
|
|
## Source equal offset northeast → angle -PI/4.
|
|
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 0.0))
|
|
assert_that(angle).is_equal_approx(-PI / 4.0, 0.01)
|
|
|
|
func test_indicator_direction_southeast() -> void:
|
|
## Source equal offset southeast → angle PI/4.
|
|
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 20.0))
|
|
assert_that(angle).is_equal_approx(PI / 4.0, 0.01)
|
|
|
|
func test_indicator_direction_southwest() -> void:
|
|
## Source equal offset southwest → angle 3PI/4.
|
|
var angle := _direction_angle(Vector2(10.0, 10.0), Vector2(0.0, 20.0))
|
|
assert_that(angle).is_equal_approx(3.0 * PI / 4.0, 0.01)
|
|
|
|
func test_indicator_direction_changes_with_source_position() -> void:
|
|
## Sanity: direction is not degenerate — different source positions give different angles.
|
|
var north := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 0.0))
|
|
var east := _direction_angle(Vector2(10.0, 10.0), Vector2(20.0, 10.0))
|
|
var south := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 20.0))
|
|
assert_that(north != east).is_true()
|
|
assert_that(east != south).is_true()
|
|
assert_that(south != north).is_true()
|
|
|
|
func test_indicator_direction_magnitude_independent_of_distance() -> void:
|
|
## Direction angle must not vary with distance — only with angle from player.
|
|
## Source 5 tiles north vs 20 tiles north: same angle.
|
|
var near := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, 5.0))
|
|
var far := _direction_angle(Vector2(10.0, 10.0), Vector2(10.0, -10.0))
|
|
assert_that(near).is_equal_approx(far, 0.01)
|
|
|
|
|
|
# ==============================================================================
|
|
# Layer 4: Indicator Script API (graceful stub)
|
|
# Tests activate when Stig creates the indicator rendering script.
|
|
# Expected paths checked in order — first match wins.
|
|
# ==============================================================================
|
|
|
|
func _load_indicator_script() -> GDScript:
|
|
## Load the sound indicator renderer script (#126 implemented).
|
|
var path: String = "res://scripts/rendering/sound_indicator_renderer.gd"
|
|
if ResourceLoader.exists(path):
|
|
return load(path) as GDScript
|
|
return null
|
|
|
|
func test_indicator_script_exists() -> void:
|
|
## #126: sound_indicator_renderer.gd must exist at the canonical path.
|
|
var script: GDScript = _load_indicator_script()
|
|
assert_that(script != null).is_true()
|
|
|
|
func test_indicator_has_update_sound_events_method() -> void:
|
|
## #126: Indicator must expose update_sound_events(events: Array).
|
|
var script: GDScript = _load_indicator_script()
|
|
if script == null:
|
|
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
|
return
|
|
var method_names: Array = script.get_script_method_list().map(
|
|
func(m: Dictionary) -> String: return m.name
|
|
)
|
|
assert_that(method_names.has("update_sound_events")).is_true()
|
|
|
|
func test_indicator_does_not_crash_for_empty_events() -> void:
|
|
## Edge case: empty sound_events list must not crash update_sound_events.
|
|
var script: GDScript = _load_indicator_script()
|
|
if script == null:
|
|
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
|
return
|
|
var indicator := Node2D.new()
|
|
indicator.set_script(script)
|
|
add_child(indicator)
|
|
indicator.update_sound_events([])
|
|
# Empty update on fresh renderer — no indicators exist, no crash
|
|
assert_that(indicator._indicators.size()).is_equal(0)
|
|
indicator.queue_free()
|
|
|
|
func test_indicator_existing_survive_empty_update() -> void:
|
|
## Existing indicators persist through an empty update (expire via _process only).
|
|
var script: GDScript = _load_indicator_script()
|
|
if script == null:
|
|
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
|
return
|
|
var indicator := Node2D.new()
|
|
indicator.set_script(script)
|
|
add_child(indicator)
|
|
indicator.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}])
|
|
assert_that(indicator._indicators.size()).is_equal(1)
|
|
indicator.update_sound_events([])
|
|
assert_that(indicator._indicators.size()).is_equal(1)
|
|
indicator.queue_free()
|
|
|
|
func test_indicator_stores_valid_medium_events() -> void:
|
|
## #126: update_sound_events must store events with x and y fields.
|
|
var script: GDScript = _load_indicator_script()
|
|
if script == null:
|
|
push_warning("TestSoundIndicators: sound_indicator_renderer.gd not found")
|
|
return
|
|
var indicator := Node2D.new()
|
|
indicator.set_script(script)
|
|
add_child(indicator)
|
|
indicator.update_sound_events([
|
|
{"x": 10.0, "y": 5.0, "event_type": "Footstep"},
|
|
{"x": 20.0, "y": 15.0, "event_type": "Voice"},
|
|
])
|
|
assert_that(indicator._indicators.size()).is_equal(2)
|
|
indicator.queue_free()
|