feat(client): Sprint 12 — renderer fix, sound pipeline, medium-range indicators
#345: entity_renderer.gd already used entity_id; added regression tests confirming old "id" field is rejected and "entity_id" is accepted. #447 (OQ-29): DIALOGUE_MAX_WIDTH = 1920 added to constants.gd. Full viewport width at target resolution (60 × TILE_SIZE), per D-061 Lead directive "max-width". Recorded as D-076 in decisions/perception.md. #126: SoundIndicatorRenderer — fog-edge directional arrows for medium-range sound events (D-018). Node2D at z:951 in World scene. Color-coded per D-018/D-069 (neutral/voice/danger). GameState.medium_sound_events partitions Medium events from snapshot sound_events field. Tests added to test_rendering.gd; Hoshe's test_sound_indicators.gd stubs updated. #125: Close-range stereo audio pipeline wired. AudioManager.play_sound_event() maps event_type to D-038 asset key (Footstep/FootstepSprint → sfx_footstep_*). GameState.close_sound_events partitions Close events. main.gd calls _play_close_sound_events() each snapshot tick. test_audio_bus_routing.gd Layer 4 stubs upgraded to real tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user