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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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.")
+17 -20
View File
@@ -4,8 +4,6 @@ Controls:
- WASD / arrow keys: move Knoll around the map - WASD / arrow keys: move Knoll around the map
- Space: switch between good/normal and bad/angry sprite - Space: switch between good/normal and bad/angry sprite
- Esc / Q: quit - Esc / Q: quit
The demo also places a few bad/angry actors as monsters in the rooms.
""" """
from __future__ import annotations 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.map import Map, color_void, screen_h, screen_w, tile_size
from src.settings import DEMO_ACTOR_HEIGHT, FPS, PLAYER_SPEED from src.settings import DEMO_ACTOR_HEIGHT, FPS, PLAYER_SPEED
from src.startscreen import Startscreen from src.startscreen import Startscreen
from src.sounds import SoundManager
# 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.
def tile_center(column: int, row: int) -> tuple[int, int]: def tile_center(column: int, row: int) -> tuple[int, int]:
"""Return the pixel center of a map tile.""" """Return the pixel center of a map tile."""
return column * tile_size + tile_size // 2, row * tile_size + tile_size // 2 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.font = pygame.font.SysFont("monospace", 22, bold=True)
self.running = True self.running = True
self.active_monster = None
# --- SOUND & SYSTEMS ---
self.sound_manager = SoundManager()
self.startscreen = Startscreen(self.screen) self.startscreen = Startscreen(self.screen)
self.state = "startscreen" self.state = "startscreen"
self.player_name: str = "" self.player_name: str = ""
self.map = Map() self.map = Map()
# resolve the assets from the folder
assets_dir = Path(__file__).resolve().parent.parent / "assets" assets_dir = Path(__file__).resolve().parent.parent / "assets"
actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT) actor_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT)
if not actor_sides: 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()) self.player = Actor.from_sides(actor_sides[0], self.find_start_position())
def run(self, max_frames: Optional[int] = None) -> None: def run(self, max_frames: Optional[int] = None) -> None:
"""Run the demo. ``max_frames`` is only used for automated smoke tests."""
frame_count = 0 frame_count = 0
while self.running and (max_frames is None or frame_count < max_frames): while self.running and (max_frames is None or frame_count < max_frames):
dt = min(self.clock.tick(FPS) / 1000.0, 0.05) dt = min(self.clock.tick(FPS) / 1000.0, 0.05)
@@ -69,6 +64,8 @@ class Game:
elif next_state == "game": elif next_state == "game":
self.player_name = self.startscreen.player_name self.player_name = self.startscreen.player_name
self.state = "game" self.state = "game"
self.sound_manager.play_bgm("normal")
elif self.state == "game": elif self.state == "game":
self.handle_events() self.handle_events()
self.update(dt) self.update(dt)
@@ -88,8 +85,16 @@ class Game:
self.running = False self.running = False
elif event.key == pygame.K_SPACE: elif event.key == pygame.K_SPACE:
self.player.toggle_side() self.player.toggle_side()
self.sound_manager.play_sfx("monster_transform")
def update(self, dt: float) -> None: 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) movement = self.get_player_movement(dt)
self.player.move_by(movement) self.player.move_by(movement)
@@ -118,31 +123,23 @@ class Game:
return movement.normalize() * PLAYER_SPEED * dt return movement.normalize() * PLAYER_SPEED * dt
def is_position_walkable(self, position: pygame.Vector2) -> bool: 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) column = int(position.x // tile_size)
row = int(position.y // tile_size) row = int(position.y // tile_size)
return self.map.is_accessible(column, row) return self.map.is_accessible(column, row)
def find_start_position(self) -> tuple[int, int]: 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): for row_index, row in enumerate(self.map.map):
column_index = row.find("S") column_index = row.find("S")
if column_index != -1: 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 ( return (
column_index * tile_size + tile_size // 2, column_index * tile_size + tile_size // 2,
(row_index + 1) * tile_size + tile_size // 2, (row_index + 1) * tile_size + tile_size // 2,
) )
return tile_size, tile_size return tile_size, tile_size
def draw(self) -> None: def draw(self) -> None:
self.screen.fill(color_void) self.screen.fill(color_void)
self.map.draw_map(self.screen, self.font) 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): for actor in sorted([self.player], key=lambda item: item.position.y):
actor.draw(self.screen) 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_DOOR = 20
HUD_Y_ZOMBIE = 50 HUD_Y_ZOMBIE = 50
HUD_Y_ECTS = 85 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: 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.ects = ects # give the ects object to the shop
self.buyable_items: list[Item] = buyable_items self.buyable_items: list[Item] = buyable_items
# save soundManager to use it in buy()
self.sound_manager = sound_manager
def buy(self, item_name): def buy(self, item_name):
item_to_buy = self.get_item(item_name) item_to_buy = self.get_item(item_name)
@@ -15,9 +17,11 @@ class Shop:
# is_over_balance() means "has enough balance" # is_over_balance() means "has enough balance"
if not self.ects.is_over_balance(cost): if not self.ects.is_over_balance(cost):
self.sound_manager.play_sfx("buy_fail")
return False return False
self.ects.decrease_balance(cost) self.ects.decrease_balance(cost)
self.sound_manager.play_sfx("buy_success")
return True return True
def get_item(self, item_name): 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}")