class_name SoundIndicatorRenderer extends GameplayRenderer ## 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 _gameplay_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 _gameplay_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