Procedural sound kit: the whole game has audio, no recordings
Every WAV is synthesized from maths by tools/gen_sounds.py (run through Blender for its bundled numpy) -- sines, noise and envelopes, seeded and reproducible, licence-free by construction. Loops (engine, skid, boost, siren) are built from integer-Hz components over exactly 1 s so the seam is mathematically silent; noise beds get a crossfaded seam. Seam continuity is verified numerically. Wiring: pitch-tracked engine loop on every car (idle burble to top-end scream, bump while boosting), skid loop off slip, player boost whoosh, small/big crash by jolt with glass on wrecks, two-tone siren on pursuit cops (derby brawlers stay silent), Crashbreaker boom, gearbox clunk/bog, win/lose stings on takedowns, outruns, escapes, busts and derby eliminations. Loop flags live in code (sfx.gd), not per-file .import settings, so a regenerated wav never silently loses its loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
aac4384c05
commit
589e99c727
29
README.md
29
README.md
@ -261,8 +261,33 @@ Visual check (opens a window, saves a PNG):
|
||||
/Applications/Godot.app/Contents/MacOS/Godot --path . --script tests/screenshot.gd -- car=datto_120y mode=crash frames=280 out=shot.png
|
||||
```
|
||||
|
||||
## Sound
|
||||
|
||||
Every WAV in `assets/sounds/` is synthesized from maths by
|
||||
[tools/gen_sounds.py](tools/gen_sounds.py) — sines, noise and envelopes, no
|
||||
recordings, so the whole kit is licence-free by construction and reproducible
|
||||
from its seed like everything else in this repo. (Run it through Blender: it's
|
||||
only there for the bundled numpy, no bpy involved.)
|
||||
|
||||
Loop discipline: anything the game loops (engine, skid, boost, siren) is built
|
||||
from integer-Hz components over exactly 1 s so the seam is mathematically
|
||||
silent; noise beds get a crossfaded seam. Loop flags are set in code
|
||||
([sfx.gd](src/sfx.gd)), not per-file import settings, so a regenerated wav never
|
||||
silently loses its loop.
|
||||
|
||||
Wiring: every car runs a pitch-tracked engine loop (the arcade law: idle burble
|
||||
at rest, screaming near top speed, a bump while boosting) plus a skid loop off
|
||||
the slip value; the player adds a boost whoosh. Crashes pick small/big by jolt
|
||||
strength with glass on a wreck, pursuit cops wail a two-tone siren (derby
|
||||
brawlers don't), the Crashbreaker booms, the gearbox clunks or bogs, and event
|
||||
results sting up or down.
|
||||
|
||||
If the kit ever wants sweetening beyond synthesis, use **CC0 sources only**
|
||||
(kenney.nl, OpenGameArt filtered to CC0) — CC-BY means shipping attribution
|
||||
forever and NC-licensed audio doesn't belong in this repo at all.
|
||||
|
||||
## Design doc
|
||||
|
||||
`docs/BurnoutInfinityShitbox.pdf` — research report on the original game. Still to come:
|
||||
crash deformation (control-hull vertex displacement, equations in the PDF), oncoming-lane
|
||||
boost, Road Rage mode, sound, real level GLBs from Blender.
|
||||
oncoming-lane boost, Road Rage mode, a DEM elevation pass (so the Story Bridge deck
|
||||
actually rises), and hand-picked crossings for the two scenery-crash junctions.
|
||||
|
||||
BIN
assets/sounds/bog.wav
Normal file
BIN
assets/sounds/bog.wav
Normal file
Binary file not shown.
24
assets/sounds/bog.wav.import
Normal file
24
assets/sounds/bog.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://qbn0cilhfqwj"
|
||||
path="res://.godot/imported/bog.wav-8f1da2786c55aeefbeb106dc868c91db.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/bog.wav"
|
||||
dest_files=["res://.godot/imported/bog.wav-8f1da2786c55aeefbeb106dc868c91db.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/boom.wav
Normal file
BIN
assets/sounds/boom.wav
Normal file
Binary file not shown.
24
assets/sounds/boom.wav.import
Normal file
24
assets/sounds/boom.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://dnwpk58ldx1ky"
|
||||
path="res://.godot/imported/boom.wav-ce80ab5026d9de2164088103d22abe14.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/boom.wav"
|
||||
dest_files=["res://.godot/imported/boom.wav-ce80ab5026d9de2164088103d22abe14.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/boost.wav
Normal file
BIN
assets/sounds/boost.wav
Normal file
Binary file not shown.
24
assets/sounds/boost.wav.import
Normal file
24
assets/sounds/boost.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://da0fblx1gdv5u"
|
||||
path="res://.godot/imported/boost.wav-810a94b056a1e46df7bb29aadebe3719.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/boost.wav"
|
||||
dest_files=["res://.godot/imported/boost.wav-810a94b056a1e46df7bb29aadebe3719.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/clunk.wav
Normal file
BIN
assets/sounds/clunk.wav
Normal file
Binary file not shown.
24
assets/sounds/clunk.wav.import
Normal file
24
assets/sounds/clunk.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://bypon8wahkl6m"
|
||||
path="res://.godot/imported/clunk.wav-39e9719aa8e918e8eeb10576b0be4c5d.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/clunk.wav"
|
||||
dest_files=["res://.godot/imported/clunk.wav-39e9719aa8e918e8eeb10576b0be4c5d.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/crash_big.wav
Normal file
BIN
assets/sounds/crash_big.wav
Normal file
Binary file not shown.
24
assets/sounds/crash_big.wav.import
Normal file
24
assets/sounds/crash_big.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://bcxbcnytwk7pj"
|
||||
path="res://.godot/imported/crash_big.wav-4b192579a3fd4414c388af2e6de59ed6.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/crash_big.wav"
|
||||
dest_files=["res://.godot/imported/crash_big.wav-4b192579a3fd4414c388af2e6de59ed6.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/crash_small.wav
Normal file
BIN
assets/sounds/crash_small.wav
Normal file
Binary file not shown.
24
assets/sounds/crash_small.wav.import
Normal file
24
assets/sounds/crash_small.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://ceeyt53afugxp"
|
||||
path="res://.godot/imported/crash_small.wav-94e44666608124db8d2651ef7b97a9aa.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/crash_small.wav"
|
||||
dest_files=["res://.godot/imported/crash_small.wav-94e44666608124db8d2651ef7b97a9aa.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/engine.wav
Normal file
BIN
assets/sounds/engine.wav
Normal file
Binary file not shown.
24
assets/sounds/engine.wav.import
Normal file
24
assets/sounds/engine.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cybkeltoy7j3a"
|
||||
path="res://.godot/imported/engine.wav-d6786269ebfd2ada75bdd079c2e17f80.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/engine.wav"
|
||||
dest_files=["res://.godot/imported/engine.wav-d6786269ebfd2ada75bdd079c2e17f80.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/glass.wav
Normal file
BIN
assets/sounds/glass.wav
Normal file
Binary file not shown.
24
assets/sounds/glass.wav.import
Normal file
24
assets/sounds/glass.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cttvhh5nm5j1m"
|
||||
path="res://.godot/imported/glass.wav-8cd29b0619eb51b1e55ea8a7e038a004.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/glass.wav"
|
||||
dest_files=["res://.godot/imported/glass.wav-8cd29b0619eb51b1e55ea8a7e038a004.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/siren.wav
Normal file
BIN
assets/sounds/siren.wav
Normal file
Binary file not shown.
24
assets/sounds/siren.wav.import
Normal file
24
assets/sounds/siren.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cwrt3oul3vs3m"
|
||||
path="res://.godot/imported/siren.wav-847d6fbddcbf15b051f8f55cc9265888.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/siren.wav"
|
||||
dest_files=["res://.godot/imported/siren.wav-847d6fbddcbf15b051f8f55cc9265888.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/skid.wav
Normal file
BIN
assets/sounds/skid.wav
Normal file
Binary file not shown.
24
assets/sounds/skid.wav.import
Normal file
24
assets/sounds/skid.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://xhfwat50fi7v"
|
||||
path="res://.godot/imported/skid.wav-2935abfa7678803791f7dd569a3dc250.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/skid.wav"
|
||||
dest_files=["res://.godot/imported/skid.wav-2935abfa7678803791f7dd569a3dc250.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/sting_lose.wav
Normal file
BIN
assets/sounds/sting_lose.wav
Normal file
Binary file not shown.
24
assets/sounds/sting_lose.wav.import
Normal file
24
assets/sounds/sting_lose.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://dw8p7p4q4y413"
|
||||
path="res://.godot/imported/sting_lose.wav-6123e9e6785ae293e80b5df7e1e06e37.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/sting_lose.wav"
|
||||
dest_files=["res://.godot/imported/sting_lose.wav-6123e9e6785ae293e80b5df7e1e06e37.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
BIN
assets/sounds/sting_win.wav
Normal file
BIN
assets/sounds/sting_win.wav
Normal file
Binary file not shown.
24
assets/sounds/sting_win.wav.import
Normal file
24
assets/sounds/sting_win.wav.import
Normal file
@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://dskukm4ylh7n7"
|
||||
path="res://.godot/imported/sting_win.wav-99ef8cb7f30234f01743fb8f02166015.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/sounds/sting_win.wav"
|
||||
dest_files=["res://.godot/imported/sting_win.wav-99ef8cb7f30234f01743fb8f02166015.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
33
src/car.gd
33
src/car.gd
@ -43,6 +43,9 @@ var _prev_lv := Vector3.ZERO
|
||||
var _impact_cd := 0.0
|
||||
var _parts: Array[Node] = []
|
||||
var _deform: Deformer = null
|
||||
var _snd_eng: AudioStreamPlayer3D
|
||||
var _snd_skid: AudioStreamPlayer3D
|
||||
var _snd_boost: AudioStreamPlayer3D
|
||||
|
||||
@onready var wheels: Array[Node] = [$FL, $FR, $RL, $RR]
|
||||
|
||||
@ -71,6 +74,13 @@ func _ready() -> void:
|
||||
var bm := BoxMesh.new()
|
||||
bm.size = Vector3(stats.width, 1.0, stats.length)
|
||||
$Body.mesh = bm
|
||||
# the player's own engine sits front and centre; everyone else's is scenery
|
||||
_snd_eng = Sfx.looper(self, "engine", -9.0 if is_player else -17.0)
|
||||
if _snd_eng:
|
||||
_snd_eng.play()
|
||||
_snd_skid = Sfx.looper(self, "skid", -7.0 if is_player else -14.0)
|
||||
if is_player:
|
||||
_snd_boost = Sfx.looper(self, "boost", -8.0)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if is_player:
|
||||
@ -123,6 +133,25 @@ func _physics_process(delta: float) -> void:
|
||||
if boosting:
|
||||
boost = maxf(boost - BOOST_DRAIN * delta, 0.0)
|
||||
|
||||
if _snd_eng:
|
||||
# arcade pitch law: idle burble at rest, screaming near top speed
|
||||
_snd_eng.pitch_scale = clampf(0.55 + linear_velocity.length() / 26.0, 0.55, 2.5) \
|
||||
+ (0.25 if boosting else 0.0)
|
||||
_snd_eng.volume_db = (-9.0 if is_player else -17.0) + absf(throttle_in) * 4.0
|
||||
if _snd_skid:
|
||||
var skidding := grounded >= 2 and absf(slip) > 4.0 and linear_velocity.length() > 8.0
|
||||
if skidding and not _snd_skid.playing:
|
||||
_snd_skid.play()
|
||||
elif not skidding and _snd_skid.playing:
|
||||
_snd_skid.stop()
|
||||
if skidding:
|
||||
_snd_skid.pitch_scale = clampf(0.85 + absf(slip) / 40.0, 0.85, 1.4)
|
||||
if _snd_boost:
|
||||
if boosting and not _snd_boost.playing:
|
||||
_snd_boost.play()
|
||||
elif not boosting and _snd_boost.playing:
|
||||
_snd_boost.stop()
|
||||
|
||||
var jolt := linear_velocity - _prev_lv
|
||||
_prev_lv = linear_velocity
|
||||
_impact_cd = maxf(_impact_cd - delta, 0.0)
|
||||
@ -136,6 +165,10 @@ func earn_boost(amount: float) -> void:
|
||||
func _impact(jolt: Vector3) -> void:
|
||||
var dv := jolt.length()
|
||||
crashed.emit(dv)
|
||||
Sfx.shot(self, "crash_big" if dv > 8.0 else "crash_small",
|
||||
-2.0, randf_range(0.9, 1.1))
|
||||
if dv > WRECK_DV:
|
||||
Sfx.shot(self, "glass", -4.0)
|
||||
var side := (global_basis.inverse() * -jolt).normalized()
|
||||
if _deform:
|
||||
# no contact manifold from the jolt heuristic, so approximate the strike
|
||||
|
||||
@ -9,9 +9,18 @@ extends Car
|
||||
|
||||
var target: Node3D
|
||||
var power_boost := 1.0 # scales with heat: more wanted = more committed
|
||||
var siren := true # pursuit cops wail; derby brawlers don't
|
||||
|
||||
var _stuck := 0.0
|
||||
var _unstick := 0.0
|
||||
var _snd_siren: AudioStreamPlayer3D
|
||||
|
||||
func _ready() -> void:
|
||||
super()
|
||||
if siren:
|
||||
_snd_siren = Sfx.looper(self, "siren", -8.0)
|
||||
if _snd_siren:
|
||||
_snd_siren.play()
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if target and not wrecked:
|
||||
|
||||
10
src/game.gd
10
src/game.gd
@ -203,6 +203,7 @@ func _ready() -> void:
|
||||
var d: Cop = (load("res://src/cop.tscn") as PackedScene).instantiate()
|
||||
d.stats = load(pool[randi() % pool.size()])
|
||||
d.power_boost = 1.0
|
||||
d.siren = false # brawlers, not police
|
||||
# a wreck is an elimination: without this car.gd's 2.2 s _recover
|
||||
# timer stands them back up and the field never empties
|
||||
d.hold_wreck = true
|
||||
@ -594,6 +595,7 @@ func _derby_down(d: Cop) -> void:
|
||||
derby_kills += 1
|
||||
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
|
||||
car.boost = car.boost_max
|
||||
Sfx.shot(car, "sting_win", -4.0)
|
||||
_msg("WRECKED 'EM! x%d" % derby_kills, 46)
|
||||
|
||||
func _derby_tick(delta: float) -> void:
|
||||
@ -642,10 +644,12 @@ func _drag_tick(delta: float) -> void:
|
||||
if Input.is_action_just_pressed("boost"):
|
||||
if revs >= SHIFT_LO and revs < REDLINE:
|
||||
clean_shifts += 1
|
||||
Sfx.shot(car, "clunk", -2.0)
|
||||
_msg("CLEAN SHIFT", 34)
|
||||
else:
|
||||
blown_shifts += 1
|
||||
bog = 0.9
|
||||
Sfx.shot(car, "bog", -2.0)
|
||||
_msg("MISSED IT", 34)
|
||||
gear += 1
|
||||
revs = 0.28 if gear <= DRAG_GEARS else 1.0
|
||||
@ -727,9 +731,11 @@ func _end_pursuit(escaped: bool) -> void:
|
||||
escapes += 1
|
||||
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
|
||||
car.boost = car.boost_max
|
||||
Sfx.shot(car, "sting_win", -4.0)
|
||||
_msg("LOST 'EM! x%d" % escapes, 52)
|
||||
else:
|
||||
busts += 1
|
||||
Sfx.shot(car, "sting_lose", -4.0)
|
||||
_msg("BUSTED", 52)
|
||||
|
||||
func _pursuit_tick(delta: float) -> void:
|
||||
@ -831,8 +837,10 @@ func _outrun_end(won: bool, why: String) -> void:
|
||||
outruns_won += 1
|
||||
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) # takedown-tier reward
|
||||
car.boost = car.boost_max
|
||||
Sfx.shot(car, "sting_win", -4.0)
|
||||
_msg("OUTRUN WON! x%d%s" % [outruns_won, tail], 52)
|
||||
else:
|
||||
Sfx.shot(car, "sting_lose", -4.0)
|
||||
_msg("OUTRUN LOST%s" % tail, 44)
|
||||
|
||||
func _race_tick() -> void:
|
||||
@ -894,6 +902,7 @@ func _rival_wrecked(r: Rival) -> void:
|
||||
return
|
||||
car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP)
|
||||
car.boost = car.boost_max
|
||||
Sfx.shot(car, "sting_win", -4.0)
|
||||
_msg("TAKEDOWN!", 56)
|
||||
|
||||
func _traffic_smashed(impulse: float) -> void:
|
||||
@ -927,6 +936,7 @@ func _crashbreaker() -> void:
|
||||
else:
|
||||
(b as RigidBody3D).apply_central_impulse(dir * strength * (b as RigidBody3D).mass)
|
||||
car.apply_central_impulse(Vector3.UP * car.mass * 7.0)
|
||||
Sfx.shot(car, "boom", 0.0)
|
||||
_msg("CRASHBREAKER!", 52)
|
||||
var boom := MeshInstance3D.new()
|
||||
var sm := SphereMesh.new()
|
||||
|
||||
51
src/sfx.gd
Normal file
51
src/sfx.gd
Normal file
@ -0,0 +1,51 @@
|
||||
class_name Sfx
|
||||
## Tiny audio utility over the procedural kit in assets/sounds (tools/gen_sounds.py).
|
||||
## Loops are flagged here rather than in per-file .import settings so a wav
|
||||
## regenerated by the tool never silently loses its loop flag.
|
||||
|
||||
const DIR := "res://assets/sounds/%s.wav"
|
||||
const LOOPS := ["engine", "skid", "boost", "siren"]
|
||||
|
||||
static var _cache := {}
|
||||
|
||||
static func stream(name: String) -> AudioStream:
|
||||
if _cache.has(name):
|
||||
return _cache[name]
|
||||
var path := DIR % name
|
||||
if not ResourceLoader.exists(path):
|
||||
return null
|
||||
var s: AudioStream = load(path)
|
||||
if s is AudioStreamWAV and name in LOOPS:
|
||||
var w := s as AudioStreamWAV
|
||||
w.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
w.loop_begin = 0
|
||||
w.loop_end = w.data.size() / 2 # 16-bit mono: 2 bytes per frame
|
||||
_cache[name] = s
|
||||
return s
|
||||
|
||||
static func looper(parent: Node, name: String, db: float) -> AudioStreamPlayer3D:
|
||||
## Persistent 3D player for a looped bed (engine, skid, siren). Not playing
|
||||
## until the caller decides; returns null when the kit isn't generated yet.
|
||||
var s := stream(name)
|
||||
if s == null:
|
||||
return null
|
||||
var p := AudioStreamPlayer3D.new()
|
||||
p.stream = s
|
||||
p.volume_db = db
|
||||
p.max_distance = 90.0
|
||||
parent.add_child(p)
|
||||
return p
|
||||
|
||||
static func shot(parent: Node, name: String, db := 0.0, pitch := 1.0) -> void:
|
||||
## Fire-and-forget one-shot at the parent's position.
|
||||
var s := stream(name)
|
||||
if s == null:
|
||||
return
|
||||
var p := AudioStreamPlayer3D.new()
|
||||
p.stream = s
|
||||
p.volume_db = db
|
||||
p.pitch_scale = pitch
|
||||
p.max_distance = 160.0
|
||||
parent.add_child(p)
|
||||
p.play()
|
||||
p.finished.connect(p.queue_free)
|
||||
1
src/sfx.gd.uid
Normal file
1
src/sfx.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bf8uwanb2pyhb
|
||||
156
tools/gen_sounds.py
Normal file
156
tools/gen_sounds.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Procedural sound kit: every WAV the game plays, synthesized from maths.
|
||||
|
||||
Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/gen_sounds.py
|
||||
(Blender only for its bundled numpy -- no bpy used)
|
||||
|
||||
Licence-free by construction: sines, noise and envelopes, no recordings.
|
||||
Deterministic (seeded), so a rebuild is byte-for-byte reproducible.
|
||||
|
||||
Loop discipline: anything the game loops (engine, skid, boost, siren) is built
|
||||
from integer-Hz components over an exact 1 s so the seam is mathematically
|
||||
silent; noise beds get a crossfaded seam instead. One-shots just decay to zero.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import wave
|
||||
|
||||
import numpy as np
|
||||
|
||||
SR = 22050
|
||||
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets", "sounds")
|
||||
rng = np.random.default_rng(1982)
|
||||
|
||||
|
||||
def t(dur):
|
||||
return np.arange(int(SR * dur)) / SR
|
||||
|
||||
|
||||
def env_exp(dur, tau):
|
||||
return np.exp(-t(dur) / tau)
|
||||
|
||||
|
||||
def noise(dur):
|
||||
return rng.standard_normal(int(SR * dur))
|
||||
|
||||
|
||||
def band(x, lo, hi, peaks=()):
|
||||
"""FFT bandpass with optional resonant peaks [(hz, width, gain)]."""
|
||||
spec = np.fft.rfft(x)
|
||||
f = np.fft.rfftfreq(len(x), 1 / SR)
|
||||
mask = ((f >= lo) & (f <= hi)).astype(float)
|
||||
edge = (hi - lo) * 0.15 + 1.0
|
||||
mask = np.convolve(mask, np.hanning(9) / np.hanning(9).sum(), mode="same")
|
||||
for hz, width, gain in peaks:
|
||||
mask += gain * np.exp(-((f - hz) / width) ** 2)
|
||||
return np.fft.irfft(spec * mask, len(x))
|
||||
|
||||
|
||||
def seam(x, fade=0.06):
|
||||
"""Crossfade the tail into the head so a noise bed loops cleanly."""
|
||||
n = int(SR * fade)
|
||||
w = np.linspace(0, 1, n)
|
||||
x[:n] = x[:n] * w + x[-n:] * (1 - w)
|
||||
return x[:-n]
|
||||
|
||||
|
||||
def norm(x, peak=0.85):
|
||||
return x / (np.max(np.abs(x)) + 1e-9) * peak
|
||||
|
||||
|
||||
def write(name, x):
|
||||
x16 = (np.clip(x, -1, 1) * 32767).astype("<i2")
|
||||
with wave.open(os.path.join(OUT, name + ".wav"), "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(SR)
|
||||
w.writeframes(x16.tobytes())
|
||||
print("%-14s %5.2fs peak %.2f" % (name, len(x) / SR, np.max(np.abs(x))))
|
||||
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# engine: integer-Hz harmonic stack of 56 Hz with a firing-pulse AM -- the
|
||||
# arcade "brap". All components integer Hz over exactly 1 s = seamless loop.
|
||||
tt = t(1.0)
|
||||
eng = np.zeros(len(tt))
|
||||
for h, a in ((1, 1.0), (2, 0.62), (3, 0.42), (4, 0.30), (6, 0.16), (8, 0.09)):
|
||||
eng += a * np.sin(2 * math.pi * 56 * h * tt + h)
|
||||
eng += 0.5 * np.sin(2 * math.pi * 28 * tt) # sub
|
||||
eng *= 1.0 + 0.38 * np.sin(2 * math.pi * 28 * tt - 0.7) # firing pulses
|
||||
bed = band(noise(1.0), 40, 900)
|
||||
eng[: len(seam(bed))] += 0.10 * norm(seam(bed), 1.0)[: len(eng)]
|
||||
write("engine", norm(eng, 0.7))
|
||||
|
||||
# skid: screechy banded noise with resonances and a 15 Hz tremolo
|
||||
sk = band(noise(1.0), 900, 3200, peaks=((1250, 90, 6.0), (1900, 120, 4.0)))
|
||||
sk *= 1.0 + 0.3 * np.sin(2 * math.pi * 15 * t(1.0))
|
||||
write("skid", norm(seam(sk), 0.6))
|
||||
|
||||
# boost: airy rushing band with a slow 6 Hz surge
|
||||
bo = band(noise(1.0), 250, 1600) + 0.5 * band(noise(1.0), 2500, 6000)
|
||||
bo *= 1.0 + 0.25 * np.sin(2 * math.pi * 6 * t(1.0))
|
||||
write("boost", norm(seam(bo), 0.55))
|
||||
|
||||
# siren: Aussie two-tone, 620/470 Hz halves with soft crossfades, odd harmonics
|
||||
tt = t(1.0)
|
||||
gate = 0.5 * (1 + np.tanh(np.sin(2 * math.pi * 1 * tt) * 24)) # smooth 0/1 square
|
||||
si = np.zeros(len(tt))
|
||||
for f0, g in ((620, gate), (470, 1 - gate)):
|
||||
tone = sum(np.sin(2 * math.pi * f0 * k * tt) / k for k in (1, 3, 5))
|
||||
si += g * tone
|
||||
write("siren", norm(si, 0.5))
|
||||
|
||||
# crashes: thump sweep + banded burst (+ clatter echoes on the big one)
|
||||
def thump(dur, f_hi, f_lo, tau):
|
||||
ph = 2 * math.pi * np.cumsum(np.linspace(f_hi, f_lo, int(SR * dur))) / SR
|
||||
return np.sin(ph) * env_exp(dur, tau)
|
||||
|
||||
cs = thump(0.35, 150, 48, 0.09)
|
||||
cs += 0.8 * band(noise(0.35), 200, 4200) * env_exp(0.35, 0.05)
|
||||
cs += 0.25 * (np.sin(2 * math.pi * 810 * t(0.35)) + np.sin(2 * math.pi * 1130 * t(0.35))) * env_exp(0.35, 0.03)
|
||||
write("crash_small", norm(cs, 0.8))
|
||||
|
||||
cb = thump(0.9, 120, 34, 0.22)
|
||||
cb += 0.9 * band(noise(0.9), 120, 3800) * env_exp(0.9, 0.13)
|
||||
for d, a in ((0.16, 0.4), (0.29, 0.3), (0.45, 0.2)): # clatter
|
||||
i = int(SR * d)
|
||||
burst = band(noise(0.12), 400, 3000) * env_exp(0.12, 0.03) * a
|
||||
cb[i:i + len(burst)] += burst
|
||||
write("crash_big", norm(cb, 0.9))
|
||||
|
||||
# glass: a shower of tiny bright pings over a sizzle
|
||||
gl = 0.25 * band(noise(0.6), 4000, 9500) * env_exp(0.6, 0.12)
|
||||
for _ in range(42):
|
||||
at = rng.uniform(0, 0.4)
|
||||
f = rng.uniform(3800, 8800)
|
||||
ping = np.sin(2 * math.pi * f * t(0.05)) * env_exp(0.05, 0.012) * rng.uniform(0.2, 0.55)
|
||||
i = int(SR * at)
|
||||
gl[i:i + len(ping)] += ping
|
||||
write("glass", norm(gl, 0.6))
|
||||
|
||||
# crashbreaker: deep sweep + saturated noise whomp
|
||||
bm = thump(1.2, 95, 28, 0.35)
|
||||
bm += 1.2 * band(noise(1.2), 30, 700) * env_exp(1.2, 0.18)
|
||||
write("boom", norm(np.tanh(bm * 2.2), 0.95))
|
||||
|
||||
# stings: little square arpeggios (win up, lose down)
|
||||
def sting(notes, dur=0.16, gap=0.02):
|
||||
parts = []
|
||||
for f in notes:
|
||||
tone = sum(np.sin(2 * math.pi * f * k * t(dur)) / k for k in (1, 3)) * env_exp(dur, 0.09)
|
||||
parts += [tone, np.zeros(int(SR * gap))]
|
||||
return np.concatenate(parts)
|
||||
|
||||
write("sting_win", norm(sting([392, 523, 659, 784]), 0.55))
|
||||
write("sting_lose", norm(sting([392, 311, 233], dur=0.22), 0.5))
|
||||
|
||||
# gearbox: clean clunk vs blown-shift bog
|
||||
ck = thump(0.14, 95, 60, 0.03) + 0.5 * band(noise(0.14), 1500, 5000) * env_exp(0.14, 0.006)
|
||||
write("clunk", norm(ck, 0.7))
|
||||
|
||||
ph = 2 * math.pi * np.cumsum(np.linspace(130, 55, int(SR * 0.45))) / SR
|
||||
bg = np.sign(np.sin(ph)) * env_exp(0.45, 0.16)
|
||||
bg *= 1.0 + 0.5 * np.sin(2 * math.pi * 27 * t(0.45))
|
||||
write("bog", norm(bg * 0.5 + 0.3 * band(noise(0.45), 80, 500) * env_exp(0.45, 0.1), 0.6))
|
||||
|
||||
print("sound kit written to", os.path.abspath(OUT))
|
||||
Loading…
Reference in New Issue
Block a user