ajhahn.de
← the-way-out commits

Commit

the-way-out

v0.9.0

ajhahnde · May 2026 · 7e4c35ba5552a21cbec77978e27ed57d3ea2ce26 · parent: 4193605 · view on GitHub →

modified CHANGELOG.md
@@ -1,5 +1,30 @@
# CHANGELOG
## v0.9.0
Hits, abilities, doors and pickups now have sound. Adds 18
synthesised chiptune-style sound effects covering every gameplay
event and every menu confirmation, plus the missing call sites for
the level's interactables.
### Audio
- New sound effects across the board: player and enemy hits, enemy
and boss death, the boss's own hit, all five character signature
abilities (Wizard slow, Penguin shield, Elf volley, Shiggy dash,
Wolf sprint), shoot, level complete and player death.
- Levers, gates, pressure plates and the key now make sound when you
use them.
- Menu confirmations beep when you click a button. The Settings
**Sound** toggle silences both music and effects.
### Tools
- New `scripts/gen_sfx.py` deterministically (re)generates all 18 SFX
WAV files using only stdlib `wave` + `math`, so a tuning tweak ships
by running the script and committing the changed assets. Output
lives under `assets/audio/sfx/`.
## v0.8.0
Combat now reads — every hit registers. Adds game-feel polish across
modified VERSION
@@ -1 +1 @@
v0.8.0
v0.9.0
added assets/audio/sfx/boss_death.wav
binary file — no preview
added assets/audio/sfx/boss_hit.wav
binary file — no preview
added assets/audio/sfx/dash.wav
binary file — no preview
added assets/audio/sfx/enemy_death.wav
binary file — no preview
added assets/audio/sfx/gate_open.wav
binary file — no preview
added assets/audio/sfx/hit.wav
binary file — no preview
added assets/audio/sfx/key_pickup.wav
binary file — no preview
added assets/audio/sfx/level_complete.wav
binary file — no preview
added assets/audio/sfx/lever_click.wav
binary file — no preview
added assets/audio/sfx/menu_confirm.wav
binary file — no preview
added assets/audio/sfx/menu_select.wav
binary file — no preview
added assets/audio/sfx/plate_press.wav
binary file — no preview
added assets/audio/sfx/player_death.wav
binary file — no preview
added assets/audio/sfx/shield.wav
binary file — no preview
added assets/audio/sfx/shoot.wav
binary file — no preview
added assets/audio/sfx/slow.wav
binary file — no preview
added assets/audio/sfx/sprint.wav
binary file — no preview
added assets/audio/sfx/volley.wav
binary file — no preview
modified interactables.py
@@ -12,6 +12,7 @@ from settings import (
TILE_SIZE, SPIKE_CYCLE, SPIKE_DANGER_TIME, SPIKE_WARN_TIME,
PLATE_TRIGGER_DELAY,
)
import audio
TS = TILE_SIZE
@@ -131,6 +132,7 @@ class Lever(pygame.sprite.Sprite):
return False
self.activated = True
self.image = self._imgs[True]
audio.play("lever_click")
return True
@@ -184,6 +186,7 @@ class Gate(pygame.sprite.Sprite):
self.opened = True
self.obstacle_group.remove(self)
self.image = self._imgs[True]
audio.play("gate_open")
class KeyItem(pygame.sprite.Sprite):
@@ -285,6 +288,7 @@ class PressurePlate(pygame.sprite.Sprite):
if self.charge >= PLATE_TRIGGER_DELAY:
self.activated = True
self.image = self._imgs[True]
audio.play("plate_press")
return True
return False
modified levels.py
@@ -641,6 +641,7 @@ class LevelManager:
for en in [e for e in self.enemy_sprites if e is not self.boss]:
if en.hp <= 0:
self._emit_enemy_death(en)
audio.play("enemy_death")
en.kill()
continue
# Edge-detect each enemy's HP so a projectile hit puffs even
@@ -671,6 +672,7 @@ class LevelManager:
self.camera.shake(2, 0.08)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_BOSS_HIT)
self._emit_boss_hit()
audio.play("boss_hit")
self._last_boss_hp = self.boss.hp
if self.boss is not None and self.boss.hp <= 0:
@@ -798,6 +800,7 @@ class LevelManager:
self.has_key = True
self.key_item.kill()
self.key_item = None
audio.play("key_pickup")
# --- draw --------------------------------------------------------
modified menu.py
@@ -204,6 +204,7 @@ class MainMenu:
mouse_pos = pygame.mouse.get_pos()
for btn in self.buttons:
if btn["rect"].collidepoint(mouse_pos):
audio.play("menu_confirm")
return btn["action"]
return None
@@ -300,6 +301,7 @@ class SettingsMenu:
pygame.display.toggle_fullscreen()
self.update_buttons()
audio.play("menu_confirm")
return btn["action"]
return None
@@ -429,6 +431,7 @@ class LevelMenu:
mouse_pos = pygame.mouse.get_pos()
for btn in self.entries:
if btn["rect"] and btn["rect"].collidepoint(mouse_pos):
audio.play("menu_confirm")
return btn["action"]
return None
@@ -643,6 +646,7 @@ class CharacterMenu:
mouse_pos = pygame.mouse.get_pos()
for btn in self.character:
if btn["rect"].collidepoint(mouse_pos):
audio.play("menu_confirm")
return btn["action"]
return None
@@ -707,5 +711,6 @@ class PauseMenu:
mp = pygame.mouse.get_pos()
for btn in self.buttons:
if btn["rect"].collidepoint(mp):
audio.play("menu_confirm")
return btn["action"]
return None
added scripts/gen_sfx.py
@@ -0,0 +1,361 @@
"""Synthesised placeholder SFX for the v1.0.0 cut.
Pure stdlib — no numpy, no scipy, no third-party audio libs — so this
script runs anywhere the game's .venv can run. Each sound is a short
chiptune-style waveform written as a 16-bit mono PCM WAV into
``assets/audio/sfx/``. The audio.py loader looks up sounds by name
(`audio.play("shoot")` → `assets/audio/sfx/shoot.wav`); the names below
match every existing call site plus the ones wired in v0.9.0.
The SFX are intentionally chiptune-thin — the meme-flavoured pass that
the design notes call out is a post-v1.0 polish slot; these are the
"every action has feedback" baseline.
Re-run via ``./.venv/bin/python scripts/gen_sfx.py``. Idempotent: each
call rewrites the WAVs to disk so a tuning tweak ships by running the
script and committing the changed assets.
"""
import math
import os
import random
import struct
import wave
SAMPLE_RATE = 44100
SFX_DIR = os.path.join("assets", "audio", "sfx")
# Equal-temperament pitches in Hz. Just the notes I reach for in the
# arpeggios below — adding more is one line of math.
PITCH = {
"A3": 220.0, "C4": 261.63, "D4": 293.66, "E4": 329.63,
"F4": 349.23, "G4": 392.0, "A4": 440.0, "B4": 493.88,
"C5": 523.25, "D5": 587.33, "E5": 659.25, "G5": 783.99,
"A5": 880.0, "C6": 1046.5, "E6": 1318.51, "G6": 1567.98,
}
def _frames(seconds):
return int(SAMPLE_RATE * seconds)
def _envelope(n, attack=0.01, release=0.05, sustain=1.0):
"""Linear AR envelope; sustain is held between attack and release."""
a = max(1, int(SAMPLE_RATE * attack))
r = max(1, int(SAMPLE_RATE * release))
out = [0.0] * n
for i in range(n):
if i < a:
out[i] = (i / a) * sustain
elif i > n - r:
out[i] = max(0.0, ((n - i) / r)) * sustain
else:
out[i] = sustain
return out
def _sine(freq, n, phase=0.0):
w = 2 * math.pi * freq / SAMPLE_RATE
return [math.sin(w * i + phase) for i in range(n)]
def _square(freq, n, duty=0.5):
period = SAMPLE_RATE / freq
return [1.0 if (i % period) / period < duty else -1.0 for i in range(n)]
def _triangle(freq, n):
period = SAMPLE_RATE / freq
out = []
for i in range(n):
t = (i % period) / period
out.append(4 * abs(t - 0.5) - 1)
return out
def _noise(n, rng):
return [rng.uniform(-1.0, 1.0) for _ in range(n)]
def _sweep(f0, f1, n, wave_fn=_square):
"""Linear pitch sweep from f0 to f1 over n samples."""
out = [0.0] * n
phase = 0.0
for i in range(n):
f = f0 + (f1 - f0) * (i / max(1, n - 1))
phase += 2 * math.pi * f / SAMPLE_RATE
if wave_fn is _square:
out[i] = 1.0 if math.sin(phase) > 0 else -1.0
elif wave_fn is _triangle:
out[i] = (2 / math.pi) * math.asin(math.sin(phase))
else:
out[i] = math.sin(phase)
return out
def _mix(*tracks):
"""Sum equal-length tracks, clip to [-1, 1]."""
n = max(len(t) for t in tracks)
out = [0.0] * n
for t in tracks:
for i, v in enumerate(t):
out[i] += v
peak = max((abs(v) for v in out), default=1.0)
if peak > 1.0:
out = [v / peak for v in out]
return out
def _concat(*chunks):
out = []
for c in chunks:
out.extend(c)
return out
def _apply(samples, env):
n = min(len(samples), len(env))
return [samples[i] * env[i] for i in range(n)]
def _write(name, samples, gain=0.6):
"""Write a 16-bit mono WAV at the project's sample rate."""
path = os.path.join(SFX_DIR, name + ".wav")
with wave.open(path, "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SAMPLE_RATE)
peak = max((abs(v) for v in samples), default=1.0)
norm = gain / max(0.01, peak)
frames = b"".join(
struct.pack("<h", max(-32767, min(32767, int(v * norm * 32767))))
for v in samples)
f.writeframes(frames)
# --- Individual SFX ---------------------------------------------------
def sfx_shoot(rng):
"""Short downward laser chirp + a click body."""
n = _frames(0.10)
body = _sweep(900, 280, n, _square)
env = _envelope(n, 0.005, 0.06)
return _apply(body, env)
def sfx_hit(rng):
"""Generic taken-hit thud: low square + noise burst."""
n = _frames(0.09)
thud = _square(160, n)
grit = _noise(n, rng)
sig = [0.7 * thud[i] + 0.4 * grit[i] for i in range(n)]
env = _envelope(n, 0.003, 0.07)
return _apply(sig, env)
def sfx_boss_hit(rng):
"""Heavier hit: lower pitch, longer tail."""
n = _frames(0.18)
thud = _square(110, n)
grit = _noise(n, rng)
sig = [0.8 * thud[i] + 0.5 * grit[i] for i in range(n)]
env = _envelope(n, 0.005, 0.14)
return _apply(sig, env)
def sfx_enemy_death(rng):
"""Pitched-down noise burst — the air going out."""
n = _frames(0.22)
sweep = _sweep(620, 110, n, _square)
grit = _noise(n, rng)
sig = [0.6 * sweep[i] + 0.4 * grit[i] for i in range(n)]
env = _envelope(n, 0.005, 0.18)
return _apply(sig, env)
def sfx_boss_death(rng):
"""Big descending sweep + grit, double-layered for weight."""
n = _frames(0.85)
s1 = _sweep(520, 60, n, _square)
s2 = _sweep(310, 40, n, _triangle)
grit = _noise(n, rng)
sig = [0.6 * s1[i] + 0.5 * s2[i] + 0.35 * grit[i] for i in range(n)]
env = _envelope(n, 0.01, 0.6)
return _apply(sig, env)
def sfx_player_death(rng):
"""Sad descending three-note motif (E4 → C4 → A3)."""
parts = []
for note in ("E4", "C4", "A3"):
n = _frames(0.18)
sig = _triangle(PITCH[note], n)
env = _envelope(n, 0.01, 0.12)
parts.append(_apply(sig, env))
return _concat(*parts)
def sfx_level_complete(rng):
"""Major arpeggio C5 E5 G5 C6 — the win chime."""
parts = []
for note in ("C5", "E5", "G5", "C6"):
n = _frames(0.14)
sig = _triangle(PITCH[note], n)
env = _envelope(n, 0.005, 0.10)
parts.append(_apply(sig, env))
n = _frames(0.36)
tail = _triangle(PITCH["C6"], n)
parts.append(_apply(tail, _envelope(n, 0.005, 0.30)))
return _concat(*parts)
def sfx_slow(rng):
"""Wizard slow — descending vibrato chord, time-warp feel."""
n = _frames(0.55)
a = _sweep(PITCH["G4"], PITCH["C4"], n, _triangle)
b = _sweep(PITCH["E5"], PITCH["A4"], n, _triangle)
sig = [0.7 * a[i] + 0.5 * b[i] for i in range(n)]
env = _envelope(n, 0.04, 0.30)
return _apply(sig, env)
def sfx_shield(rng):
"""Penguin shield — rising glass shimmer."""
n = _frames(0.32)
shimmer = _sweep(PITCH["A4"], PITCH["E6"], n, _triangle)
chime = _triangle(PITCH["C5"], n)
sig = [0.6 * shimmer[i] + 0.4 * chime[i] for i in range(n)]
env = _envelope(n, 0.02, 0.22)
return _apply(sig, env)
def sfx_volley(rng):
"""Elf volley — rapid double-tap chirp."""
parts = []
for f in (880, 1320):
n = _frames(0.07)
sig = _sweep(f, f * 1.6, n, _square)
env = _envelope(n, 0.005, 0.05)
parts.append(_apply(sig, env))
parts.append([0.0] * _frames(0.02))
return _concat(*parts)
def sfx_dash(rng):
"""Shiggy dash — quick whoosh."""
n = _frames(0.18)
sig = _sweep(180, 90, n, _triangle)
grit = _noise(n, rng)
mixed = [0.6 * sig[i] + 0.35 * grit[i] for i in range(n)]
env = _envelope(n, 0.005, 0.14)
return _apply(mixed, env)
def sfx_sprint(rng):
"""Wolf sprint — short rising whoosh that sustains."""
n = _frames(0.35)
sig = _sweep(140, 320, n, _triangle)
grit = _noise(n, rng)
mixed = [0.55 * sig[i] + 0.3 * grit[i] for i in range(n)]
env = _envelope(n, 0.04, 0.20)
return _apply(mixed, env)
def sfx_lever_click(rng):
"""Lever pull — two-click clack (engage + settle)."""
parts = []
for f in (640, 480):
n = _frames(0.04)
sig = _square(f, n)
env = _envelope(n, 0.001, 0.03)
parts.append(_apply(sig, env))
parts.append([0.0] * _frames(0.03))
return _concat(*parts)
def sfx_plate_press(rng):
"""Pressure plate — low compressed clunk."""
n = _frames(0.12)
sig = _square(110, n)
grit = _noise(n, rng)
mixed = [0.6 * sig[i] + 0.3 * grit[i] for i in range(n)]
env = _envelope(n, 0.002, 0.10)
return _apply(mixed, env)
def sfx_gate_open(rng):
"""Gate retracting — slow rising chord."""
n = _frames(0.45)
a = _sweep(PITCH["C4"], PITCH["G4"], n, _triangle)
b = _sweep(PITCH["E4"], PITCH["B4"], n, _triangle)
grit = _noise(_frames(0.45), rng)
sig = [0.55 * a[i] + 0.4 * b[i] + 0.2 * grit[i] for i in range(n)]
env = _envelope(n, 0.04, 0.30)
return _apply(sig, env)
def sfx_key_pickup(rng):
"""Key pickup — bright two-tone chime (C5 → G5)."""
parts = []
for note in ("C5", "G5"):
n = _frames(0.10)
sig = _triangle(PITCH[note], n)
env = _envelope(n, 0.003, 0.08)
parts.append(_apply(sig, env))
return _concat(*parts)
def sfx_menu_select(rng):
"""Menu navigation blip — single thin tick."""
n = _frames(0.05)
sig = _square(880, n)
env = _envelope(n, 0.001, 0.04)
return _apply(sig, env)
def sfx_menu_confirm(rng):
"""Menu confirm — short rising blip (G5 → C6)."""
parts = []
for note in ("G5", "C6"):
n = _frames(0.06)
sig = _square(PITCH[note], n)
env = _envelope(n, 0.002, 0.05)
parts.append(_apply(sig, env))
return _concat(*parts)
SFX = {
"shoot": (sfx_shoot, 0.45),
"hit": (sfx_hit, 0.55),
"boss_hit": (sfx_boss_hit, 0.60),
"enemy_death": (sfx_enemy_death, 0.55),
"boss_death": (sfx_boss_death, 0.70),
"player_death": (sfx_player_death, 0.50),
"level_complete": (sfx_level_complete, 0.55),
"slow": (sfx_slow, 0.55),
"shield": (sfx_shield, 0.55),
"volley": (sfx_volley, 0.55),
"dash": (sfx_dash, 0.55),
"sprint": (sfx_sprint, 0.55),
"lever_click": (sfx_lever_click, 0.50),
"plate_press": (sfx_plate_press, 0.55),
"gate_open": (sfx_gate_open, 0.55),
"key_pickup": (sfx_key_pickup, 0.55),
"menu_select": (sfx_menu_select, 0.40),
"menu_confirm": (sfx_menu_confirm, 0.45),
}
def main():
os.makedirs(SFX_DIR, exist_ok=True)
rng = random.Random(0xCAFE) # deterministic — rerun gives same bytes
for name, (fn, gain) in SFX.items():
samples = fn(rng)
_write(name, samples, gain=gain)
path = os.path.join(SFX_DIR, name + ".wav")
print(f" wrote {path} ({len(samples)} samples, "
f"{len(samples) / SAMPLE_RATE:.2f}s)")
if __name__ == "__main__":
main()