Files
security-game/src/game/audio_feedback.gd
T
zwitschi a27e8cb74c
Full build / Full desktop build + optional release (push) Successful in 3m37s
Full build / Import + headless tests (push) Successful in 33s
Character and movement pass
NPC characters
- Articulated rig (hips/knees, shoulders/elbows/hands) replaces rigid capsules;
  the walk cycle bends knees and elbows and flees with a longer stride.
- Layered outfits (jacket/vest/coat/dress), extended accessories and hand items
  (bag, umbrella, phone, camera, clipboard, cup), face details and five hair
  silhouettes, all deterministic per profile seed.
- Idle behaviour layers (phone, clipboard, crossed arms, look-around), breathing
  and weight shift; escalation raises hands or points, and noticed NPCs turn
  toward the guard.
- Reaction symbols above the head (!!, ?!, !, ?), door-queue offsets assigned by
  GameManager, three LOD tiers with hysteresis and a billboard impostor.
- smoke_npc_budget.gd: 16 avatars = 534 meshes, ~6.9 ms average (50 ms budget).

Player movement
- Separate accel/decel, step-up assist for kerbs and the entrance steps, and a
  forward-axis fix: W was inverted (get_axis(forward, back) is negative).
- Head bob, landing dip and precision-walk FOV, gated by persisted head-bob and
  invert-Y settings; precision chip and tick; surface-aware footsteps and a
  landing cue forwarded to AudioFeedback.
- Gamepad support (sticks, deadzone, face buttons, bumpers), a control legend
  that follows the active device, and named actions for the remaining keys.
- Key/button remapping in Settings, persisted as input_bindings and applied by
  InputBindings (mouse and gamepad axes are never removed).
- smoke_movement.gd (windowed): climbs the steps, checks precision speed, chip,
  footsteps and landing; new 11-precision-walk capture and catalogue entry.

Verification: headless 208/208, Python tooling 56/56, all 11 smokes pass,
compare 11 good / 0 bad, movement judge match. Version 0.5.0. Only optional
Future ideas remain in TODO.
2026-11-01 10:41:17 +01:00

186 lines
5.8 KiB
GDScript

class_name AudioFeedback
extends Node
## Procedural audio feedback (PLAN.md: Polish and Accessibility). Generates
## short tones for confirmation prompts, radio responses, and escalation
## feedback without requiring audio assets. Headless-safe: in headless mode
## the AudioServer is a dummy and playback is a no-op.
const SAMPLE_RATE := 22050.0
var _players: Array[AudioStreamPlayer] = []
var _bed_player: AudioStreamPlayer = null
## Observable state so headless tests and the HUD can see what was requested.
var crowd_level: float = 0.0
var bed_playing := false
var radio_crackles := 0
var footsteps := 0
var landings := 0
var precision_ticks := 0
func _ready() -> void:
# Pre-create a few players so overlapping cues don't cut each other off.
for i in 4:
var player := AudioStreamPlayer.new()
add_child(player)
_players.append(player)
## Play a short confirmation beep (single high tone).
func play_confirm() -> void:
_play_tone(880.0, 0.12, 0.5)
## Play a radio-response cue (two quick tones).
func play_radio_response() -> void:
_play_tone(660.0, 0.10, 0.5)
_play_tone(990.0, 0.10, 0.5, 0.12)
## Play an escalation warning cue (low descending tone).
func play_escalation(level: int) -> void:
var freq := 440.0 - float(level) * 40.0
_play_tone(maxf(freq, 180.0), 0.18, 0.6)
## Start the looping crowd bed (low murmur). Called once per shift; safe to call
## again. Volume then follows `set_crowd_level`.
func start_crowd_bed() -> void:
if _bed_player == null:
_bed_player = AudioStreamPlayer.new()
_bed_player.name = "CrowdBed"
add_child(_bed_player)
_bed_player.stream = _build_crowd_stream()
_bed_player.volume_db = _level_to_db(crowd_level)
_bed_player.play()
bed_playing = true
func stop_crowd_bed() -> void:
if _bed_player:
_bed_player.stop()
bed_playing = false
## Crowd mood (0..1) drives the bed volume: quiet when calm, louder when the
## street is agitated or panicking.
func set_crowd_level(mood: float) -> void:
crowd_level = clampf(mood, 0.0, 1.0)
if _bed_player:
_bed_player.volume_db = _level_to_db(crowd_level)
static func _level_to_db(mood: float) -> float:
return lerpf(-34.0, -12.0, clampf(mood, 0.0, 1.0))
## Radio open cue: a short burst of filtered crackle.
func play_radio_open() -> void:
radio_crackles += 1
_play_noise(0.09, 0.35)
## Radio close cue: a lower, shorter click.
func play_radio_close() -> void:
radio_crackles += 1
_play_noise(0.06, 0.25, 1400.0)
## Surface-aware footstep: a short filtered burst, lower on asphalt, brighter on
## the stone entrance steps.
func play_footstep(surface: String) -> void:
footsteps += 1
var cutoff := 2600.0
match surface:
"asphalt":
cutoff = 1500.0
"stone":
cutoff = 3400.0
_:
cutoff = 2400.0
_play_noise(0.045, 0.22, cutoff)
## Landing thump after a step or drop.
func play_land() -> void:
landings += 1
_play_tone(92.0, 0.09, 0.4)
## Quiet tick when the precision-walk modifier engages.
func play_precision_tick() -> void:
precision_ticks += 1
_play_tone(1180.0, 0.035, 0.22)
## Play a denial/error cue (low buzz).
func play_deny() -> void:
_play_tone(220.0, 0.20, 0.5)
## Generate a sine-wave tone and play it on a free player.
func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0) -> void:
var player := _free_player()
if player == null:
return
var stream := AudioStreamGenerator.new()
stream.mix_rate = SAMPLE_RATE
stream.buffer_length = duration + 0.05
player.stream = stream
player.volume_db = linear_to_db(volume)
player.play()
# Fill the generator buffer with a sine wave.
var playback: AudioStreamGeneratorPlayback = player.get_stream_playback()
var frames := int(SAMPLE_RATE * duration)
for i in frames:
var t := float(i) / SAMPLE_RATE
var sample := sin(TAU * freq * t) * 0.6
playback.push_frame(Vector2(sample, sample))
## Deterministic low murmur for the crowd bed: filtered noise plus a low drone.
func _build_crowd_stream() -> AudioStreamWAV:
const SECONDS := 4.0
var frames := int(SAMPLE_RATE * SECONDS)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 20261031
var smooth := 0.0
for i in frames:
var t := float(i) / SAMPLE_RATE
# Low-passed noise (a simple one-pole filter) sounds like distant talking.
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), 0.02)
var drone := sin(TAU * 68.0 * t) * 0.25 + sin(TAU * 103.0 * t) * 0.12
var sample := (smooth * 2.2 + drone) * 0.35
var v := int(clampf(sample, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
stream.loop_begin = 0
stream.loop_end = frames
return stream
## Short filtered noise burst used for the radio crackle.
func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> void:
var player := _free_player()
if player == null:
return
var frames := int(SAMPLE_RATE * duration)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 7
var smooth := 0.0
var alpha := clampf(cutoff_hz / SAMPLE_RATE, 0.01, 0.9)
for i in frames:
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), alpha)
var env := 1.0 - float(i) / float(maxi(frames, 1))
var v := int(clampf(smooth * env, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
player.stream = stream
player.volume_db = linear_to_db(volume)
player.play()
func _free_player() -> AudioStreamPlayer:
if _players.is_empty():
return null
for player in _players:
if not player.playing:
return player
return _players[0]