Add procedural crowd ambience and radio crackle
- AudioFeedback.start_crowd_bed() builds a 4 s looping AudioStreamWAV at runtime (one-pole filtered noise plus a 68/103 Hz drone, seeded) and set_crowd_level() maps crowd mood 0..1 to -34..-12 dB, so the street gets louder as it gets agitated or panics. The bed starts with the shift and stops at shift end. - play_radio_open()/play_radio_close() push short filtered-noise bursts; wired to opening the radio panel, submitting a report, cancelling, and closing the panel. - Still asset-free and headless-safe: with a dummy audio server the calls no-op. _free_player() returns null before _ready and callers handle it. Tests/docs - Headless suite 127 -> 134 (test_audio_feedback.gd). - New smoke_audio.gd: bed playing, level follows mood events, radio crackles once per open/close, bed stops at shift end. Verified windowed (real audio server). - README, TODO section moved to DONE, MEMORY. Version 0.2.8.
This commit is contained in:
1 parent
e2c2a0bf4c
commit
294ac17333
13 files changed
+305
-17
No files matched your search
@@ -0,0 +1,85 @@
|
||||
extends SceneTree
|
||||
## Audio smoke: starts a shift, verifies the crowd bed is playing and follows mood
|
||||
## events, and that opening/closing the radio triggers crackle cues.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_audio.gd
|
||||
## (Headless uses a dummy audio server; the calls must still be safe.)
|
||||
|
||||
const MAX_FRAMES := 900
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout")
|
||||
return true
|
||||
if frame < 120:
|
||||
return false
|
||||
match step:
|
||||
0:
|
||||
_check(gm.audio.bed_playing, "the crowd bed is playing during the shift")
|
||||
var before: float = gm.audio.crowd_level
|
||||
gm.coordinator.crowd.apply_event("aggressive_behavior")
|
||||
_check(gm.audio.crowd_level > before, "an aggressive event raises the bed level")
|
||||
gm.coordinator.crowd.apply_event("emergency_responder")
|
||||
_check(gm.audio.crowd_level > before, "the responder keeps it up")
|
||||
step = 1
|
||||
1:
|
||||
if gm.npcs.is_empty():
|
||||
return false
|
||||
gm.call("_on_guard_target", gm.npcs[0])
|
||||
var crackles: int = gm.audio.radio_crackles
|
||||
gm.call("_open_radio", false)
|
||||
_check(gm.audio.radio_crackles == crackles + 1, "opening the radio crackles once")
|
||||
gm.call("_on_radio_submitted", {
|
||||
"npc_id": gm.current_incident_npc_id,
|
||||
"location": "entrance",
|
||||
"description": "tourist",
|
||||
"behavior": "standing",
|
||||
"weapons_seen": false,
|
||||
"danger_level": 1,
|
||||
}, false)
|
||||
_check(gm.audio.radio_crackles == crackles + 2, "closing the radio crackles once")
|
||||
gm.coordinator.call("_finish_shift")
|
||||
_check(not gm.audio.bed_playing, "the bed stops when the shift ends")
|
||||
step = 2
|
||||
2:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: crowd bed and radio crackle")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,78 @@
|
||||
extends RefCounted
|
||||
## Audio feedback: a looping crowd bed that follows the crowd mood, and radio
|
||||
## crackle on open/close. All procedural, no audio assets, headless-safe.
|
||||
|
||||
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
|
||||
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||
|
||||
func test_bed_volume_follows_mood() -> bool:
|
||||
var quiet: float = AudioFeedback._level_to_db(0.0)
|
||||
var loud: float = AudioFeedback._level_to_db(1.0)
|
||||
var mid: float = AudioFeedback._level_to_db(0.5)
|
||||
var a := TestUtils.check(quiet < -30.0, "a calm street keeps the bed quiet")
|
||||
var b := TestUtils.check(loud > quiet, "panic raises the bed")
|
||||
var c := TestUtils.check(mid > quiet and mid < loud, "the rise is gradual")
|
||||
return a and b and c
|
||||
|
||||
func test_crowd_level_is_clamped() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.set_crowd_level(-1.0)
|
||||
var a := TestUtils.check_eq(audio.crowd_level, 0.0, "negative mood clamps to 0")
|
||||
audio.set_crowd_level(3.0)
|
||||
var b := TestUtils.check_eq(audio.crowd_level, 1.0, "over-unity mood clamps to 1")
|
||||
audio.free()
|
||||
return a and b
|
||||
|
||||
func test_crowd_stream_loops() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
var stream: AudioStreamWAV = audio._build_crowd_stream()
|
||||
var a := TestUtils.check(stream != null, "a crowd stream is generated")
|
||||
var b := TestUtils.check_eq(stream.loop_mode, AudioStreamWAV.LOOP_FORWARD, "the bed loops")
|
||||
var c := TestUtils.check(stream.data.size() > 1000, "the bed has audio data")
|
||||
var d := TestUtils.check(stream.data.size() == stream.loop_end * 2, "loop covers the whole buffer")
|
||||
audio.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_crowd_bed_is_headless_safe() -> bool:
|
||||
# No tree, no audio server: none of these may crash.
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.start_crowd_bed()
|
||||
var a := TestUtils.check(audio.bed_playing, "start marks the bed as playing")
|
||||
audio.set_crowd_level(0.78)
|
||||
var b := TestUtils.check(is_equal_approx(audio.crowd_level, 0.78), "level is stored")
|
||||
audio.stop_crowd_bed()
|
||||
var c := TestUtils.check(not audio.bed_playing, "stop clears the playing flag")
|
||||
audio.free()
|
||||
return a and b and c
|
||||
|
||||
func test_radio_crackle_methods() -> bool:
|
||||
var audio: Node = AudioFeedback.new()
|
||||
audio.play_radio_open()
|
||||
audio.play_radio_close()
|
||||
var a := TestUtils.check_eq(audio.radio_crackles, 2, "open and close each count a crackle")
|
||||
var b := TestUtils.check(audio._free_player() == null, "no players exist before _ready, and that is safe")
|
||||
audio.free()
|
||||
return a and b
|
||||
|
||||
func test_game_manager_wires_audio() -> bool:
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift")
|
||||
var b := TestUtils.check(src.contains("audio.set_crowd_level(float(payload.get("),
|
||||
"crowd events drive the bed")
|
||||
var c := TestUtils.check(src.contains("audio.stop_crowd_bed()"), "the bed stops at shift end")
|
||||
var d := TestUtils.check(src.contains("audio.play_radio_open()"), "opening the radio crackles")
|
||||
var e := TestUtils.check(src.contains("audio.play_radio_close()"), "closing the radio crackles")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_mood_events_reach_the_bed() -> bool:
|
||||
## The bed level is fed from the same payload the crowd reactions use.
|
||||
var crowd := CrowdSystem.new()
|
||||
var seen: Array = []
|
||||
crowd.changed.connect(func(_p): pass)
|
||||
crowd.apply_event("aggressive_behavior")
|
||||
seen.append(crowd.mood)
|
||||
crowd.apply_event("emergency_responder")
|
||||
seen.append(crowd.mood)
|
||||
var a := TestUtils.check(seen[1] > seen[0], "mood rises with a responder arrival")
|
||||
crowd = null
|
||||
return a
|
||||
Reference in new issue
Block a user