feat: sound

- add SoundManager class to handle bgm and sfx
- add script to generate wav sound assets
- integrate sfx for shop purchases and player transform
- add dynamic bgm switching based on player state
- add sound file paths and volume configurations to settings
This commit is contained in:
Berger Lena
2026-05-08 18:34:18 +02:00
parent b3923719fd
commit 16b8f26361
13 changed files with 129 additions and 22 deletions
+38
View File
@@ -0,0 +1,38 @@
import wave, struct, math, os, random
"""
run once to get sounds generated - KI helped here to do this!
"""
SOUND_DIR = "../assets/sounds"
def save_wav(filename, data):
if not os.path.exists(SOUND_DIR):
os.makedirs(SOUND_DIR)
path = os.path.join(SOUND_DIR, filename)
with wave.open(path, 'w') as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(44100)
for s in data:
# Den Soundwert in das richtige Format (16-bit PCM) umwandeln
f.writeframesraw(struct.pack('<h', int(s * 32767)))
def gen(dur, func, vol=0.4):
num_samples = int(dur * 44100)
return [func(i/44100, i) * vol * min(1.0, (num_samples-i)/(4410*dur)) for i in range(num_samples)]
print(f"Das Skript liegt in 'src', speichert aber nach: {os.path.abspath(SOUND_DIR)}")
save_wav("bgm_normal.wav", gen(10.0, lambda t, i: (math.sin(2*math.pi*110*t) + math.sin(2*math.pi*440*t)*0.2)/2, 0.2))
save_wav("bgm_creepy.wav", gen(10.0, lambda t, i: math.sin(2*math.pi*(60+5*math.sin(t))*t), 0.2))
save_wav("tower_shoot.wav", gen(0.15, lambda t, i: 1.0 if math.sin(2*math.pi*1200*(1-t*5)*t) > 0 else -1.0, 0.1))
save_wav("monster_hit.wav", gen(0.1, lambda t, i: random.uniform(-1, 1), 0.3))
save_wav("buy_success.wav", gen(0.3, lambda t, i: math.sin(2*math.pi*(660 if t<0.1 else 880)*t)))
save_wav("buy_fail.wav", gen(0.4, lambda t, i: math.sin(2*math.pi*120*t) * (1+math.sin(40*t))))
save_wav("monster_healed.wav", gen(0.8, lambda t, i: math.sin(2*math.pi*(440+800*t**2)*t)))
save_wav("monster_transform.wav", gen(1.2, lambda t, i: math.sin(2*math.pi*(200-150*t)*t) * random.uniform(0.5, 1.0)))
print("Fertig! Schau jetzt in deinem Hauptverzeichnis unter assets/sounds nach.")
+18 -21
View File
@@ -4,8 +4,6 @@ Controls:
- WASD / arrow keys: move Knoll around the map
- Space: switch between good/normal and bad/angry sprite
- Esc / Q: quit
The demo also places a few bad/angry actors as monsters in the rooms.
"""
from __future__ import annotations
@@ -19,15 +17,11 @@ from src.actors import Actor
from src.map import Map, color_void, screen_h, screen_w, tile_size
from src.settings import DEMO_ACTOR_HEIGHT, FPS, PLAYER_SPEED
from src.startscreen import Startscreen
# Keep the actor close to the map scale. The map tiles are 30x30, so 2 tiles
# high makes the character readable without covering whole rooms/walls.
from src.sounds import SoundManager
def tile_center(column: int, row: int) -> tuple[int, int]:
"""Return the pixel center of a map tile."""
return column * tile_size + tile_size // 2, row * tile_size + tile_size // 2
@@ -39,25 +33,26 @@ class Game:
self.font = pygame.font.SysFont("monospace", 22, bold=True)
self.running = True
self.active_monster = None
# --- SOUND & SYSTEMS ---
self.sound_manager = SoundManager()
self.startscreen = Startscreen(self.screen)
self.state = "startscreen"
self.player_name: str = ""
self.map = Map()
# resolve the assets from the folder
assets_dir = Path(__file__).resolve().parent.parent / "assets"
actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT)
if not actor_sides:
raise RuntimeError("No normal_*/angry_* actor sprite pairs found in assets/")
raise RuntimeError(f"Keine Sprites in {assets_dir} gefunden!")
# Use the first pair for the player. With current assets this is Knoll.
self.player = Actor.from_sides(actor_sides[0], self.find_start_position())
def run(self, max_frames: Optional[int] = None) -> None:
"""Run the demo. ``max_frames`` is only used for automated smoke tests."""
frame_count = 0
while self.running and (max_frames is None or frame_count < max_frames):
dt = min(self.clock.tick(FPS) / 1000.0, 0.05)
@@ -69,6 +64,8 @@ class Game:
elif next_state == "game":
self.player_name = self.startscreen.player_name
self.state = "game"
self.sound_manager.play_bgm("normal")
elif self.state == "game":
self.handle_events()
self.update(dt)
@@ -88,8 +85,16 @@ class Game:
self.running = False
elif event.key == pygame.K_SPACE:
self.player.toggle_side()
self.sound_manager.play_sfx("monster_transform")
def update(self, dt: float) -> None:
if self.player.is_bad:
self.active_monster = self.player
self.sound_manager.play_bgm("creepy")
else:
self.active_monster = None
self.sound_manager.play_bgm("normal")
movement = self.get_player_movement(dt)
self.player.move_by(movement)
@@ -105,7 +110,7 @@ class Game:
- (keys[pygame.K_LEFT] or keys[pygame.K_a]),
(keys[pygame.K_DOWN] or keys[pygame.K_s])
- (keys[pygame.K_UP] or keys[pygame.K_w]),
)
)
if movement.length_squared() == 0:
return movement
@@ -118,31 +123,23 @@ class Game:
return movement.normalize() * PLAYER_SPEED * dt
def is_position_walkable(self, position: pygame.Vector2) -> bool:
"""Check if the actor's feet are standing on a walkable map tile."""
column = int(position.x // tile_size)
row = int(position.y // tile_size)
return self.map.is_accessible(column, row)
def find_start_position(self) -> tuple[int, int]:
"""Find the first S tile and place the actor's feet near its center."""
for row_index, row in enumerate(self.map.map):
column_index = row.find("S")
if column_index != -1:
# The S tile is at the very top of the current map, so place the
# actor one tile lower to keep the sprite visible on screen.
return (
column_index * tile_size + tile_size // 2,
(row_index + 1) * tile_size + tile_size // 2,
)
return tile_size, tile_size
def draw(self) -> None:
self.screen.fill(color_void)
self.map.draw_map(self.screen, self.font)
# Draw by Y position so actors lower on the map appear in front.
for actor in sorted([self.player], key=lambda item: item.position.y):
actor.draw(self.screen)
+17
View File
@@ -120,3 +120,20 @@ HUD_BAR_X = 150 # left startpoint for hp bars
HUD_Y_DOOR = 20
HUD_Y_ZOMBIE = 50
HUD_Y_ECTS = 85
# --- SOUND ---
SOUND_DIR = "assets/sounds"
SFX_TOWER_SHOOT = f"{SOUND_DIR}/tower_shoot.wav"
SFX_MONSTER_HIT = f"{SOUND_DIR}/monster_hit.wav"
SFX_BUY_SUCCESS = f"{SOUND_DIR}/buy_success.wav"
SFX_BUY_FAIL = f"{SOUND_DIR}/buy_fail.wav"
SFX_MONSTER_HEALED = f"{SOUND_DIR}/monster_healed.wav"
SFX_MONSTER_TRANSFORM = f"{SOUND_DIR}/monster_transform.wav"
BGM_NORMAL = f"{SOUND_DIR}/bgm_normal.wav"
BGM_CREEPY = f"{SOUND_DIR}/bgm_creepy.wav"
VOLUME_SFX = 0.5
VOLUME_BGM = 0.3
+5 -1
View File
@@ -2,9 +2,11 @@ from src.item import Item
class Shop:
def __init__(self, ects, buyable_items):
def __init__(self, ects, buyable_items, sound_manager):
self.ects = ects # give the ects object to the shop
self.buyable_items: list[Item] = buyable_items
# save soundManager to use it in buy()
self.sound_manager = sound_manager
def buy(self, item_name):
item_to_buy = self.get_item(item_name)
@@ -15,9 +17,11 @@ class Shop:
# is_over_balance() means "has enough balance"
if not self.ects.is_over_balance(cost):
self.sound_manager.play_sfx("buy_fail")
return False
self.ects.decrease_balance(cost)
self.sound_manager.play_sfx("buy_success")
return True
def get_item(self, item_name):
+51
View File
@@ -0,0 +1,51 @@
import pygame
from src.settings import (
SFX_TOWER_SHOOT, SFX_MONSTER_HIT, SFX_BUY_SUCCESS,
SFX_BUY_FAIL, SFX_MONSTER_HEALED, SFX_MONSTER_TRANSFORM,
BGM_NORMAL, BGM_CREEPY, VOLUME_SFX, VOLUME_BGM
)
class SoundManager:
def __init__(self):
pygame.mixer.init()
self.effects = {}
self.current_bgm = None
self._load_sfx("tower_shoot", SFX_TOWER_SHOOT)
self._load_sfx("monster_hit", SFX_MONSTER_HIT)
self._load_sfx("buy_success", SFX_BUY_SUCCESS)
self._load_sfx("buy_fail", SFX_BUY_FAIL)
self._load_sfx("monster_healed", SFX_MONSTER_HEALED)
self._load_sfx("monster_transform", SFX_MONSTER_TRANSFORM)
# set volume via settings param
for sound in self.effects.values():
if sound:
sound.set_volume(VOLUME_SFX)
def _load_sfx(self, name, path):
try:
self.effects[name] = pygame.mixer.Sound(path)
except pygame.error:
print(f"Could not find sound: {path}")
self.effects[name] = None
def play_sfx(self, name):
sound = self.effects.get(name)
if isinstance(sound, pygame.mixer.Sound):
sound.play()
# set BackGroundMusic dependent if monster is active or tutor is active
def play_bgm(self, mode):
if self.current_bgm == mode:
return
path = BGM_NORMAL if mode == "normal" else BGM_CREEPY
try:
pygame.mixer.music.load(path)
pygame.mixer.music.set_volume(VOLUME_BGM)
pygame.mixer.music.play(-1)
self.current_bgm = mode
except pygame.error:
print(f"Could not find sound: {path}")