From c6bd7c2db2485ec5b8a289e25a07f9e821c62b6d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 13:33:05 +0100 Subject: [PATCH 1/5] =?UTF-8?q?feat(client):=20Sprint=2012=20=E2=80=94=20r?= =?UTF-8?q?enderer=20fix,=20sound=20pipeline,=20medium-range=20indicators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- client/scenes/main.tscn | 10 +- client/scripts/autoloads/audio_manager.gd | 27 ++ client/scripts/autoloads/game_state.gd | 26 ++ client/scripts/constants.gd | 6 + client/scripts/main.gd | 17 ++ .../rendering/sound_indicator_renderer.gd | 139 +++++++++ client/scripts/rendering/world_renderer.gd | 5 + client/tests/test_audio_bus_routing.gd | 264 ++++++++++++++++++ client/tests/test_rendering.gd | 189 +++++++++++++ client/tests/test_sound_indicators.gd | 220 +++++++++++++++ decisions/perception.md | 12 +- 11 files changed, 913 insertions(+), 2 deletions(-) create mode 100644 client/scripts/rendering/sound_indicator_renderer.gd create mode 100644 client/tests/test_audio_bus_routing.gd create mode 100644 client/tests/test_sound_indicators.gd diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index ef47cef8b..fa52fcf7d 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=21 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -16,6 +16,7 @@ [ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"] [ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"] [ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"] +[ext_resource type="Script" path="res://scripts/rendering/sound_indicator_renderer.gd" id="20_soundind"] [ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"] [ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] [ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] @@ -96,6 +97,13 @@ script = ExtResource("4_fog") z_index = 950 script = ExtResource("16_fogent") +; --- z:951 — Medium-range sound indicators (#126, D-018) --- +; Directional arrows at fog boundary for sounds outside LOS. +; Above FogEntities (z:950), below InsertOverlay (CanvasLayer 10). +[node name="SoundIndicators" type="Node2D" parent="World"] +z_index = 951 +script = ExtResource("20_soundind") + ; --- Camera --- [node name="Camera2D" type="Camera2D" parent="."] position_smoothing_enabled = true diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index 1707d2e12..ecba7e4f4 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -163,6 +163,33 @@ func stop_all_loops() -> void: stop_loop(key) +# --- Audio asset registry: event type → asset key (D-018, #125) --- +# Maps server-sent sound event_type strings to audio asset keys. +# Keys match filename stems in res://audio/ (scanned by _scan_registry). +# Audio assets per D-038: footstep variants (walk / run). +# No asset for Voice events in v0.1 — play_sound_event no-ops gracefully +# (D-038: "renders as audio if asset exists, or visual indicator + monologue if not"). +const SOUND_EVENT_ASSETS: Dictionary = { + "Footstep": "sfx_footstep_metal", + "FootstepWalk": "sfx_footstep_metal", + "FootstepCareful":"sfx_footstep_metal", + "FootstepCrouch": "sfx_footstep_metal", + "FootstepSprint": "sfx_footstep_metal_run", + "FootstepRun": "sfx_footstep_metal_run", +} + + +## Play a close-range sound event at a world tile position (D-018, #125). +## event_type: server RangeCategory::Close event type string (e.g. "Footstep"). +## world_tile_pos: server tile coordinates — converted to world pixels internally. +## No-ops if event_type has no registered asset or asset file is absent. +func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void: + var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "") + if asset_key.is_empty(): + return + play_at(asset_key, world_tile_pos * Constants.TILE_SIZE) + + # --- Playback: spatial (D-018 close-range) --- ## Play a one-shot spatial sound at a world position (pixels). diff --git a/client/scripts/autoloads/game_state.gd b/client/scripts/autoloads/game_state.gd index 6be09bc9f..4f3045158 100644 --- a/client/scripts/autoloads/game_state.gd +++ b/client/scripts/autoloads/game_state.gd @@ -54,6 +54,14 @@ var rng_seed: Variant = null # v7 fields (#431, D-059/D-060) var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}] +# #126, D-018: Medium-range sound events for fog-edge directional indicators. +# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick. +var medium_sound_events: Array = [] + +# #125, D-018: Close-range sound events for positional 2D audio. +# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd. +var close_sound_events: Array = [] + func apply_snapshot(snapshot: Dictionary) -> void: current_snapshot = snapshot @@ -157,6 +165,24 @@ func apply_snapshot(snapshot: Dictionary) -> void: else: rng_seed = null + # D-018: Sound events from server — partition by range_category. + # #126: Medium → fog-edge directional indicators. + # #125: Close → positional 2D audio via AudioManager. + if snapshot.has("sound_events") and snapshot.sound_events is Array: + medium_sound_events = [] + close_sound_events = [] + for se in snapshot.sound_events: + if not se is Dictionary: + continue + var rc: String = se.get("range_category", "") + if rc == "Medium": + medium_sound_events.append(se) + elif rc == "Close": + close_sound_events.append(se) + else: + medium_sound_events = [] + close_sound_events = [] + # v2: visible_tiles with visibility sectors # Derives visible_positions when not explicitly provided (real server mode) if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0: diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index d8f66f72a..a774359ed 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -90,6 +90,12 @@ const PERIPHERAL_ALPHA: float = 0.5 const FACING_INDICATOR_SIZE: float = 6.0 const FACING_INDICATOR_OFFSET: float = 14.0 +# D-076 (OQ-29 resolution): Dialogue box max-width in pixels. +# Target resolution: 1920×1080. Full viewport width = 60 × TILE_SIZE (32px). +# Lead directive (D-061): "max-width" — full screen, not centered 50%. +# Text readability is managed via font size and internal UI node padding. +const DIALOGUE_MAX_WIDTH: int = 1920 + # #517: Implant UI font color grading — avoid pure white, project through a lens const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ed72d567b..cd9988673 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -121,6 +121,9 @@ func _process(_delta: float) -> void: if checklist_overlay and checklist_overlay.has_method("update_from_state"): checklist_overlay.update_from_state() + # D-018 #125: Play close-range sound events via positional 2D audio + _play_close_sound_events() + # Show monologue if server sent one this tick (#414) _consume_monologue() @@ -191,6 +194,20 @@ func _process(_delta: float) -> void: _pending_record_inputs.clear() +# D-018 #125: Play close-range sound events — fired once per snapshot tick. +# Each event is passed to AudioManager.play_sound_event() for 2D positional playback +# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038). +func _play_close_sound_events() -> void: + for evt in GameState.close_sound_events: + if not evt is Dictionary or not evt.has("x") or not evt.has("y"): + continue + AudioManager.play_sound_event( + evt.get("event_type", ""), + Vector2(float(evt.x), float(evt.y)) + ) + GameState.close_sound_events = [] + + # Consume-once per tick: show monologue text, then clear. # Tick guard prevents re-triggering when the same tick is polled multiple # times (client FPS > sim tick rate). diff --git a/client/scripts/rendering/sound_indicator_renderer.gd b/client/scripts/rendering/sound_indicator_renderer.gd new file mode 100644 index 000000000..ab54be5d1 --- /dev/null +++ b/client/scripts/rendering/sound_indicator_renderer.gd @@ -0,0 +1,139 @@ +class_name SoundIndicatorRenderer +extends Node2D + +## Medium-range sound indicators (#126, D-018). +## Renders directional arrows at the fog boundary for sounds outside LOS. +## +## Close-range sounds → 2D positional audio (handled by AudioManager). +## Medium-range sounds → visual arrow at fog edge pointing toward source. +## +## Color per D-018/D-069: +## Neutral #c8d0e0 — footsteps, generic sounds +## Voice #e8c547 — speech, conversation, social sounds +## 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). + +const TILE_SIZE: int = Constants.TILE_SIZE + +# Visual parameters +const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible +const FADE_DURATION: float = 0.6 # Fade-out window at end +const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge +const ARROW_HALF: float = 7.0 # Half-width of arrowhead base +const ARROW_LEN: float = 12.0 # Length from tip to base + +# D-018/D-069 colors +const COLOR_NEUTRAL: Color = Color("#c8d0e0") # Generic / footstep +const COLOR_VOICE: Color = Color("#e8c547") # Speech / conversation +const COLOR_DANGER: Color = Color("#d45d5d") # Alert / threat / gunshot + +# Indicators: [{x, y, event_type, elapsed}] +var _indicators: Array = [] + +func _process(delta: float) -> void: + if _indicators.is_empty(): + return + + var i := _indicators.size() - 1 + while i >= 0: + _indicators[i].elapsed += delta + if _indicators[i].elapsed >= INDICATOR_LIFETIME: + _indicators.remove_at(i) + i -= 1 + + queue_redraw() + + +## Update medium-range sound events from snapshot. +## events: Array of {x: float, y: float, event_type: String} +## Only Medium range_category events should be passed. +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, + }) + if not _indicators.is_empty(): + queue_redraw() + + +func _draw() -> void: + if _indicators.is_empty(): + return + + var player_world: Vector2 = GameState.player_position * TILE_SIZE + + # Compute half-extents of the visible world area from camera zoom + viewport. + # Arrows are placed at this boundary minus EDGE_INSET so they sit just inside + # the fog edge and don't clip to the physical screen border. + var vp_size := get_viewport().get_visible_rect().size + var cam := get_viewport().get_camera_2d() + var zoom := cam.zoom if cam else Vector2(2.0, 2.0) + var half_extents: Vector2 = vp_size / (2.0 * zoom) + + for ind in _indicators: + var sound_world: Vector2 = Vector2(ind.x, ind.y) * TILE_SIZE + var dir: Vector2 = sound_world - player_world + if dir.is_zero_approx(): + continue + dir = dir.normalized() + + # Project direction onto the visible-area rectangle boundary + var edge_pt: Vector2 = _rect_edge_point(player_world, dir, half_extents, EDGE_INSET) + + # Alpha: full for most of lifetime, fade out over last FADE_DURATION seconds + var t: float = ind.elapsed / INDICATOR_LIFETIME + var fade_start: float = 1.0 - FADE_DURATION / INDICATOR_LIFETIME + var alpha: float + if t < fade_start: + alpha = 1.0 + else: + alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME)) + + var color: Color = _color_for_type(ind.event_type) + color.a = alpha * 0.9 + _draw_arrow(edge_pt, dir, color) + + +## Project from center along dir to the boundary of a rectangle with +## half_extents, inset by inset pixels. Returns the boundary point. +func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float) -> Vector2: + var h := Vector2( + maxf(half.x - inset, 8.0), + maxf(half.y - inset, 8.0), + ) + # Ray-AABB slab test: find smallest positive t where the ray exits + var t_x: float = INF if abs(dir.x) < 1e-6 else abs(h.x / dir.x) + var t_y: float = INF if abs(dir.y) < 1e-6 else abs(h.y / dir.y) + var t: float = minf(t_x, t_y) + return center + dir * t + + +## Draw a filled arrowhead at pos pointing in dir. +## The tip is at pos; the base is ARROW_LEN pixels behind along -dir. +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( + PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]), + PackedColorArray([color, color, color]) + ) + + +## Map event type string → D-018 color category. +func _color_for_type(event_type: String) -> Color: + var et := event_type.to_lower() + if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"): + return COLOR_VOICE + if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \ + or et.contains("alert") or et.contains("threat"): + return COLOR_DANGER + return COLOR_NEUTRAL diff --git a/client/scripts/rendering/world_renderer.gd b/client/scripts/rendering/world_renderer.gd index 7e9594583..be3ffe63a 100644 --- a/client/scripts/rendering/world_renderer.gd +++ b/client/scripts/rendering/world_renderer.gd @@ -16,6 +16,7 @@ extends Node2D @onready var tile_renderer = $FogGroup/FloorTiles @onready var fog_renderer = $FogOverlay @onready var entity_renderer = $FogGroup/YSortGroup/Entities +@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators var _last_tick: int = -1 @@ -42,3 +43,7 @@ func update_from_state() -> void: # Update entity sprites if entity_renderer and entity_renderer.has_method("update_entities"): entity_renderer.update_entities(GameState.visible_entities) + + # D-018 #126: Update medium-range sound indicators + if sound_indicator_renderer and sound_indicator_renderer.has_method("update_sound_events"): + sound_indicator_renderer.update_sound_events(GameState.medium_sound_events) diff --git a/client/tests/test_audio_bus_routing.gd b/client/tests/test_audio_bus_routing.gd new file mode 100644 index 000000000..7e4745f3a --- /dev/null +++ b/client/tests/test_audio_bus_routing.gd @@ -0,0 +1,264 @@ +## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069) +## Spec refs: +## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds) +## D-069: Audio dip profiles — confrontation drops WorldSFX 4–6dB +## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio +## +## Test layers: +## 1. D-068 Bus architecture constants (no implementation required) +## 2. D-069 Dip profile spec values (no implementation required) +## 3. AudioManager API — dip state machine, signal emission +## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events +class_name TestAudioBusRouting +extends GdUnitTestSuite + + +func before_test() -> void: + AudioManager.clear_dip() + + +func after_test() -> void: + AudioManager.clear_dip() + + +# ============================================================================== +# Layer 1: D-068 Bus Architecture Constants +# ============================================================================== + +func test_d068_bus_world_sfx_name() -> void: + ## D-068: WorldSFX bus name must match exactly — used by all sound event routing. + assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX") + +func test_d068_bus_ambient_name() -> void: + assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient") + +func test_d068_bus_player_actions_name() -> void: + assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions") + +func test_d068_bus_ui_sounds_name() -> void: + assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds") + +func test_d068_bus_music_name() -> void: + assert_that(AudioManager.BUS_MUSIC).is_equal("Music") + +func test_d068_five_buses_defined() -> void: + ## D-068: Exactly 5 buses in the architecture. + assert_that(AudioManager.BUSES.size()).is_equal(5) + +func test_d068_all_bus_names_present_in_buses_array() -> void: + ## D-068: BUSES array must contain all 5 named buses. + assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true() + assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true() + assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true() + assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true() + assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true() + +func test_d068_bus_names_are_unique() -> void: + ## D-068: No two buses share a name. + var seen: Dictionary = {} + for bus_name in AudioManager.BUSES: + assert_that(seen.has(bus_name)).is_false() + seen[bus_name] = true + + +# ============================================================================== +# Layer 2: D-069 Dip Profile Spec Values +# Close-range sound events go on WorldSFX; confrontation dips that bus 4–6 dB. +# ============================================================================== + +func test_d069_three_dip_profiles_exist() -> void: + ## D-069: Three profiles — dialogue, confrontation, listening_focus. + assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true() + assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true() + assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true() + +func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void: + ## D-069: Confrontation must drop WorldSFX by 4–6 dB. Sprint 12 uses -5.0 dB. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("WorldSFX")).is_true() + var dip_db: float = buses["WorldSFX"] + assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true() + +func test_d069_confrontation_dips_ambient() -> void: + ## D-069: Confrontation suppresses both Ambient and WorldSFX. + var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"] + assert_that(buses.has("Ambient")).is_true() + +func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void: + ## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz. + var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"] + assert_that(spec.has("filter_hz")).is_true() + assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0) + +func test_d069_dialogue_dips_ambient_not_world_sfx() -> void: + ## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed. + ## Confrontation is the one that mutes world SFX. + var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"] + assert_that(buses.has("Ambient")).is_true() + assert_that(buses.has("WorldSFX")).is_false() + +func test_d069_listening_focus_boosts_world_sfx() -> void: + ## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping. + var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"] + assert_that(buses.has("WorldSFX")).is_true() + assert_that(buses["WorldSFX"] > 0.0).is_true() + +func test_d069_all_profiles_have_positive_ease_times() -> void: + ## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions). + for profile in AudioManager.DIP_SPECS.keys(): + var spec: Dictionary = AudioManager.DIP_SPECS[profile] + assert_that(spec.has("ease_in")).is_true() + assert_that(spec.has("ease_out")).is_true() + assert_that(spec["ease_in"] > 0.0).is_true() + assert_that(spec["ease_out"] > 0.0).is_true() + +func test_d069_confrontation_ease_out_longer_than_dialogue() -> void: + ## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive. + var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"] + var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"] + assert_that(conf_out >= dial_out).is_true() + + +# ============================================================================== +# Layer 3: AudioManager API — Dip State Machine +# ============================================================================== + +func test_audio_manager_initial_state_no_active_dip() -> void: + assert_that(AudioManager.get_active_dip()).is_equal("") + +func test_audio_manager_apply_dip_sets_active() -> void: + AudioManager.apply_dip("dialogue") + assert_that(AudioManager.get_active_dip()).is_equal("dialogue") + +func test_audio_manager_clear_dip_resets_active() -> void: + AudioManager.apply_dip("confrontation") + AudioManager.clear_dip() + assert_that(AudioManager.get_active_dip()).is_equal("") + +func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void: + ## Unknown profile: push_warning + early return, active dip stays empty. + AudioManager.apply_dip("not_a_real_profile") + assert_that(AudioManager.get_active_dip()).is_equal("") + +func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void: + ## apply_dip() must emit dip_changed(profile) synchronously. + var received_profile := "" + var conn := func(p: String) -> void: received_profile = p + AudioManager.dip_changed.connect(conn) + AudioManager.apply_dip("dialogue") + AudioManager.dip_changed.disconnect(conn) + assert_that(received_profile).is_equal("dialogue") + +func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void: + ## clear_dip() must emit dip_changed("") to signal audio restored. + AudioManager.apply_dip("dialogue") + var received_profile := "sentinel" + var conn := func(p: String) -> void: received_profile = p + AudioManager.dip_changed.connect(conn) + AudioManager.clear_dip() + AudioManager.dip_changed.disconnect(conn) + assert_that(received_profile).is_equal("") + +func test_audio_manager_apply_dip_interrupts_previous() -> void: + ## Switching profiles mid-dip: active profile must update to the new one. + AudioManager.apply_dip("dialogue") + AudioManager.apply_dip("confrontation") + assert_that(AudioManager.get_active_dip()).is_equal("confrontation") + +func test_audio_manager_dip_changed_fires_on_profile_switch() -> void: + ## Switching from dialogue to confrontation emits dip_changed("confrontation"). + AudioManager.apply_dip("dialogue") + var received_profile := "" + var conn := func(p: String) -> void: received_profile = p + AudioManager.dip_changed.connect(conn) + AudioManager.apply_dip("confrontation") + AudioManager.dip_changed.disconnect(conn) + assert_that(received_profile).is_equal("confrontation") + +func test_audio_manager_has_asset_false_for_unknown_key() -> void: + ## has_asset() must return false for a key that was never registered. + ## Ensures no-op fallback path (D-038) is correctly guarded. + assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false() + + +# ============================================================================== +# Layer 4: D-018 Snapshot Integration — #125 implemented +# GameState partitions sound_events by range_category into close_sound_events +# and medium_sound_events. Close events are played via AudioManager.play_sound_event(). +# ============================================================================== + +func test_snapshot_close_events_stored_in_close_sound_events() -> void: + ## #125: Close-range events must land in GameState.close_sound_events. + GameState.apply_snapshot({ + "tick": 1, + "sound_events": [ + {"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"}, + ] + }) + assert_that(GameState.close_sound_events.size()).is_equal(1) + assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep") + +func test_snapshot_medium_events_stored_in_medium_sound_events() -> void: + ## #126/#125: Medium-range events land in medium_sound_events, not close. + 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.close_sound_events.size()).is_equal(0) + +func test_snapshot_partitions_close_and_medium_events() -> void: + ## D-018: Mixed batch — Close and Medium events partitioned correctly. + GameState.apply_snapshot({ + "tick": 1, + "sound_events": [ + {"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"}, + {"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"}, + {"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"}, + ] + }) + assert_that(GameState.close_sound_events.size()).is_equal(1) + assert_that(GameState.medium_sound_events.size()).is_equal(1) + +func test_snapshot_empty_sound_events_clears_both_arrays() -> void: + ## When snapshot has no sound_events, both close and medium arrays are cleared. + GameState.apply_snapshot({ + "tick": 1, + "sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}] + }) + assert_that(GameState.close_sound_events.size()).is_equal(1) + GameState.apply_snapshot({"tick": 2}) + assert_that(GameState.close_sound_events.size()).is_equal(0) + assert_that(GameState.medium_sound_events.size()).is_equal(0) + +# ============================================================================== +# Layer 5: AudioManager.play_sound_event — asset registry and routing +# ============================================================================== + +func test_audio_manager_sound_event_assets_registry_exists() -> void: + ## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary. + assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true() + assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0) + +func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void: + ## #125: "Footstep" event type → sfx_footstep_metal (D-038 asset). + assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true() + assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal") + +func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void: + ## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep). + assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true() + assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run") + +func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void: + ## #125 / D-038: Unknown event types must be silently skipped (no-op). + ## play_sound_event should not crash and should not emit sound. + AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0)) + # No assertion needed — absence of crash is the test. + +func test_audio_manager_play_sound_event_noop_for_empty_type() -> void: + ## #125: Empty event type string must be a no-op. + AudioManager.play_sound_event("", Vector2(5.0, 5.0)) + # No assertion needed — absence of crash is the test. diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index f19d9ff96..087068859 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -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: diff --git a/client/tests/test_sound_indicators.gd b/client/tests/test_sound_indicators.gd new file mode 100644 index 000000000..1aebb88c1 --- /dev/null +++ b/client/tests/test_sound_indicators.gd @@ -0,0 +1,220 @@ +## 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([]) + assert_that(indicator._indicators.size()).is_equal(0) + 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() diff --git a/decisions/perception.md b/decisions/perception.md index fdace2f21..6eeeba17f 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -345,6 +345,16 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Raised by:** Paula (zone-conspicuousness model), Inigo (scoping to future sprint) - **Dissent:** None +### D-076: Dialogue box max-width — 1920px at target resolution (OQ-29 resolution) +- **Date:** 2026-02-19 +- **Decision:** `DIALOGUE_MAX_WIDTH = 1920px`. The dialogue box occupies the full viewport width at the target resolution (1920×1080). +- **Derivation:** 1920px = 60 × TILE_SIZE (32px) — grid-aligned. Full viewport width per Lead directive (D-061: "max-width", explicitly contrasting Stig's original 50% centered proposal). +- **Downstream impact:** Text wrapping in the dialogue UI is controlled by this constant. Internal padding within the dialogue node handles visual breathing room; this constant is the outer boundary. +- **Implementation:** `Constants.DIALOGUE_MAX_WIDTH` in `client/scripts/constants.gd`. +- **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), dual-scale grid ([D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)) +- **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width) +- **Raised by:** Stig (Sprint 12 OQ-29 resolution) + --- -*29 decisions. Last updated: 2026-02-19 (OQ-07 resolved: D-056/D-057 amendment)* +*30 decisions. Last updated: 2026-02-19 (D-076: OQ-29 resolved — dialogue max-width 1920px)* From 719721cd4c7f57c14d8e5b243a92c486223b009a Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 13:35:27 +0100 Subject: [PATCH 2/5] =?UTF-8?q?fix(client):=20apply=20arch=20review=20corr?= =?UTF-8?q?ections=20=E2=80=94=20640px=20dialogue=20width,=20D-067=20chime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DIALOGUE_MAX_WIDTH: correct to 640px (20 × TILE_SIZE) per Tyre architecture review. D-076 updated in decisions/perception.md with amendment note. Initial 1920px was D-061 "max-width" but readability wins at 640px. D-067 recognition chime: wire sfx_monologue_chime to fog entity recognition onset. AudioManager.CHIME_RECOGNITION constant added. main.gd tracks seen entity IDs in _known_recognition_ids; fires chime on first appearance in pending_recognitions, expires when entity leaves the queue. UISounds bus (not WorldSFX) per D-038 "monologue chime is a UI sound." Tests added to test_audio_bus_routing.gd (Layer 2b). Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/autoloads/audio_manager.gd | 5 ++++ client/scripts/constants.gd | 8 +++---- client/scripts/main.gd | 23 ++++++++++++++++++ client/tests/test_audio_bus_routing.gd | 29 +++++++++++++++++++++++ decisions/perception.md | 11 +++++---- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index ecba7e4f4..59fccf883 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -5,6 +5,11 @@ extends Node ## No-op fallback when audio assets absent (D-038). ## Spatial audio positioning for close-range sounds (D-018). +# --- D-067: Recognition chime asset key --- +# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX). +# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel. +const CHIME_RECOGNITION := "sfx_monologue_chime" + # --- Bus names (D-068) --- const BUS_MUSIC := "Music" const BUS_AMBIENT := "Ambient" diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index a774359ed..3cb7bc01e 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -91,10 +91,10 @@ const FACING_INDICATOR_SIZE: float = 6.0 const FACING_INDICATOR_OFFSET: float = 14.0 # D-076 (OQ-29 resolution): Dialogue box max-width in pixels. -# Target resolution: 1920×1080. Full viewport width = 60 × TILE_SIZE (32px). -# Lead directive (D-061): "max-width" — full screen, not centered 50%. -# Text readability is managed via font size and internal UI node padding. -const DIALOGUE_MAX_WIDTH: int = 1920 +# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport. +# Tyre architecture review 2026-02-19: readability over max-width; fits +# two columns of text comfortably, leaves world game visible alongside. +const DIALOGUE_MAX_WIDTH: int = 640 # #517: Implant UI font color grading — avoid pure white, project through a lens const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text diff --git a/client/scripts/main.gd b/client/scripts/main.gd index cd9988673..2914b4dfa 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -20,6 +20,7 @@ var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice var _last_dialogue_tick: int = -1 +var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber) var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival @@ -113,6 +114,9 @@ func _process(_delta: float) -> void: if fog_entities and fog_entities.has_method("update_from_state"): fog_entities.update_from_state() + # D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition + _play_recognition_chimes() + # #496: Update gauntlet HUD (room timer + personal bests) if gauntlet_hud and gauntlet_hud.has_method("update_from_state"): gauntlet_hud.update_from_state() @@ -208,6 +212,25 @@ func _play_close_sound_events() -> void: GameState.close_sound_events = [] +# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity +# enters the cognitive delay recognition queue for the first time. +# "The chime marks the character's attention shifting" (D-067). +# Entities that complete recognition (leave pending_recognitions) are removed +# from _known_recognition_ids so they can chime again if re-encountered. +func _play_recognition_chimes() -> void: + var active_ids: Dictionary = {} + for rec in GameState.pending_recognitions: + var eid: int = rec.entity_id + active_ids[eid] = true + if not _known_recognition_ids.has(eid): + _known_recognition_ids[eid] = true + AudioManager.play(AudioManager.CHIME_RECOGNITION) + # Expire IDs no longer in the recognition queue + for eid in _known_recognition_ids.keys(): + if not active_ids.has(eid): + _known_recognition_ids.erase(eid) + + # Consume-once per tick: show monologue text, then clear. # Tick guard prevents re-triggering when the same tick is polled multiple # times (client FPS > sim tick rate). diff --git a/client/tests/test_audio_bus_routing.gd b/client/tests/test_audio_bus_routing.gd index 7e4745f3a..fe930e083 100644 --- a/client/tests/test_audio_bus_routing.gd +++ b/client/tests/test_audio_bus_routing.gd @@ -119,6 +119,35 @@ func test_d069_confrontation_ease_out_longer_than_dialogue() -> void: assert_that(conf_out >= dial_out).is_true() +# ============================================================================== +# Layer 2b: D-067 Recognition Chime +# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX). +# ============================================================================== + +func test_d067_chime_recognition_constant_exists() -> void: + ## D-067: AudioManager must expose a CHIME_RECOGNITION constant. + assert_that("CHIME_RECOGNITION" in AudioManager).is_true() + +func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void: + ## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key). + ## "Neural lattice firing" feel — soft crystalline tone. + assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime") + +func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void: + ## D-067/D-038: Monologue chime is a UI sound, not a simulation sound. + ## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX. + ## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS. + assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true() + ## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS. + ## No further assertion needed — play() default bus IS UISounds by design. + +func test_d067_chime_noop_when_asset_absent() -> void: + ## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry, + ## play(CHIME_RECOGNITION) must be a no-op (no crash). + AudioManager.play(AudioManager.CHIME_RECOGNITION) + ## No assertion — absence of crash is the test. + + # ============================================================================== # Layer 3: AudioManager API — Dip State Machine # ============================================================================== diff --git a/decisions/perception.md b/decisions/perception.md index 6eeeba17f..a6cc2e4d7 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -345,15 +345,16 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Raised by:** Paula (zone-conspicuousness model), Inigo (scoping to future sprint) - **Dissent:** None -### D-076: Dialogue box max-width — 1920px at target resolution (OQ-29 resolution) +### D-076: Dialogue box max-width — 640px (OQ-29 resolution) - **Date:** 2026-02-19 -- **Decision:** `DIALOGUE_MAX_WIDTH = 1920px`. The dialogue box occupies the full viewport width at the target resolution (1920×1080). -- **Derivation:** 1920px = 60 × TILE_SIZE (32px) — grid-aligned. Full viewport width per Lead directive (D-061: "max-width", explicitly contrasting Stig's original 50% centered proposal). -- **Downstream impact:** Text wrapping in the dialogue UI is controlled by this constant. Internal padding within the dialogue node handles visual breathing room; this constant is the outer boundary. +- **Decision:** `DIALOGUE_MAX_WIDTH = 640px`. Dialogue box is max 640px wide, centered on screen. +- **Derivation:** 640px = 20 × TILE_SIZE (32px) — grid-aligned. ~33% of target 1920px viewport width. Readability over full-width: leaves world game visible alongside dialogue, comfortable two-column text width. +- **Downstream impact:** Text wrapping in the dialogue UI is controlled by this constant. Box is centered; the game world remains visible left and right. +- **Amendment note:** Initial resolution was 1920px (full viewport width) per D-061 Lead directive. Tyre architecture review (2026-02-19) revised to 640px for readability. - **Implementation:** `Constants.DIALOGUE_MAX_WIDTH` in `client/scripts/constants.gd`. - **Cross-reference:** Dialogue box ([D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits)), dual-scale grid ([D-066](architecture.md#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor)) - **Amends:** [D-061](#d-061-dialogue-box--bottom-screen-max-20-height-no-portraits) (adds pixel value for max-width) -- **Raised by:** Stig (Sprint 12 OQ-29 resolution) +- **Raised by:** Stig (OQ-29), revised per Tyre architecture review --- From f337bb3ca89768b6cf57938172e45d41f5c653dc Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 14:59:50 +0100 Subject: [PATCH 3/5] fix(client): correct 5 bugs found in pre-PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../rendering/sound_indicator_renderer.gd | 35 +++++++++++++------ client/tests/test_rendering.gd | 20 ++++++++--- client/tests/test_sound_indicators.gd | 16 +++++++++ client/ui/dialogue_box.gd | 2 +- client/ui/dialogue_box.tscn | 4 +-- decisions/perception.md | 2 +- 6 files changed, 59 insertions(+), 20 deletions(-) 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)* From e1a1a9cfc2846649f29530a12a478b7ff0b144c6 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 15:03:10 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix(client):=20address=20PR=20review=20warn?= =?UTF-8?q?ings=20=E2=80=94=20field=20guard=20+=20public=20API=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.gd: add has("entity_id") guard to _play_recognition_chimes() (matches defensive pattern in _play_close_sound_events and update_sound_events) - sound_indicator_renderer.gd: rename _color_for_type → color_for_type (public testable API, not an internal-only method) - test_rendering.gd: update test calls to match rename Co-Authored-By: Claude Opus 4.6 --- client/scripts/main.gd | 2 ++ .../rendering/sound_indicator_renderer.gd | 4 ++-- client/tests/test_rendering.gd | 18 +++++++++--------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 2914b4dfa..a68af9dd5 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -220,6 +220,8 @@ func _play_close_sound_events() -> void: func _play_recognition_chimes() -> void: var active_ids: Dictionary = {} for rec in GameState.pending_recognitions: + if not rec is Dictionary or not rec.has("entity_id"): + continue var eid: int = rec.entity_id active_ids[eid] = true if not _known_recognition_ids.has(eid): diff --git a/client/scripts/rendering/sound_indicator_renderer.gd b/client/scripts/rendering/sound_indicator_renderer.gd index 6dc0864ef..1ed633cbc 100644 --- a/client/scripts/rendering/sound_indicator_renderer.gd +++ b/client/scripts/rendering/sound_indicator_renderer.gd @@ -111,7 +111,7 @@ func _draw() -> void: else: alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME)) - var color: Color = _color_for_type(ind.event_type) + var color: Color = color_for_type(ind.event_type) color.a = alpha * 0.9 _draw_arrow(edge_pt, dir, color) @@ -142,7 +142,7 @@ func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void: ## Map event type string → D-018 color category. -func _color_for_type(event_type: String) -> Color: +func color_for_type(event_type: String) -> Color: var et := event_type.to_lower() if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"): return COLOR_VOICE diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 4e4a760fe..100be1862 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -461,23 +461,23 @@ func test_sound_indicator_empty_update_preserves_existing() -> void: 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) + 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) + 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) + 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_deduplicates_same_position() -> void: From dfcafee3ee987e3f39b4c731a34e34a0f5589102 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 19 Feb 2026 15:37:20 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix(client):=20address=20PR=20#43=20review?= =?UTF-8?q?=20=E2=80=94=20walk=20key=20mismatch=20+=205=20suggestions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix SOUND_EVENT_ASSETS walk-speed keys to match actual filename (sfx_footstep_metal_walk), add play_loop null guard, source indicator colors from Constants, extract CAMERA_DEFAULT_ZOOM, document consume-once semantics on close_sound_events. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/audio_manager.gd | 10 ++++++---- client/scripts/constants.gd | 3 +++ client/scripts/main.gd | 2 ++ client/scripts/rendering/sound_indicator_renderer.gd | 10 +++++----- client/tests/test_audio_bus_routing.gd | 4 ++-- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/client/scripts/autoloads/audio_manager.gd b/client/scripts/autoloads/audio_manager.gd index 59fccf883..3fc0f64be 100644 --- a/client/scripts/autoloads/audio_manager.gd +++ b/client/scripts/autoloads/audio_manager.gd @@ -142,6 +142,8 @@ func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlaye return null stop_loop(asset_key) var loop_stream := stream.duplicate() as AudioStream + if loop_stream == null: + return null _enable_loop(loop_stream) var player := AudioStreamPlayer.new() player.stream = loop_stream @@ -175,10 +177,10 @@ func stop_all_loops() -> void: # No asset for Voice events in v0.1 — play_sound_event no-ops gracefully # (D-038: "renders as audio if asset exists, or visual indicator + monologue if not"). const SOUND_EVENT_ASSETS: Dictionary = { - "Footstep": "sfx_footstep_metal", - "FootstepWalk": "sfx_footstep_metal", - "FootstepCareful":"sfx_footstep_metal", - "FootstepCrouch": "sfx_footstep_metal", + "Footstep": "sfx_footstep_metal_walk", + "FootstepWalk": "sfx_footstep_metal_walk", + "FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands + "FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands "FootstepSprint": "sfx_footstep_metal_run", "FootstepRun": "sfx_footstep_metal_run", } diff --git a/client/scripts/constants.gd b/client/scripts/constants.gd index 3cb7bc01e..1fe78761e 100644 --- a/client/scripts/constants.gd +++ b/client/scripts/constants.gd @@ -96,6 +96,9 @@ const FACING_INDICATOR_OFFSET: float = 14.0 # two columns of text comfortably, leaves world game visible alongside. const DIALOGUE_MAX_WIDTH: int = 640 +# Default camera zoom — used as fallback when get_camera_2d() returns null +const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0) + # #517: Implant UI font color grading — avoid pure white, project through a lens const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text diff --git a/client/scripts/main.gd b/client/scripts/main.gd index a68af9dd5..677c7ebf4 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -201,6 +201,8 @@ func _process(_delta: float) -> void: # D-018 #125: Play close-range sound events — fired once per snapshot tick. # Each event is passed to AudioManager.play_sound_event() for 2D positional playback # on the WorldSFX bus. Events with no registered asset are silently skipped (D-038). +# Consume-once: events are cleared after processing so they don't replay if +# _process runs again before the next server tick (D-009 multiplayer-safe pattern). func _play_close_sound_events() -> void: for evt in GameState.close_sound_events: if not evt is Dictionary or not evt.has("x") or not evt.has("y"): diff --git a/client/scripts/rendering/sound_indicator_renderer.gd b/client/scripts/rendering/sound_indicator_renderer.gd index 1ed633cbc..7d00f32b0 100644 --- a/client/scripts/rendering/sound_indicator_renderer.gd +++ b/client/scripts/rendering/sound_indicator_renderer.gd @@ -26,10 +26,10 @@ const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge const ARROW_HALF: float = 7.0 # Half-width of arrowhead base const ARROW_LEN: float = 12.0 # Length from tip to base -# D-018/D-069 colors -const COLOR_NEUTRAL: Color = Color("#c8d0e0") # Generic / footstep -const COLOR_VOICE: Color = Color("#e8c547") # Speech / conversation -const COLOR_DANGER: Color = Color("#d45d5d") # Alert / threat / gunshot +# D-018/D-069 colors — sourced from Constants to prevent palette drift +const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep +const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation +const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot # Indicators: [{x, y, event_type, elapsed}] var _indicators: Array = [] @@ -89,7 +89,7 @@ func _draw() -> void: # the fog edge and don't clip to the physical screen border. var vp_size := get_viewport().get_visible_rect().size var cam := get_viewport().get_camera_2d() - var zoom := cam.zoom if cam else Vector2(2.0, 2.0) + var zoom := cam.zoom if cam else Constants.CAMERA_DEFAULT_ZOOM var half_extents: Vector2 = vp_size / (2.0 * zoom) for ind in _indicators: diff --git a/client/tests/test_audio_bus_routing.gd b/client/tests/test_audio_bus_routing.gd index fe930e083..f0df0eb2b 100644 --- a/client/tests/test_audio_bus_routing.gd +++ b/client/tests/test_audio_bus_routing.gd @@ -272,9 +272,9 @@ func test_audio_manager_sound_event_assets_registry_exists() -> void: assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0) func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void: - ## #125: "Footstep" event type → sfx_footstep_metal (D-038 asset). + ## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset). assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true() - assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal") + assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk") func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void: ## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).