Files
settled-reach/client/scripts/rendering/sound_indicator_renderer.gd
T
jpmschweitzerandClaude Sonnet 4.6 c6bd7c2db2 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>
2026-02-19 13:33:05 +01:00

140 lines
4.9 KiB
GDScript

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