Merge branch 'feat/monsters' into 'main'

feat: implement game loop

See merge request swd25-bootcamp/silent-fh!28
This commit is contained in:
Haiden Niklas
2026-05-08 17:23:38 +00:00
8 changed files with 890 additions and 202 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import pygame import pygame
from button import Button from src.button import Button
from settings import (COLOR_ENDSCREEN_BACKGROUND, from src.settings import (COLOR_ENDSCREEN_BACKGROUND,
COLOR_ENDSCREEN_TEXT, COLOR_ENDSCREEN_TEXT,
COLOR_ENDSCREEN_WIN, COLOR_ENDSCREEN_WIN,
COLOR_ENDSCREEN_LOSE, COLOR_ENDSCREEN_LOSE,
+279 -10
View File
@@ -1,8 +1,8 @@
"""Small playable demo that uses ``src.map.Map`` and the actor system. """Small playable demo that uses ``src.map.Map`` and the actor system.
Controls: Controls:
- WASD / arrow keys: move Knoll around the map - WASD / arrow keys: move Niklas around the map
- Space: switch between good/normal and bad/angry sprite - Space: toggle player state
- Esc / Q: quit - Esc / Q: quit
""" """
@@ -14,10 +14,27 @@ from typing import Optional
import pygame import pygame
from src.actors import Actor from src.actors import Actor
from src.door import Door
from src.ects import ECTS
from src.endscreen import Endscreen
from src.hud import HUD
from src.item import Item, ItemManager
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.monster import MonsterManager
from src.settings import (
BEER_BOTTLE_ECTS_VALUE,
COFFEE_HEAL_VALUE,
DEMO_ACTOR_HEIGHT,
DOOR_HEIGHT,
DOOR_HP,
DOOR_WIDTH,
FPS,
PLAYER_SPEED,
)
from src.shop import Shop
from src.startscreen import Startscreen from src.startscreen import Startscreen
from src.sounds import SoundManager from src.sounds import SoundManager
from src.tower import Tower
def tile_center(column: int, row: int) -> tuple[int, int]: def tile_center(column: int, row: int) -> tuple[int, int]:
@@ -31,26 +48,45 @@ class Game:
self.screen = pygame.display.set_mode((screen_w, screen_h)) self.screen = pygame.display.set_mode((screen_w, screen_h))
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("monospace", 22, bold=True) self.font = pygame.font.SysFont("monospace", 22, bold=True)
self.item_font = pygame.font.SysFont("Arial", 16, bold=True)
self.running = True self.running = True
self.active_monster = None self.active_monster = None
self.buy_requested = False
# --- SOUND & SYSTEMS --- # --- SOUND & SYSTEMS ---
self.sound_manager = SoundManager() self.sound_manager = SoundManager()
self.startscreen = Startscreen(self.screen) self.startscreen = Startscreen(self.screen)
self.hud = HUD()
self.ects = ECTS()
self.item_manager = ItemManager()
self.shop = Shop(self.ects, self.item_manager.items, self.sound_manager)
self.tower = Tower(self.sound_manager)
self.state = "startscreen" self.state = "startscreen"
self.player_name: str = "" self.player_name: str = ""
self.game_start_time = 0
self.endscreen: Optional[Endscreen] = None
self.current_item_room_key: Optional[tuple[int, int, int, int]] = None
self.map = Map() self.map = Map()
self.active_door: Optional[Door] = None
self.doors_by_room: dict[tuple[int, int, int, int], Door] = {}
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) player_asset = Actor.load_sprite_asset(
assets_dir / "niklas_sprites",
target_height=DEMO_ACTOR_HEIGHT,
)
self.player = Actor(
name="Niklas",
good_asset=player_asset,
bad_asset=player_asset,
position=self.find_start_position(),
)
if not actor_sides: monster_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT)
raise RuntimeError(f"Keine Sprites in {assets_dir} gefunden!") self.monsters = MonsterManager(monster_sides, self.map)
self.monsters.spawn_initial(self.get_monster_spawn_blockers())
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:
frame_count = 0 frame_count = 0
@@ -63,6 +99,7 @@ class Game:
self.running = False self.running = False
elif next_state == "game": elif next_state == "game":
self.player_name = self.startscreen.player_name self.player_name = self.startscreen.player_name
self.game_start_time = pygame.time.get_ticks()
self.state = "game" self.state = "game"
self.sound_manager.play_bgm("normal") self.sound_manager.play_bgm("normal")
@@ -70,6 +107,14 @@ class Game:
self.handle_events() self.handle_events()
self.update(dt) self.update(dt)
self.draw() self.draw()
elif self.state == "endscreen":
next_state = self.endscreen.run() if self.endscreen is not None else "startscreen"
if next_state == "quit":
self.running = False
elif next_state == "startscreen":
self.state = "startscreen"
elif next_state == "game":
self.restart_game()
pygame.display.flip() pygame.display.flip()
frame_count += 1 frame_count += 1
@@ -86,6 +131,8 @@ class Game:
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") self.sound_manager.play_sfx("monster_transform")
elif event.key == pygame.K_e:
self.buy_requested = True
def update(self, dt: float) -> None: def update(self, dt: float) -> None:
if self.player.is_bad: if self.player.is_bad:
@@ -102,6 +149,17 @@ class Game:
self.player.hit_wall() self.player.hit_wall()
self.player.update_animation(dt) self.player.update_animation(dt)
self.ects.update(self.is_player_at_desk())
self.handle_room_table_collision()
self.update_items()
self.monsters.update(
dt,
self.player.position,
self.is_position_walkable,
self.get_monster_spawn_blockers(),
)
self.update_tower()
self.check_end_conditions()
def get_player_movement(self, dt: float) -> pygame.Vector2: def get_player_movement(self, dt: float) -> pygame.Vector2:
keys = pygame.key.get_pressed() keys = pygame.key.get_pressed()
@@ -127,6 +185,195 @@ class Game:
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 is_player_at_desk(self) -> bool:
"""Return True while the player's hitbox touches a desk in the current room."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
if room_rect is None:
return False
return any(desk.check_collision(player_rect) for desk in self.map.get_desks_for_room(room_rect))
def update_tower(self) -> None:
"""Spawn a tower when enough ECTS are available and let it attack the zombie."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
monster = self.monsters.monsters[0] if self.monsters.monsters else None
if monster is None or not monster.is_alive:
self.tower.clear()
return
damage = self.tower.update(
room_rect,
self.get_tower_spawn_blockers(),
self.ects,
monster.rect,
)
if monster is not None and damage > 0:
monster.take_damage(damage)
if not monster.is_alive:
self.tower.clear()
def update_items(self) -> None:
"""Spawn room items and process one player purchase request."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
room_key = self.get_room_key(room_rect) if room_rect is not None else None
if room_key != self.current_item_room_key:
self.item_manager.clear()
self.current_item_room_key = room_key
self.item_manager.update(room_rect, self.get_item_spawn_blockers())
if self.buy_requested:
self.try_buy_touching_item(player_rect)
self.buy_requested = False
def try_buy_touching_item(self, player_rect: pygame.Rect) -> None:
item = self.item_manager.get_touching_item(player_rect)
if item is None:
return
if self.shop.buy_item(item):
self.apply_item_effect(item)
self.item_manager.remove_item(item)
def apply_item_effect(self, item: Item) -> None:
if item.item_type == "beer":
self.ects.upgrade_rate(BEER_BOTTLE_ECTS_VALUE)
elif item.item_type == "coffee" and self.active_door is not None:
self.active_door.current_hp = min(
self.active_door.max_hp,
self.active_door.current_hp + COFFEE_HEAL_VALUE,
)
def check_end_conditions(self) -> None:
if self.active_door is not None and self.active_door.current_hp <= 0:
self.show_endscreen(won=False)
def show_endscreen(self, won: bool) -> None:
self.endscreen = Endscreen(
self.screen,
self.player_name,
self.ects.ects_balance,
self.get_elapsed_seconds(),
won,
)
self.state = "endscreen"
def restart_game(self) -> None:
self.ects = ECTS()
self.shop = Shop(self.ects, self.item_manager.items, self.sound_manager)
self.item_manager.clear()
self.current_item_room_key = None
self.tower.clear()
self.active_door = None
self.doors_by_room = {}
self.player.set_position(self.find_start_position())
self.monsters.clear()
self.monsters.spawn_initial(self.get_monster_spawn_blockers())
self.game_start_time = pygame.time.get_ticks()
self.endscreen = None
self.state = "game"
def handle_room_table_collision(self) -> None:
"""Close/draw the room door and wake monsters when player hits a desk."""
player_rect = self.get_actor_rect(self.player)
room_rect = self.map.get_current_room(player_rect)
if room_rect is None:
return
for desk in self.map.get_desks_for_room(room_rect):
if desk.check_collision(player_rect):
door = self.get_or_create_door_for_room(room_rect)
if door.current_hp > 0:
door.is_closed = True
self.active_door = door
self.monsters.activate_for_room(room_rect, door)
return
def get_or_create_door_for_room(self, room_rect: pygame.Rect) -> Door:
key = self.get_room_key(room_rect)
if key not in self.doors_by_room:
self.doors_by_room[key] = self.create_door_for_room(room_rect)
return self.doors_by_room[key]
def create_door_for_room(self, room_rect: pygame.Rect) -> Door:
"""Create a Door object centered on the map's D tiles for this room."""
door_tiles: list[pygame.Rect] = []
for row_index, row in enumerate(self.map.map):
for column_index, tile in enumerate(row):
if tile != "D":
continue
tile_rect = pygame.Rect(
column_index * tile_size,
row_index * tile_size,
tile_size,
tile_size,
)
if (
room_rect.left <= tile_rect.centerx <= room_rect.right
and room_rect.bottom <= tile_rect.centery <= room_rect.bottom + 2 * tile_size
):
door_tiles.append(tile_rect)
if not door_tiles:
return Door(room_rect.centerx - DOOR_WIDTH // 2, room_rect.bottom, DOOR_HP)
door_area = door_tiles[0].copy()
for tile_rect in door_tiles[1:]:
door_area.union_ip(tile_rect)
return Door(
door_area.centerx - DOOR_WIDTH // 2,
door_area.centery - DOOR_HEIGHT // 2,
DOOR_HP,
)
def get_monster_spawn_blockers(self) -> list[pygame.Rect]:
"""Rects that monsters should not spawn on."""
blockers = [self.get_actor_rect(self.player)]
for desk_list in self.map.desks.values():
blockers.extend(desk.rect for desk in desk_list)
return blockers
def get_tower_spawn_blockers(self) -> list[pygame.Rect]:
"""Rects that towers should not spawn on."""
blockers = self.get_monster_spawn_blockers()
if self.active_door is not None:
blockers.append(pygame.Rect(self.active_door.x, self.active_door.y, DOOR_WIDTH, DOOR_HEIGHT))
for monster in self.monsters.monsters:
blockers.append(monster.rect)
blockers.extend(item.rect for item in self.item_manager.items)
return blockers
def get_item_spawn_blockers(self) -> list[pygame.Rect]:
"""Rects that room items should not spawn on."""
blockers = self.get_tower_spawn_blockers()
if self.tower.tower_rect is not None:
blockers.append(self.tower.tower_rect)
blockers.extend(item.rect for item in self.item_manager.items)
return blockers
@staticmethod
def get_room_key(room_rect: pygame.Rect) -> tuple[int, int, int, int]:
return room_rect.x, room_rect.y, room_rect.width, room_rect.height
@staticmethod
def get_actor_rect(actor: Actor) -> pygame.Rect:
frame = actor.get_frame()
return frame.get_rect(midbottom=(round(actor.position.x), round(actor.position.y)))
def find_start_position(self) -> tuple[int, int]: def find_start_position(self) -> tuple[int, int]:
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")
@@ -137,9 +384,31 @@ class Game:
) )
return tile_size, tile_size return tile_size, tile_size
def get_elapsed_seconds(self) -> int:
if self.game_start_time == 0:
return 0
return (pygame.time.get_ticks() - self.game_start_time) // 1000
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)
for actor in sorted([self.player], key=lambda item: item.position.y): if self.active_door is not None and self.active_door.is_closed:
self.active_door.draw(self.screen)
self.item_manager.draw(self.screen, 0, 0, self.item_font)
self.tower.draw(self.screen, self.font)
# Draw by Y position so actors lower on the map appear in front.
actors = [self.player, *self.monsters.actors]
for actor in sorted(actors, key=lambda item: item.position.y):
actor.draw(self.screen) actor.draw(self.screen)
self.hud.draw(
self.screen,
self.player_name,
self.active_door,
self.monsters.monsters[0] if self.monsters.monsters else None,
self.ects,
self.get_elapsed_seconds(),
)
+45 -15
View File
@@ -3,20 +3,20 @@ from src.settings import (
HUD_FONT, HUD_FONT_SIZE, HUD_FONT_BOLD, HUD_FONT, HUD_FONT_SIZE, HUD_FONT_BOLD,
COLOR_HUD_TEXT, COLOR_HP_BAR_GREEN, COLOR_HP_BAR_RED, COLOR_HP_BAR_BG, COLOR_HUD_TEXT, COLOR_HP_BAR_GREEN, COLOR_HP_BAR_RED, COLOR_HP_BAR_BG,
HUD_BAR_WIDTH, HUD_BAR_HEIGHT, HUD_BAR_WIDTH, HUD_BAR_HEIGHT,
HUD_X_OFFSET, HUD_BAR_X, HUD_Y_DOOR, HUD_Y_ZOMBIE, HUD_Y_ECTS
) )
# draw status bar (background bar and the colored hp bar)
def _draw_status_bar(screen, x, y, current, max_val, color): def _draw_status_bar(screen, x, y, current, max_val, color):
# bg bar is always the same # bg bar is always the same
pygame.draw.rect(screen, COLOR_HP_BAR_BG, (x, y, HUD_BAR_WIDTH, HUD_BAR_HEIGHT)) pygame.draw.rect(screen, COLOR_HP_BAR_BG, (x, y, HUD_BAR_WIDTH, HUD_BAR_HEIGHT))
# hp bar # hp bar
if max_val > 0: if max_val > 0:
hp_width = (current / max_val) * HUD_BAR_WIDTH current = max(0, min(current, max_val))
pygame.draw.rect((screen, color), (x, y, hp_width, HUD_BAR_HEIGHT)) hp_width = int((current / max_val) * HUD_BAR_WIDTH)
pygame.draw.rect(screen, color, (x, y, hp_width, HUD_BAR_HEIGHT))
# time formatter for MM:SS
def _format_time(total_seconds): def _format_time(total_seconds):
mins = total_seconds // 60 mins = total_seconds // 60
secs = total_seconds % 60 secs = total_seconds % 60
@@ -26,22 +26,52 @@ def _format_time(total_seconds):
class HUD: class HUD:
def __init__(self): def __init__(self):
self.font = pygame.font.SysFont(HUD_FONT, HUD_FONT_SIZE, bold=HUD_FONT_BOLD) self.font = pygame.font.SysFont(HUD_FONT, HUD_FONT_SIZE, bold=HUD_FONT_BOLD)
self.small_font = pygame.font.SysFont(HUD_FONT, max(16, HUD_FONT_SIZE - 4), bold=False)
def draw(self, screen, player_name, door, zombie, ects, total_time):
"""Draw the game HUD at the bottom of the screen."""
panel_height = 86
panel_rect = pygame.Rect(0, screen.get_height() - panel_height, screen.get_width(), panel_height)
pygame.draw.rect(screen, (10, 10, 15), panel_rect)
pygame.draw.rect(screen, (70, 70, 80), panel_rect, 2)
top = panel_rect.top + 12
label_y = top
bar_y = top + 34
# --- PLAYER ---
player_text = f"PLAYER: {player_name or 'Student'}"
screen.blit(self.font.render(player_text, True, COLOR_HUD_TEXT), (20, label_y))
def draw(self, screen, door, zombie, ects, total_time):
# --- DOOR HP --- # --- DOOR HP ---
_draw_status_bar(screen, HUD_BAR_X, HUD_Y_DOOR, door.current_hp, COLOR_HP_BAR_GREEN) door_x = 330
# -5 px, small off-set to align the text better with hp bar screen.blit(self.font.render("DOOR HP", True, COLOR_HUD_TEXT), (door_x, label_y))
screen.blit(self.font.render("DOOR HP ", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_DOOR - 5)) if door is not None:
_draw_status_bar(screen, door_x, bar_y, door.current_hp, door.max_hp, COLOR_HP_BAR_GREEN)
hp_text = f"{door.current_hp}/{door.max_hp}"
else:
_draw_status_bar(screen, door_x, bar_y, 0, 1, COLOR_HP_BAR_GREEN)
hp_text = "--/--"
screen.blit(self.small_font.render(hp_text, True, COLOR_HUD_TEXT), (door_x + HUD_BAR_WIDTH + 10, bar_y - 2))
# --- ZOMBIE HP --- # --- ZOMBIE HP ---
if zombie: zombie_x = 620
_draw_status_bar(screen, HUD_BAR_X, HUD_Y_ZOMBIE, zombie.current_hp, zombie.max_hp, COLOR_HP_BAR_RED) screen.blit(self.font.render("ZOMBIE HP", True, COLOR_HUD_TEXT), (zombie_x, label_y))
screen.blit(self.font.render("Zombie HP:", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ZOMBIE - 5)) if zombie is not None:
_draw_status_bar(screen, zombie_x, bar_y, zombie.current_hp, zombie.max_hp, COLOR_HP_BAR_RED)
zombie_text = f"{zombie.current_hp}/{zombie.max_hp}"
else:
_draw_status_bar(screen, zombie_x, bar_y, 0, 1, COLOR_HP_BAR_RED)
zombie_text = "--/--"
screen.blit(self.small_font.render(zombie_text, True, COLOR_HUD_TEXT), (zombie_x + HUD_BAR_WIDTH + 10, bar_y - 2))
# --- ECTS --- # --- ECTS ---
ects_info = f"ECTS: {ects.ects_balance} ({ects.ects_per_second}/s)" ects_x = 910
screen.blit(self.font.render(ects_info, True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ECTS)) ects_text = f"ECTS: {ects.ects_balance} ({ects.ects_per_second}/s)"
screen.blit(self.font.render(ects_text, True, COLOR_HUD_TEXT), (ects_x, label_y))
# --- TIME --- # --- TIME ---
time_str = _format_time(total_time) time_str = _format_time(total_time)
screen.blit(self.font.render(time_str, True, COLOR_HUD_TEXT), (screen.get_width() - 150, HUD_Y_DOOR - 5)) time_surface = self.font.render(time_str, True, COLOR_HUD_TEXT)
screen.blit(time_surface, (screen.get_width() - time_surface.get_width() - 20, label_y))
+27 -14
View File
@@ -1,17 +1,27 @@
import pygame import pygame
import random import random
from src.settings import ( from src.settings import (
ITEM_SPAWN_INTERVAL, ITEM_SHOPPING_TIMER, ITEM_SIZE, BEER_COST,
COLOR_HP_BAR_YELLOW, COLOR_HP_BAR_BG COFFEE_COST,
ITEM_SPAWN_INTERVAL,
ITEM_SHOPPING_TIMER,
ITEM_SIZE,
COLOR_HP_BAR_YELLOW,
COLOR_HP_BAR_BG,
) )
ITEM_COSTS = {
"beer": BEER_COST,
"coffee": COFFEE_COST,
}
class Item: class Item:
def __init__(self, x, y, item_type): def __init__(self, x, y, item_type):
self.x = x self.x = x
self.y = y self.y = y
self.item_type = item_type self.item_type = item_type
self.cost = random.randint(5, 10) self.cost = ITEM_COSTS.get(item_type, random.randint(5, 10))
# collision rect for player (2 times item size for width and height) # collision rect for player (2 times item size for width and height)
self.rect = pygame.Rect(self.x, self.y, ITEM_SIZE, ITEM_SIZE) self.rect = pygame.Rect(self.x, self.y, ITEM_SIZE, ITEM_SIZE)
@@ -33,7 +43,7 @@ class Item:
return pygame.time.get_ticks() - self.spawn_time >= ITEM_SHOPPING_TIMER return pygame.time.get_ticks() - self.spawn_time >= ITEM_SHOPPING_TIMER
# cam offset to convert world coordinates to screen coordinates # cam offset to convert world coordinates to screen coordinates
def draw(self, screen, cam_x, cam_y): def draw(self, screen, cam_x, cam_y, font=None):
draw_x = self.x - cam_x draw_x = self.x - cam_x
draw_y = self.y - cam_y draw_y = self.y - cam_y
@@ -51,6 +61,12 @@ class Item:
pygame.draw.rect(screen, color, draw_rect) pygame.draw.rect(screen, color, draw_rect)
pygame.draw.rect(screen, COLOR_HP_BAR_BG, draw_rect, 2) pygame.draw.rect(screen, COLOR_HP_BAR_BG, draw_rect, 2)
if font is not None:
cost_surface = font.render(f"{self.cost} ECTS", True, (255, 255, 255))
prompt_surface = font.render("E", True, (255, 255, 255))
screen.blit(cost_surface, (draw_x - 10, draw_y - cost_surface.get_height() - 2))
screen.blit(prompt_surface, (draw_x + ITEM_SIZE // 2 - prompt_surface.get_width() // 2, draw_y + ITEM_SIZE + 2))
def get_cost(self): def get_cost(self):
return self.cost return self.cost
@@ -93,14 +109,7 @@ class ItemManager:
def update(self, room_rect, blocked_rects): def update(self, room_rect, blocked_rects):
now = pygame.time.get_ticks() now = pygame.time.get_ticks()
remaining_items = [] self.items = [item for item in self.items if not item.is_expired()]
for item in self.items:
if item.is_expired() == False:
remaining_items.append(item)
# replace the old list with the new temp_list
self.items = remaining_items
# spawn logic for new items # spawn logic for new items
if room_rect is not None: if room_rect is not None:
@@ -116,6 +125,10 @@ class ItemManager:
# check if player press "e" to buy # check if player press "e" to buy
def check_collect(self, player_rect, keys): def check_collect(self, player_rect, keys):
if keys[pygame.K_e]: if keys[pygame.K_e]:
return self.get_touching_item(player_rect)
return None
def get_touching_item(self, player_rect):
for item in self.items: for item in self.items:
if player_rect.colliderect(item.rect): if player_rect.colliderect(item.rect):
return item return item
@@ -127,9 +140,9 @@ class ItemManager:
self.items.remove(item) self.items.remove(item)
# draw all active items # draw all active items
def draw(self, screen, cam_x, cam_y): def draw(self, screen, cam_x, cam_y, font=None):
for item in self.items: for item in self.items:
item.draw(screen, cam_x, cam_y) item.draw(screen, cam_x, cam_y, font)
# clear items when changing room # clear items when changing room
def clear(self): def clear(self):
+386
View File
@@ -0,0 +1,386 @@
"""Monster spawning and hallway movement behaviour."""
from __future__ import annotations
import random
from collections import deque
from typing import Callable, Iterable, Optional
import pygame
from src.actors import Actor, ActorSpriteSides
from src.door import Door
from src.settings import (
MONSTER_ATTACK_INTERVAL,
MONSTER_DOOR_DAMAGE,
MONSTER_HP,
MONSTER_MAX_COUNT,
MONSTER_SPAWN_INTERVAL,
MONSTER_SPEED,
tile_size,
)
WalkableCheck = Callable[[pygame.Vector2], bool]
GridPosition = tuple[int, int]
Position = tuple[int, int]
class Monster:
"""A spawned monster backed by a good/bad actor sprite pair."""
def __init__(
self,
actor: Actor,
*,
max_hp: int = MONSTER_HP,
speed: float = MONSTER_SPEED,
) -> None:
self.actor = actor
self.actor.become_good()
self.max_hp = max_hp
self.current_hp = max_hp
self.speed = speed
self.last_attack_time = -MONSTER_ATTACK_INTERVAL
@property
def is_alive(self) -> bool:
return self.current_hp > 0
@property
def position(self) -> pygame.Vector2:
return self.actor.position
@property
def rect(self) -> pygame.Rect:
frame = self.actor.get_frame()
return frame.get_rect(midbottom=self._int_position(self.actor.position))
def take_damage(self, amount: int) -> None:
self.current_hp = max(0, self.current_hp - amount)
def sleep(self) -> None:
"""Stay friendly/idling until the player triggers the room."""
self.actor.become_good()
self.actor.stop_walking()
def wake_up(self) -> None:
"""Switch to the angry sprite when the player triggers a room."""
self.actor.become_bad()
def update(
self,
dt: float,
target_position: pygame.Vector2,
can_move_to: WalkableCheck,
) -> None:
"""Move toward the target, but only where ``can_move_to`` allows."""
if not self.is_alive:
self.actor.stop_walking()
return
movement = pygame.Vector2(target_position) - self.actor.position
if movement.length_squared() == 0:
self.actor.stop_walking()
self.actor.update_animation(dt)
return
self.face_position(target_position)
distance = movement.length()
max_distance = self.speed * dt
if distance > max_distance:
movement = movement.normalize() * max_distance
self.actor.move_by(movement)
if not can_move_to(self.actor.position):
self.actor.hit_wall()
self.actor.update_animation(dt)
def attack_door(self, door: Door) -> None:
"""Damage the active door while standing outside it."""
self.wake_up()
self.actor.stop_walking()
now = pygame.time.get_ticks()
if now - self.last_attack_time >= MONSTER_ATTACK_INTERVAL:
door.take_damage(MONSTER_DOOR_DAMAGE)
self.last_attack_time = now
def face_position(self, target_position: pygame.Vector2) -> None:
movement = pygame.Vector2(target_position) - self.actor.position
if movement.length_squared() == 0:
return
if abs(movement.x) > abs(movement.y):
self.actor.direction = "right" if movement.x > 0 else "left"
else:
self.actor.direction = "down" if movement.y > 0 else "up"
def draw(self, surface: pygame.Surface) -> pygame.Rect:
return self.actor.draw(surface)
@staticmethod
def _int_position(position: pygame.Vector2) -> tuple[int, int]:
return round(position.x), round(position.y)
class MonsterManager:
"""Loads monster sprites, spawns monsters on H hallway tiles, and updates them."""
def __init__(
self,
sprite_sides: Iterable[ActorSpriteSides],
game_map,
*,
max_monsters: int = MONSTER_MAX_COUNT,
spawn_interval_ms: int = MONSTER_SPAWN_INTERVAL,
) -> None:
self.sprite_sides = list(sprite_sides)
self.map = game_map
self.max_monsters = max_monsters
self.spawn_interval_ms = spawn_interval_ms
self.monsters: list[Monster] = []
self.last_spawn_time = pygame.time.get_ticks()
self.active_target_tile: Optional[GridPosition] = None
self.active_door: Optional[Door] = None
if not self.sprite_sides:
raise RuntimeError("No normal_*/angry_* monster sprite pairs found in assets/")
@property
def actors(self) -> list[Actor]:
return [monster.actor for monster in self.monsters]
def spawn_initial(self, blocked_rects: Optional[list[pygame.Rect]] = None) -> None:
"""Fill the game with the starting monsters on H hallway tiles."""
blockers = list(blocked_rects or [])
while len(self.monsters) < self.max_monsters:
monster = self.spawn_monster(blockers)
if monster is None:
break
blockers.append(monster.rect)
self.last_spawn_time = pygame.time.get_ticks()
def activate_for_room(self, room_rect: pygame.Rect, door: Door) -> None:
"""Wake monsters and send them to the hallway side of this room's door."""
target_tile = self.get_hall_tile_at_room_door(room_rect)
if target_tile is None:
return
self.active_target_tile = target_tile
self.active_door = door
for monster in self.monsters:
monster.wake_up()
def update(
self,
dt: float,
player_position: pygame.Vector2,
is_position_walkable: WalkableCheck,
blocked_rects: Optional[list[pygame.Rect]] = None,
) -> None:
self.monsters = [monster for monster in self.monsters if monster.is_alive]
if self.active_target_tile is None:
for monster in self.monsters:
monster.sleep()
else:
for monster in self.monsters:
waypoint = self.get_next_waypoint(monster, self.active_target_tile)
if self.position_to_tile(monster.position) == self.active_target_tile:
if self.active_door is not None and self.active_door.is_closed:
monster.attack_door(self.active_door)
else:
monster.actor.stop_walking()
else:
monster.wake_up()
monster.update(dt, waypoint, self.is_hall_position)
now = pygame.time.get_ticks()
if len(self.monsters) < self.max_monsters and now - self.last_spawn_time >= self.spawn_interval_ms:
monster = self.spawn_monster(blocked_rects)
if monster is not None and self.active_target_tile is not None:
monster.wake_up()
self.last_spawn_time = now
def get_hall_tile_at_room_door(self, room_rect: pygame.Rect) -> Optional[GridPosition]:
"""Find the H tile directly outside the D-door of ``room_rect``."""
door_tiles: list[GridPosition] = []
for row_index, row in enumerate(self.map.map):
for column_index, tile in enumerate(row):
if tile != "D":
continue
tile_center_x = column_index * tile_size + tile_size // 2
tile_center_y = row_index * tile_size + tile_size // 2
if (
room_rect.left <= tile_center_x <= room_rect.right
and room_rect.bottom <= tile_center_y <= room_rect.bottom + 2 * tile_size
):
door_tiles.append((column_index, row_index))
hall_tiles: list[GridPosition] = []
for door_tile in door_tiles:
for neighbor in self.get_neighbors(door_tile, allowed_tiles={"H"}):
hall_tiles.append(neighbor)
if not hall_tiles:
return None
room_center_tile = (room_rect.centerx // tile_size, room_rect.centery // tile_size)
return min(
hall_tiles,
key=lambda tile: abs(tile[0] - room_center_tile[0]) + abs(tile[1] - room_center_tile[1]),
)
def get_next_waypoint(self, monster: Monster, target_tile: GridPosition) -> pygame.Vector2:
"""Return the next H-tile center on a BFS path to the target tile."""
start_tile = self.position_to_tile(monster.position)
if start_tile == target_tile:
return self.tile_center(target_tile)
path = self.find_hall_path(start_tile, target_tile)
if len(path) >= 2:
return self.tile_center(path[1])
if len(path) == 1:
return self.tile_center(path[0])
return monster.position.copy()
def find_hall_path(self, start: GridPosition, target: GridPosition) -> list[GridPosition]:
"""Find a path using only H hallway tiles. D doors are never traversed."""
if self.map.get_tile(*start) != "H" or self.map.get_tile(*target) != "H":
return []
queue: deque[GridPosition] = deque([start])
came_from: dict[GridPosition, Optional[GridPosition]] = {start: None}
while queue:
current = queue.popleft()
if current == target:
break
for neighbor in self.get_neighbors(current, allowed_tiles={"H"}):
if neighbor in came_from:
continue
came_from[neighbor] = current
queue.append(neighbor)
if target not in came_from:
return []
path = [target]
while path[-1] != start:
previous = came_from[path[-1]]
if previous is None:
break
path.append(previous)
path.reverse()
return path
def get_neighbors(
self,
tile: GridPosition,
*,
allowed_tiles: set[str],
) -> list[GridPosition]:
column, row = tile
neighbors: list[GridPosition] = []
for next_column, next_row in (
(column + 1, row),
(column - 1, row),
(column, row + 1),
(column, row - 1),
):
if self.map.get_tile(next_column, next_row) in allowed_tiles:
neighbors.append((next_column, next_row))
return neighbors
def is_hall_position(self, position: pygame.Vector2) -> bool:
"""Monsters may move only on H hallway tiles, never through D doors."""
return self.map.get_tile(*self.position_to_tile(position)) == "H"
def spawn_monster(self, blocked_rects: Optional[list[pygame.Rect]] = None) -> Optional[Monster]:
"""Spawn one monster on a random H hallway tile if a free tile exists."""
if len(self.monsters) >= self.max_monsters:
return None
blockers = list(blocked_rects or [])
blockers.extend(monster.rect for monster in self.monsters)
sprite_sides = list(self.sprite_sides)
random.shuffle(sprite_sides)
for sides in sprite_sides:
position = self._random_position_on_hall_tile(sides.bad.frames["down"][1], blockers)
if position is None:
continue
actor = Actor.from_sides(sides, position)
monster = Monster(actor)
self.monsters.append(monster)
return monster
return None
def draw(self, surface: pygame.Surface) -> list[pygame.Rect]:
return [monster.draw(surface) for monster in sorted(self.monsters, key=lambda item: item.position.y)]
def clear(self) -> None:
self.monsters = []
self.last_spawn_time = pygame.time.get_ticks()
self.active_target_tile = None
self.active_door = None
def _random_position_on_hall_tile(
self,
frame: pygame.Surface,
blocked_rects: list[pygame.Rect],
) -> Optional[Position]:
hall_tiles = self.get_all_hall_tiles()
random.shuffle(hall_tiles)
for column, row in hall_tiles:
position = self.tile_center((column, row))
rect = frame.get_rect(midbottom=(round(position.x), round(position.y)))
if any(rect.colliderect(blocker) for blocker in blocked_rects):
continue
return round(position.x), round(position.y)
return None
def get_all_hall_tiles(self) -> list[GridPosition]:
hall_tiles: list[GridPosition] = []
for row_index, row in enumerate(self.map.map):
for column_index, tile in enumerate(row):
if tile == "H":
hall_tiles.append((column_index, row_index))
return hall_tiles
@staticmethod
def position_to_tile(position: pygame.Vector2) -> GridPosition:
return int(position.x // tile_size), int(position.y // tile_size)
@staticmethod
def tile_center(tile: GridPosition) -> pygame.Vector2:
column, row = tile
return pygame.Vector2(
column * tile_size + tile_size // 2,
row * tile_size + tile_size // 2,
)
+10 -16
View File
@@ -11,17 +11,16 @@ DESK_HEIGHT = 40
DOOR_WIDTH = 20 DOOR_WIDTH = 20
DOOR_HEIGHT = 100 DOOR_HEIGHT = 100
DOOR_BORDER_WIDTH = 3 DOOR_BORDER_WIDTH = 3
DOOR_HP = 100
COLOR_DOOR_BEIGE = (245, 245, 220) COLOR_DOOR_BEIGE = (245, 245, 220)
COLOR_DOOR_BORDER = (100, 90, 80) COLOR_DOOR_BORDER = (100, 90, 80)
# --- PLAYER --- # --- PLAYER ---
# Players Constants / Settings # Players Constants / Settings
# Valid movement/animation directions. # Valid movement/animation directions.
Direction = Literal["down", "left", "right", "up"] Direction = Literal["down", "left", "right", "up"]
# --- SPRITE --- # --- SPRITE ---
# Sprite files are grouped by direction. # Sprite files are grouped by direction.
# Example: "down" uses sprite_01.png, sprite_02.png, sprite_03.png. # Example: "down" uses sprite_01.png, sprite_02.png, sprite_03.png.
@@ -36,7 +35,6 @@ DIRECTION_FRAMES: Mapping[Direction, Tuple[int, int, int]] = {
DEFAULT_SPRITE_HEIGHT: int = 138 DEFAULT_SPRITE_HEIGHT: int = 138
DEFAULT_FRAME_TIME: float = 0.16 DEFAULT_FRAME_TIME: float = 0.16
# --- ECTS --- # --- ECTS ---
START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning
ECTS_UPDATE_INTERVAL = 1000 # time in ms (1000 = 1 sec) ECTS_UPDATE_INTERVAL = 1000 # time in ms (1000 = 1 sec)
@@ -44,18 +42,15 @@ ECTS_UPDATE_INTERVAL = 1000 # time in ms (1000 = 1 sec)
BEER_BOTTLE_ECTS_VALUE = 2 BEER_BOTTLE_ECTS_VALUE = 2
BEER_COST = 50 BEER_COST = 50
# --- BUTTONS --- # --- BUTTONS ---
COLOR_BUTTON = (0, 255, 0) COLOR_BUTTON = (0, 255, 0)
COLOR_BUTTON_HOVER = (200, 255, 0) COLOR_BUTTON_HOVER = (200, 255, 0)
# --- STARTSCREEN --- # --- STARTSCREEN ---
COLOR_STARTSCREEN_BACKGROUND = (0, 0, 0) COLOR_STARTSCREEN_BACKGROUND = (0, 0, 0)
COLOR_STARTSCREEN_FONT_TITLE = (0, 255, 0) COLOR_STARTSCREEN_FONT_TITLE = (0, 255, 0)
COLOR_STARTSCREEN_FONT_LABEL = (0, 255, 255) COLOR_STARTSCREEN_FONT_LABEL = (0, 255, 255)
# --- ENDSCREEN --- # --- ENDSCREEN ---
COLOR_ENDSCREEN_BACKGROUND = (0, 0, 0) COLOR_ENDSCREEN_BACKGROUND = (0, 0, 0)
COLOR_ENDSCREEN_TEXT = (255, 255, 255) COLOR_ENDSCREEN_TEXT = (255, 255, 255)
@@ -63,30 +58,22 @@ COLOR_ENDSCREEN_WIN = (0, 255, 0)
COLOR_ENDSCREEN_LOSE = (255, 0, 0) COLOR_ENDSCREEN_LOSE = (255, 0, 0)
FONT_ENDSCREEN = "Arial" FONT_ENDSCREEN = "Arial"
# --- COFFEE --- # --- COFFEE ---
COFFEE_COST = 30 COFFEE_COST = 30
COFFEE_HEAL_VALUE = 30 COFFEE_HEAL_VALUE = 30
# --- TOWER --- # --- TOWER ---
TOWER_COST = 150 TOWER_COST = 10
PROJECTILE_DMG = 15 PROJECTILE_DMG = 7
PROJECTILE_SPEED = 7 PROJECTILE_SPEED = 7
COLOR_PROJECTILE = (200, 200, 0) COLOR_PROJECTILE = (200, 200, 0)
TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears
tower_size = 30
projectile_size = 8
fire_interval = 1000 # ms
respawn_delay = 10000 # ms
# --- ITEM SPAWN TIMER --- # --- ITEM SPAWN TIMER ---
ITEM_SPAWN_INTERVAL = 5000 # ms until a new item spawns ITEM_SPAWN_INTERVAL = 5000 # ms until a new item spawns
ITEM_SHOPPING_TIMER = 10000 # how long you have time to buy items before they despawn ITEM_SHOPPING_TIMER = 10000 # how long you have time to buy items before they despawn
ITEM_SIZE = 30 ITEM_SIZE = 30
# --- ZOMBIE --- # --- ZOMBIE ---
ZOMBIE_EASY_HP = 30 ZOMBIE_EASY_HP = 30
ZOMBIE_EASY_DMG = 3 ZOMBIE_EASY_DMG = 3
@@ -97,6 +84,13 @@ ZOMBIE_MEDIUM_DMG = 7
ZOMBIE_HARD_HP = 120 ZOMBIE_HARD_HP = 120
ZOMBIE_HARD_DMG = 12 ZOMBIE_HARD_DMG = 12
# --- MONSTERS ---
MONSTER_HP = ZOMBIE_MEDIUM_HP
MONSTER_MAX_COUNT = 1
MONSTER_SPAWN_INTERVAL = 8000 # ms until a replacement monster spawns
MONSTER_SPEED = 90.0
MONSTER_DOOR_DAMAGE = ZOMBIE_MEDIUM_DMG
MONSTER_ATTACK_INTERVAL = 1000 # ms between door attacks
# --- HUD --- # --- HUD ---
HUD_FONT = "Arial" HUD_FONT = "Arial"
+3
View File
@@ -10,6 +10,9 @@ class Shop:
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)
return self.buy_item(item_to_buy)
def buy_item(self, item_to_buy):
if item_to_buy is None: if item_to_buy is None:
return False return False
+132 -139
View File
@@ -1,181 +1,174 @@
import pygame
import random
import math import math
from src.settings import TOWER_COST, PROJECTILE_DMG, PROJECTILE_SPEED, COLOR_PROJECTILE, TOWER_TIMER, projectile_size, tower_size, fire_interval, respawn_delay import random
from typing import Optional
tower_size = 30 # Größe Tower Feld in Pixel import pygame
projectile_size = 8 # Größe Projektil in Pixel
from src.settings import (
COLOR_PROJECTILE,
PROJECTILE_DMG,
PROJECTILE_SPEED,
TOWER_COST,
TOWER_TIMER,
)
tower_size = 30
projectile_size = 20
fire_interval = 1000 # ms zwischen Schüssen fire_interval = 1000 # ms zwischen Schüssen
respawn_delay = 10000 # ms bis neues Feld spawnt
class Tower: class Tower:
def __init__(self, sound_manager) -> None: def __init__(self, sound_manager=None) -> None:
self.sound_manager = sound_manager self.sound_manager = sound_manager
self.tower_rect: Optional[pygame.Rect] = None
# Feld und Tower -> None wenn nicht aktiv self.projectiles: list[dict[str, float]] = []
# Position interagierbares Feld self.tower_start: Optional[int] = None
self.field_rect: pygame.Rect = None
#Position des gebauten Tower
self.tower_rect: pygame.Rect = None
# Lister aller aktiven Projektile - dict mit Position und Geschwindigkeit
self.projectiles: list[dict] = []
# Zeitpunkt, wann der Tower gebaut wurde
self.tower_start: int = None
# Zeitpunkt, wann Tower despawnt ist
self.despawn_time: int = None
# Zeitpunkt des letzten Schusses
self.last_shot: int = 0 self.last_shot: int = 0
# Sprite laden für den Tower
try: try:
self.tower_image = pygame.image.load("assets/items/tower.png").convert_alpha() self.tower_image = pygame.image.load("assets/items/tower.png").convert_alpha()
self.tower_image = pygame.transform.scale(self.tower_image, (tower_size, tower_size)) self.tower_image = pygame.transform.scale(self.tower_image, (tower_size, tower_size))
except Exception as e: except Exception as error:
print(f"Error loading tower sprite: {e}") print(f"Error loading tower sprite: {error}")
self.tower_image = None self.tower_image = None
# Sprite laden für Projektil
try: try:
self.projectile_image = pygame.image.load("assets/items/book.png").convert_alpha() self.projectile_image = pygame.image.load("assets/items/book.png").convert_alpha()
self.projectile_image = pygame.transform.scale(self.projectile_image, (projectile_size, projectile_size)) self.projectile_image = pygame.transform.scale(
except Exception as e: self.projectile_image,
print(f"Error loading projectile sprite: {e}") (projectile_size, projectile_size),
)
except Exception as error:
print(f"Error loading projectile sprite: {error}")
self.projectile_image = None self.projectile_image = None
# soll interaktives Tower Feld an zufälliger freier Position im Raum spawnen def try_spawn(self, room_rect: Optional[pygame.Rect], blocked_rects: list[pygame.Rect], ects) -> bool:
def field_spawn(self, room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]): """Spawn and pay for one tower when the player has enough ECTS."""
# versucht bis zu 50 mal, eine nicht belegte Stelle im Raum zu finden
for i in range(50):
x = random.randint(room_rect.left + tower_size, room_rect.right - tower_size * 2)
y = random.randint(room_rect.top + tower_size, room_rect.bottom - tower_size * 2)
rect = pygame.Rect(x, y, tower_size, tower_size)
# freie Position finden - nicht auf dem Tisch oder blockierten Rects if room_rect is None or self.tower_rect is not None:
if not any(rect.colliderect(b) for b in blocked_rects):
self.field_rect = rect
return
# Baut Tower wenn Spieler E drückt, auf Feld steht und genug ects hat
def try_build(self, player_rect: pygame.Rect, keys: pygame.key.ScancodeWrapper, ects):
# bricht ab wenn kein Feld vorhanden ist oder ein Tower bereits aktiv ist
if not self.field_rect or self.tower_rect:
return False return False
# prüft alle Bedingungen gleichzeitig if ects.ects_balance < TOWER_COST:
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 return False
# ECTS abziehen und Tower bauen rect = self._random_free_rect(room_rect, blocked_rects)
self.sound_manager.play_sfx("buy_success") if rect is None:
ects.balance -= TOWER_COST self._play_sfx("buy_fail")
self.tower_rect = self.field_rect return False
self.field_rect = None
ects.decrease_balance(TOWER_COST)
self.tower_rect = rect
self.tower_start = pygame.time.get_ticks() self.tower_start = pygame.time.get_ticks()
self.projectiles = []
self._play_sfx("buy_success")
return True return True
# ruft jeden Frame auf und gibt den Schaden auf Monster zurück def update(
# verwaltet Schüsse, Projektile, Tower-Leben und Respawn self,
def update(self, player_rect: pygame.Rect, keys: pygame.key.ScancodeWrapper, ects, monster_rect: pygame.Rect, room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]): room_rect: Optional[pygame.Rect],
blocked_rects: list[pygame.Rect],
ects,
monster_rect: Optional[pygame.Rect],
) -> int:
"""Update tower spawning/shooting and return damage dealt to the monster."""
now = pygame.time.get_ticks() now = pygame.time.get_ticks()
total_damage = 0 total_damage = 0
# versucht, den Tower zu bauen wenn der Spieler auf dem Feld steht self.try_spawn(room_rect, blocked_rects, ects)
self.try_build(player_rect, keys, ects)
# schießen dann, wenn Tower aktiv und Monster vorhanden sind if self.tower_rect is not None and monster_rect is not None:
if self.tower_rect and monster_rect:
if now - self.last_shot >= fire_interval: if now - self.last_shot >= fire_interval:
#berechnet Richtungsvektor vom Tower zum Monster self._shoot_at(monster_rect)
self.last_shot = now
for projectile in self.projectiles[:]:
projectile["x"] += projectile["vel_x"]
projectile["y"] += projectile["vel_y"]
projectile_rect = pygame.Rect(
int(projectile["x"]),
int(projectile["y"]),
projectile_size,
projectile_size,
)
if monster_rect is not None and projectile_rect.colliderect(monster_rect):
total_damage += PROJECTILE_DMG
self.projectiles.remove(projectile)
self._play_sfx("monster_hit")
if self.tower_rect is not None and self.tower_start is not None:
if now - self.tower_start >= TOWER_TIMER:
self.clear()
return total_damage
def draw(self, surface: pygame.Surface, font: pygame.font.Font, cam_x: int = 0, cam_y: int = 0) -> None:
if self.tower_rect is not None and self.tower_start is not None:
draw_rect = pygame.Rect(
self.tower_rect.x - cam_x,
self.tower_rect.y - cam_y,
tower_size,
tower_size,
)
seconds_left = max(0, TOWER_TIMER - (pygame.time.get_ticks() - self.tower_start)) // 1000
if self.tower_image is not None:
surface.blit(self.tower_image, draw_rect)
else:
pygame.draw.rect(surface, (40, 160, 80), draw_rect)
pygame.draw.rect(surface, (80, 220, 120), draw_rect, 2)
surface.blit(font.render(str(seconds_left), True, (255, 255, 255)), (draw_rect.x + 8, draw_rect.y - 18))
for projectile in self.projectiles:
projectile_rect = pygame.Rect(
int(projectile["x"]) - cam_x,
int(projectile["y"]) - cam_y,
projectile_size,
projectile_size,
)
if self.projectile_image is not None:
surface.blit(self.projectile_image, projectile_rect)
else:
pygame.draw.rect(surface, COLOR_PROJECTILE, projectile_rect)
def clear(self) -> None:
self.tower_rect = None
self.projectiles = []
self.tower_start = None
self.last_shot = 0
def _shoot_at(self, monster_rect: pygame.Rect) -> None:
if self.tower_rect is None:
return
dx = monster_rect.centerx - self.tower_rect.centerx dx = monster_rect.centerx - self.tower_rect.centerx
dy = monster_rect.centery - self.tower_rect.centery dy = monster_rect.centery - self.tower_rect.centery
distance = math.sqrt(dx * dx + dy * dy) or 1 distance = math.sqrt(dx * dx + dy * dy) or 1
self.projectiles.append(
# fügt neues Projektil mit Startposition und Geschwindigkeit hinzu {
self.projectiles.append({
"x": float(self.tower_rect.centerx), "x": float(self.tower_rect.centerx),
"y": float(self.tower_rect.centery), "y": float(self.tower_rect.centery),
"vel_x": (dx / distance) * PROJECTILE_SPEED, "vel_x": (dx / distance) * PROJECTILE_SPEED,
"vel_y": (dy / distance) * PROJECTILE_SPEED, "vel_y": (dy / distance) * PROJECTILE_SPEED,
}) }
)
self._play_sfx("tower_shoot")
self.sound_manager.play_sfx("tower_shoot") def _play_sfx(self, name: str) -> None:
self.last_shot = now if self.sound_manager is not None:
self.sound_manager.play_sfx(name)
# Bewegung der Projektile und Kollision mit Monster prüfen @staticmethod
for p in self.projectiles[:]: # [:] = Kopie der Liste, dass man sicher löschen kann def _random_free_rect(room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]) -> Optional[pygame.Rect]:
p["x"] += p["vel_x"] for _ in range(50):
p["y"] += p["vel_y"] x = random.randint(room_rect.left + tower_size, room_rect.right - tower_size * 2)
y = random.randint(room_rect.top + tower_size, room_rect.bottom - tower_size * 2)
rect = pygame.Rect(x, y, tower_size, tower_size)
# Projektil trifft auf Monster -> Schaden wird addiert und Projektil entfernt if not any(rect.colliderect(blocked) for blocked in blocked_rects):
if monster_rect and pygame.Rect(int(p["x"]), int(p["y"]), projectile_size, projectile_size).colliderect(monster_rect): return 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 return None
if self.tower_rect and self.tower_start and now - self.tower_start >= TOWER_TIMER:
self.tower_rect = None
self.despawn_time = now
self.projectiles = []
# ein neues Feld soll spawnen nach dem Respawn Delay
if not self.tower_rect and not self.field_rect and self.despawn_time:
if now - self.despawn_time >= respawn_delay:
self.field_spawn(room_rect, blocked_rects)
self.despawn_time = None
return total_damage
# Zeichnet Feld, Tower und Projektile auf dem Bildschirm
def draw(self, surface: pygame.Surface, font: pygame.font.Font, cam_x: int = 0, cam_y: int = 0):
# Feld zeichnen - goldenes Rechteck mit "T" als Hinweis
if self.field_rect:
r = pygame.Rect(self.field_rect.x - cam_x, self.field_rect.y - cam_y, tower_size, tower_size)
pygame.draw.rect(surface, (160,120,30), r)
pygame.draw.rect(surface, (220,180,60), r, 2)
surface.blit(font.render("T", True, (255, 255, 255)), (r.x + 12, r.y + 10))
# Tower Zeichnen mit Sprite - als Fallback grünes Rechteck
if self.tower_rect and self.tower_start:
r = pygame.Rect(self.tower_rect.x - cam_x, self.tower_rect.y - cam_y, tower_size, tower_size)
# berechnet die verbleibende Zeit in Sekunden
sec = max(0, TOWER_TIMER - (pygame.time.get_ticks() - self.tower_start)) // 1000
if self.tower_image:
# Sprite verfügbar -> zeichnen
surface.blit(self.tower_image, r)
else:
# kein Sprite -> Fallback grünes Rechteck
pygame.draw.rect(surface, (40, 160, 80), r)
pygame.draw.rect(surface, (80, 220, 120), r, 2)
# Restzeit der Tower über dem Sprite anzeigen (in Sekunden)
surface.blit(font.render(str(sec), True, (255, 255, 255)), (r.x + 8, r.y - 18))
# alle aktiven Projektile zeichnen als Prite oder Fallback als kleine Rechtecke
for p in self.projectiles:
draw_x = int(p["x"]) - cam_x
draw_y = int(p["y"]) - cam_y
if self.projectile_image:
# zeichnet Buch Sprite
surface.blit(self.projectile_image, (draw_x, draw_y))
else:
# Fallback - gelbes Rechteck
pygame.draw.rect(surface, COLOR_PROJECTILE,
pygame.Rect(draw_x, draw_y, projectile_size, projectile_size))
# alles zurücksetzen, wenn der Raum verlassen wird
def clear(self) -> None:
self.field_rect = None
self.tower_rect = None
self.projectiles = []
self.despawn_time = None