#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>
140 lines
4.9 KiB
GDScript
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
|