Merge remote-tracking branch 'origin/client'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -137,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
|
||||
@@ -163,6 +170,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_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",
|
||||
}
|
||||
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -90,6 +90,15 @@ 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.
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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()
|
||||
@@ -121,6 +125,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 +198,43 @@ 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).
|
||||
# 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"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
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:
|
||||
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):
|
||||
_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).
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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 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
|
||||
|
||||
# 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 — 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 = []
|
||||
|
||||
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()
|
||||
|
||||
|
||||
## 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:
|
||||
for evt in events:
|
||||
if not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
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()
|
||||
|
||||
|
||||
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 Constants.CAMERA_DEFAULT_ZOOM
|
||||
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_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
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
## 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 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
|
||||
# ==============================================================================
|
||||
|
||||
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_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_walk")
|
||||
|
||||
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.
|
||||
@@ -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,191 @@ 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_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 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(3)
|
||||
renderer.queue_free()
|
||||
|
||||
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(1)
|
||||
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_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"}])
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
## 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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+12
-1
@@ -345,6 +345,17 @@ 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 — 640px (OQ-29 resolution)
|
||||
- **Date:** 2026-02-19
|
||||
- **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 (OQ-29), revised per Tyre architecture review
|
||||
|
||||
---
|
||||
|
||||
*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 640px)*
|
||||
|
||||
Reference in New Issue
Block a user