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 <noreply@anthropic.com>
153 lines
5.5 KiB
GDScript
153 lines
5.5 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 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
|