diff --git a/client/scripts/rendering/sound_indicator_renderer.gd b/client/scripts/rendering/sound_indicator_renderer.gd index ab54be5d1..6dc0864ef 100644 --- a/client/scripts/rendering/sound_indicator_renderer.gd +++ b/client/scripts/rendering/sound_indicator_renderer.gd @@ -13,8 +13,9 @@ extends Node2D ## Danger #d45d5d — alert, gunshot, explosion, threat ## ## Each indicator lives for INDICATOR_LIFETIME seconds and fades out over -## the last FADE_DURATION seconds. New events from the same tick replace -## any previously received events (server sends current medium events per tick). +## the last FADE_DURATION seconds. New events are appended each tick; +## expired indicators are removed by _process(). Deduplication prevents +## the same source position from stacking multiple arrows. const TILE_SIZE: int = Constants.TILE_SIZE @@ -47,20 +48,32 @@ func _process(delta: float) -> void: queue_redraw() -## Update medium-range sound events from snapshot. +## Append new medium-range sound events from snapshot. ## events: Array of {x: float, y: float, event_type: String} ## Only Medium range_category events should be passed. +## Deduplicates by tile position — if an indicator already exists at (x,y), +## its timer resets instead of spawning a duplicate arrow. func update_sound_events(events: Array) -> void: - _indicators.clear() for evt in events: if not evt.has("x") or not evt.has("y"): continue - _indicators.append({ - "x": float(evt.x), - "y": float(evt.y), - "event_type": evt.get("event_type", ""), - "elapsed": 0.0, - }) + var ex: float = float(evt.x) + var ey: float = float(evt.y) + # Deduplicate: reset timer if an indicator already exists at this tile + var found := false + for ind in _indicators: + if is_equal_approx(ind.x, ex) and is_equal_approx(ind.y, ey): + ind.elapsed = 0.0 + ind.event_type = evt.get("event_type", "") + found = true + break + if not found: + _indicators.append({ + "x": ex, + "y": ey, + "event_type": evt.get("event_type", ""), + "elapsed": 0.0, + }) if not _indicators.is_empty(): queue_redraw() @@ -122,7 +135,7 @@ func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void: var perp := Vector2(-dir.y, dir.x) var base_center := pos - dir * ARROW_LEN - draw_colored_polygon( + draw_polygon( PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]), PackedColorArray([color, color, color]) ) diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 087068859..4e4a760fe 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -438,24 +438,25 @@ func test_sound_indicator_drops_events_without_position() -> void: assert_that(renderer._indicators.size()).is_equal(1) renderer.queue_free() -func test_sound_indicator_replaces_events_on_update() -> void: +func test_sound_indicator_appends_new_events() -> 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 + # New update appends — existing indicators persist until they expire renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Footstep"}]) - assert_that(renderer._indicators.size()).is_equal(1) + assert_that(renderer._indicators.size()).is_equal(3) renderer.queue_free() -func test_sound_indicator_empty_update_clears_indicators() -> void: +func test_sound_indicator_empty_update_preserves_existing() -> 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) + # Empty update does not clear existing indicators — they expire via _process renderer.update_sound_events([]) - assert_that(renderer._indicators.size()).is_equal(0) + assert_that(renderer._indicators.size()).is_equal(1) renderer.queue_free() func test_sound_indicator_color_voice() -> void: @@ -479,6 +480,15 @@ func test_sound_indicator_color_neutral_for_unknown() -> void: assert_that(renderer._color_for_type("Unknown")).is_equal(SoundIndicatorRenderer.COLOR_NEUTRAL) renderer.queue_free() +func test_sound_indicator_deduplicates_same_position() -> void: + var renderer := _make_sound_indicator_renderer() + renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}]) + assert_that(renderer._indicators.size()).is_equal(1) + # Same position again — should reset timer, not add a duplicate + renderer.update_sound_events([{"x": 5.0, "y": 5.0, "event_type": "Voice"}]) + assert_that(renderer._indicators.size()).is_equal(1) + 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"}]) diff --git a/client/tests/test_sound_indicators.gd b/client/tests/test_sound_indicators.gd index 1aebb88c1..e9abcc6e8 100644 --- a/client/tests/test_sound_indicators.gd +++ b/client/tests/test_sound_indicators.gd @@ -200,9 +200,25 @@ func test_indicator_does_not_crash_for_empty_events() -> void: 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() diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index a0683983f..62c71b53b 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -28,7 +28,7 @@ const FADE_IN: float = 0.2 const FADE_OUT: float = 0.3 # D-064: 300ms fade on walk-away const MAX_OPTIONS: int = 3 # D-061: max 3 response options visible const MAX_HEIGHT_RATIO: float = 0.2 # D-061: max 20% viewport height -const MAX_WIDTH_PX: float = 832.0 # D-061: max-width cap (~65% of 1280) +const MAX_WIDTH_PX: float = Constants.DIALOGUE_MAX_WIDTH # D-076: 640px (OQ-29) const CONFRONTATION_BEAT_DURATION: float = 1.5 # D-063: pause before sending const CONFRONTATION_DIM_ALPHA: float = 0.7 # D-063: dialogue box dims during beat const CONFRONTATION_MONOLOGUE_KEY: String = "dialogue.confrontation_beat" diff --git a/client/ui/dialogue_box.tscn b/client/ui/dialogue_box.tscn index 9207b8bad..ac80c616e 100644 --- a/client/ui/dialogue_box.tscn +++ b/client/ui/dialogue_box.tscn @@ -20,9 +20,9 @@ anchor_left = 0.5 anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 -offset_left = -416.0 +offset_left = -320.0 offset_top = -200.0 -offset_right = 416.0 +offset_right = 320.0 grow_horizontal = 2 grow_vertical = 0 diff --git a/decisions/perception.md b/decisions/perception.md index a6cc2e4d7..c4675ee4a 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -358,4 +358,4 @@ How the player observes and interacts with the world: camera, fog, line-of-sight --- -*30 decisions. Last updated: 2026-02-19 (D-076: OQ-29 resolved — dialogue max-width 1920px)* +*30 decisions. Last updated: 2026-02-19 (D-076: OQ-29 resolved — dialogue max-width 640px)*