Merge branch 'main' into 'feat/tower'

This commit is contained in:
Haiden Niklas
2026-05-08 17:06:10 +00:00
20 changed files with 246 additions and 56 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

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.")
+7
View File
@@ -2,6 +2,7 @@ import pygame
from src.settings import START_ECTS_VALUE, ECTS_UPDATE_INTERVAL
class ECTS:
def __init__(self):
# total amount of ECTS the player has at the beginning
@@ -24,3 +25,9 @@ class ECTS:
# beer increases amount of how much ECTS are earned per second
def upgrade_rate(self, extra_ects):
self.ects_per_second += extra_ects
def is_over_balance(self, amount):
return self.ects_balance >= amount
def decrease_balance(self, amount):
self.ects_balance -= amount
+7
View File
@@ -8,6 +8,8 @@ from settings import (COLOR_ENDSCREEN_BACKGROUND,
COLOR_BUTTON,
FONT_ENDSCREEN)
from src.highscore import Highscore
class Endscreen:
def __init__(self, screen, player_name, score_ects, time_survived, won):
self.screen = screen
@@ -52,6 +54,8 @@ class Endscreen:
self.button_hover_color
)
self.highscore = Highscore()
def run(self):
# get events
events = pygame.event.get()
@@ -113,6 +117,9 @@ class Endscreen:
restart_pressed = self.button_restart.draw(self.screen)
menu_pressed = self.button_menu.draw(self.screen)
# draw highscore table
self.highscore.draw(self.screen, 800, 300)
# check button click events
if restart_pressed:
return "game"
+18 -24
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,12 +64,13 @@ 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)
self.draw()
pygame.display.flip()
frame_count += 1
@@ -89,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)
@@ -106,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
@@ -119,33 +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)
+45 -1
View File
@@ -1,9 +1,14 @@
import json
import pygame
from src.settings import HIGHSCORE_COLOR_FONT, HIGHSCORE_COLOR_TABLE
class Highscore:
def __init__(self):
# fix path to json file with highscores
self.path = "./highscore.json"
self.path = "src/highscore.json"
self.font_title = pygame.font.SysFont("Arial", 28, bold=True)
self.font_score = pygame.font.SysFont("Arial", 18, bold=True)
# check if file already exists, otherwise create it
try:
@@ -90,3 +95,42 @@ class Highscore:
formatted_scores.append(line)
return formatted_scores
def draw(self, screen, x, y):
# draw box background
box_rect = pygame.Rect(x, y, 500, 300)
pygame.draw.rect(screen, HIGHSCORE_COLOR_TABLE, box_rect, border_radius=12)
pygame.draw.rect(screen, HIGHSCORE_COLOR_TABLE, box_rect, border_radius=12)
# title
title = self.font_title.render(
"ECTS Leaderboard",
True,
HIGHSCORE_COLOR_FONT
)
screen.blit(title, (x + 20, y + 20))
# Get highscore lines
highscore_lines = self.get_top_scores_formatted()
if len(highscore_lines) == 0:
no_scores_text = self.font_score.render(
"No students ranked yet.",
True,
HIGHSCORE_COLOR_FONT
)
screen.blit(no_scores_text, (x + 20, y + 70))
return
# Draw scores
score_y = y + 70
for line in highscore_lines:
score_text = self.font_score.render(
line,
True,
HIGHSCORE_COLOR_FONT
)
screen.blit(score_text, (x + 20, score_y))
score_y += 35
+4
View File
@@ -11,6 +11,7 @@ class Item:
self.x = x
self.y = y
self.item_type = item_type
self.cost = random.randint(5, 10)
# collision rect for player (2 times item size for width and height)
self.rect = pygame.Rect(self.x, self.y, ITEM_SIZE, ITEM_SIZE)
@@ -50,6 +51,9 @@ class Item:
pygame.draw.rect(screen, color, draw_rect)
pygame.draw.rect(screen, COLOR_HP_BAR_BG, draw_rect, 2)
def get_cost(self):
return self.cost
# try 50 times to find a free position to spawn item
def _random_position(room_rect, blocked_rects):
+23 -3
View File
@@ -1,7 +1,7 @@
from typing import Mapping, Literal, Tuple
from src.map import tile_size
from tower import projectile_size
# Fenstergröße und Tile Größe (Felder)
tile_size = 30
# --- DESK ---
DESK_WIDTH = 70
@@ -127,7 +127,6 @@ HUD_Y_DOOR = 20
HUD_Y_ZOMBIE = 50
HUD_Y_ECTS = 85
# --- MAP ---
# Fenstergröße und Tile Größe (Felder)
tile_size = 30
@@ -143,3 +142,24 @@ color_void = (10, 10, 15) # a little more black
color_text = (220, 180, 210) # rose
color_border = (55, 35, 70)
color_start = (255, 0, 0)# dark violet
=======
# --- HIGHSCORE ---
HIGHSCORE_COLOR_FONT = (0, 0, 0)
HIGHSCORE_COLOR_TABLE = (150, 255, 0)
# --- 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
+34
View File
@@ -0,0 +1,34 @@
from src.item import Item
class Shop:
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)
if item_to_buy is None:
return False
cost = item_to_buy.get_cost()
# 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):
for item in self.buyable_items:
if item.item_type == item_name:
return item
return None
def get_items(self):
return self.buyable_items
+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}")
+6
View File
@@ -8,6 +8,7 @@ from src.settings import (
COLOR_STARTSCREEN_FONT_LABEL,
)
from src.text_input import TextInput
from src.highscore import Highscore
class Startscreen:
def __init__(self, screen):
@@ -42,6 +43,8 @@ class Startscreen:
20,
310)
self.highscore = Highscore()
def run(self):
# set background color
self.screen.fill(COLOR_STARTSCREEN_BACKGROUND)
@@ -70,6 +73,9 @@ class Startscreen:
label_input_error = self.font_label.render("Student not found. Please enter valid name/id.", True,
(255, 0, 0))
# draw highscore table
self.highscore.draw(self.screen, 560, 90)
# get all events
events = pygame.event.get()
+10 -25
View File
@@ -1,5 +1,3 @@
from turtledemo.lindenmayer import draw
import pygame
import random
import math
@@ -13,7 +11,9 @@ respawn_delay = 10000 # ms bis neues Feld spawnt
class Tower:
def __init__(self) -> None:
def __init__(self, sound_manager) -> None:
self.sound_manager = sound_manager
# Feld und Tower -> None wenn nicht aktiv
# Position interagierbares Feld
self.field_rect: pygame.Rect = None
@@ -24,7 +24,7 @@ class Tower:
# Zeitpunkt, wann der Tower gebaut wurde
self.tower_start: int = None
# Zeitpunkt, wann Tower despawnt ist
self.despawn_timer: int = None
self.despawn_time: int = None
# Zeitpunkt des letzten Schusses
self.last_shot: int = 0
@@ -65,9 +65,11 @@ class Tower:
return False
# prüft alle Bedingungen gleichzeitig
if not (self.field_rect.colliderect(player_rect) and keys[pygame.K_e] and ects.balance >= TOWER_COST):
self.sound_manager.play_sfx("buy_fail")
return False
# ECTS abziehen und Tower bauen
self.sound_manager.play_sfx("buy_success")
ects.balance -= TOWER_COST
self.tower_rect = self.field_rect
self.field_rect = None
@@ -100,6 +102,7 @@ class Tower:
"vel_y": (dy / distance) * PROJECTILE_SPEED,
})
self.sound_manager.play_sfx("tower_shoot")
self.last_shot = now
# Bewegung der Projektile und Kollision mit Monster prüfen
@@ -110,6 +113,7 @@ class Tower:
# Projektil trifft auf Monster -> Schaden wird addiert und Projektil entfernt
if monster_rect and pygame.Rect(int(p["x"]), int(p["y"]), projectile_size, projectile_size).colliderect(monster_rect):
total_damage += PROJECTILE_DMG
self.sound_manager.play_sfx("monster_hit")
self.projectiles.remove(p)
# wenn der Tower abgelaufen ist, soll er despawnen und ein Timer starten
@@ -165,8 +169,7 @@ class Tower:
else:
# Fallback - gelbes Rechteck
pygame.draw.rect(surface, COLOR_PROJECTILE,
pygame.Rect(int(p["x"]) - cam_x, int(p["y"]) - cam_y, projectile_size, projectile_size))
pygame.Rect(draw_x, draw_y, projectile_size, projectile_size))
@@ -175,22 +178,4 @@ class Tower:
self.field_rect = None
self.tower_rect = None
self.projectiles = []
self.despawn_timer = None
self.despawn_time = None