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
from button import Button
from settings import (COLOR_ENDSCREEN_BACKGROUND,
from src.button import Button
from src.settings import (COLOR_ENDSCREEN_BACKGROUND,
COLOR_ENDSCREEN_TEXT,
COLOR_ENDSCREEN_WIN,
COLOR_ENDSCREEN_LOSE,
+279 -10
View File
@@ -1,8 +1,8 @@
"""Small playable demo that uses ``src.map.Map`` and the actor system.
Controls:
- WASD / arrow keys: move Knoll around the map
- Space: switch between good/normal and bad/angry sprite
- WASD / arrow keys: move Niklas around the map
- Space: toggle player state
- Esc / Q: quit
"""
@@ -14,10 +14,27 @@ from typing import Optional
import pygame
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.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.sounds import SoundManager
from src.tower import Tower
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.clock = pygame.time.Clock()
self.font = pygame.font.SysFont("monospace", 22, bold=True)
self.item_font = pygame.font.SysFont("Arial", 16, bold=True)
self.running = True
self.active_monster = None
self.buy_requested = False
# --- SOUND & SYSTEMS ---
self.sound_manager = SoundManager()
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.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.active_door: Optional[Door] = None
self.doors_by_room: dict[tuple[int, int, int, int], Door] = {}
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:
raise RuntimeError(f"Keine Sprites in {assets_dir} gefunden!")
self.player = Actor.from_sides(actor_sides[0], self.find_start_position())
monster_sides = Actor.load_sprite_sides(assets_dir, target_height=DEMO_ACTOR_HEIGHT)
self.monsters = MonsterManager(monster_sides, self.map)
self.monsters.spawn_initial(self.get_monster_spawn_blockers())
def run(self, max_frames: Optional[int] = None) -> None:
frame_count = 0
@@ -63,6 +99,7 @@ class Game:
self.running = False
elif next_state == "game":
self.player_name = self.startscreen.player_name
self.game_start_time = pygame.time.get_ticks()
self.state = "game"
self.sound_manager.play_bgm("normal")
@@ -70,6 +107,14 @@ class Game:
self.handle_events()
self.update(dt)
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()
frame_count += 1
@@ -86,6 +131,8 @@ class Game:
elif event.key == pygame.K_SPACE:
self.player.toggle_side()
self.sound_manager.play_sfx("monster_transform")
elif event.key == pygame.K_e:
self.buy_requested = True
def update(self, dt: float) -> None:
if self.player.is_bad:
@@ -102,6 +149,17 @@ class Game:
self.player.hit_wall()
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:
keys = pygame.key.get_pressed()
@@ -127,6 +185,195 @@ class Game:
row = int(position.y // tile_size)
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]:
for row_index, row in enumerate(self.map.map):
column_index = row.find("S")
@@ -137,9 +384,31 @@ class Game:
)
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:
self.screen.fill(color_void)
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)
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,
COLOR_HUD_TEXT, COLOR_HP_BAR_GREEN, COLOR_HP_BAR_RED, COLOR_HP_BAR_BG,
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):
# bg bar is always the same
pygame.draw.rect(screen, COLOR_HP_BAR_BG, (x, y, HUD_BAR_WIDTH, HUD_BAR_HEIGHT))
# hp bar
if max_val > 0:
hp_width = (current / max_val) * HUD_BAR_WIDTH
pygame.draw.rect((screen, color), (x, y, hp_width, HUD_BAR_HEIGHT))
current = max(0, min(current, max_val))
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):
mins = total_seconds // 60
secs = total_seconds % 60
@@ -26,22 +26,52 @@ def _format_time(total_seconds):
class HUD:
def __init__(self):
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 ---
_draw_status_bar(screen, HUD_BAR_X, HUD_Y_DOOR, door.current_hp, COLOR_HP_BAR_GREEN)
# -5 px, small off-set to align the text better with hp bar
screen.blit(self.font.render("DOOR HP ", True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_DOOR - 5))
door_x = 330
screen.blit(self.font.render("DOOR HP", True, COLOR_HUD_TEXT), (door_x, label_y))
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 ---
if zombie:
_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), (HUD_X_OFFSET, HUD_Y_ZOMBIE - 5))
zombie_x = 620
screen.blit(self.font.render("ZOMBIE HP", True, COLOR_HUD_TEXT), (zombie_x, label_y))
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_info = f"ECTS: {ects.ects_balance} ({ects.ects_per_second}/s)"
screen.blit(self.font.render(ects_info, True, COLOR_HUD_TEXT), (HUD_X_OFFSET, HUD_Y_ECTS))
ects_x = 910
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_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 random
from src.settings import (
ITEM_SPAWN_INTERVAL, ITEM_SHOPPING_TIMER, ITEM_SIZE,
COLOR_HP_BAR_YELLOW, COLOR_HP_BAR_BG
BEER_COST,
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:
def __init__(self, x, y, item_type):
self.x = x
self.y = y
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)
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
# 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_y = self.y - cam_y
@@ -51,6 +61,12 @@ class Item:
pygame.draw.rect(screen, color, draw_rect)
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):
return self.cost
@@ -93,14 +109,7 @@ class ItemManager:
def update(self, room_rect, blocked_rects):
now = pygame.time.get_ticks()
remaining_items = []
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
self.items = [item for item in self.items if not item.is_expired()]
# spawn logic for new items
if room_rect is not None:
@@ -116,6 +125,10 @@ class ItemManager:
# check if player press "e" to buy
def check_collect(self, player_rect, keys):
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:
if player_rect.colliderect(item.rect):
return item
@@ -127,9 +140,9 @@ class ItemManager:
self.items.remove(item)
# 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:
item.draw(screen, cam_x, cam_y)
item.draw(screen, cam_x, cam_y, font)
# clear items when changing room
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_HEIGHT = 100
DOOR_BORDER_WIDTH = 3
DOOR_HP = 100
COLOR_DOOR_BEIGE = (245, 245, 220)
COLOR_DOOR_BORDER = (100, 90, 80)
# --- PLAYER ---
# Players Constants / Settings
# Valid movement/animation directions.
Direction = Literal["down", "left", "right", "up"]
# --- SPRITE ---
# Sprite files are grouped by direction.
# 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_FRAME_TIME: float = 0.16
# --- ECTS ---
START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning
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_COST = 50
# --- BUTTONS ---
COLOR_BUTTON = (0, 255, 0)
COLOR_BUTTON_HOVER = (200, 255, 0)
# --- STARTSCREEN ---
COLOR_STARTSCREEN_BACKGROUND = (0, 0, 0)
COLOR_STARTSCREEN_FONT_TITLE = (0, 255, 0)
COLOR_STARTSCREEN_FONT_LABEL = (0, 255, 255)
# --- ENDSCREEN ---
COLOR_ENDSCREEN_BACKGROUND = (0, 0, 0)
COLOR_ENDSCREEN_TEXT = (255, 255, 255)
@@ -63,30 +58,22 @@ COLOR_ENDSCREEN_WIN = (0, 255, 0)
COLOR_ENDSCREEN_LOSE = (255, 0, 0)
FONT_ENDSCREEN = "Arial"
# --- COFFEE ---
COFFEE_COST = 30
COFFEE_HEAL_VALUE = 30
# --- TOWER ---
TOWER_COST = 150
PROJECTILE_DMG = 15
TOWER_COST = 10
PROJECTILE_DMG = 7
PROJECTILE_SPEED = 7
COLOR_PROJECTILE = (200, 200, 0)
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_INTERVAL = 5000 # ms until a new item spawns
ITEM_SHOPPING_TIMER = 10000 # how long you have time to buy items before they despawn
ITEM_SIZE = 30
# --- ZOMBIE ---
ZOMBIE_EASY_HP = 30
ZOMBIE_EASY_DMG = 3
@@ -97,6 +84,13 @@ ZOMBIE_MEDIUM_DMG = 7
ZOMBIE_HARD_HP = 120
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_FONT = "Arial"
+3
View File
@@ -10,6 +10,9 @@ class Shop:
def buy(self, 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:
return False
+132 -139
View File
@@ -1,181 +1,174 @@
import pygame
import random
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
projectile_size = 8 # Größe Projektil in Pixel
import pygame
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
respawn_delay = 10000 # ms bis neues Feld spawnt
class Tower:
def __init__(self, sound_manager) -> None:
def __init__(self, sound_manager=None) -> None:
self.sound_manager = sound_manager
# Feld und Tower -> None wenn nicht aktiv
# Position interagierbares Feld
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.tower_rect: Optional[pygame.Rect] = None
self.projectiles: list[dict[str, float]] = []
self.tower_start: Optional[int] = None
self.last_shot: int = 0
# Sprite laden für den Tower
try:
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))
except Exception as e:
print(f"Error loading tower sprite: {e}")
except Exception as error:
print(f"Error loading tower sprite: {error}")
self.tower_image = None
# Sprite laden für Projektil
try:
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))
except Exception as e:
print(f"Error loading projectile sprite: {e}")
self.projectile_image = pygame.transform.scale(
self.projectile_image,
(projectile_size, projectile_size),
)
except Exception as error:
print(f"Error loading projectile sprite: {error}")
self.projectile_image = None
# soll interaktives Tower Feld an zufälliger freier Position im Raum spawnen
def field_spawn(self, room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]):
# 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)
def try_spawn(self, room_rect: Optional[pygame.Rect], blocked_rects: list[pygame.Rect], ects) -> bool:
"""Spawn and pay for one tower when the player has enough ECTS."""
# freie Position finden - nicht auf dem Tisch oder blockierten Rects
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:
if room_rect is None or self.tower_rect is not None:
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")
if ects.ects_balance < TOWER_COST:
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
rect = self._random_free_rect(room_rect, blocked_rects)
if rect is None:
self._play_sfx("buy_fail")
return False
ects.decrease_balance(TOWER_COST)
self.tower_rect = rect
self.tower_start = pygame.time.get_ticks()
self.projectiles = []
self._play_sfx("buy_success")
return True
# ruft jeden Frame auf und gibt den Schaden auf Monster zurück
# verwaltet Schüsse, Projektile, Tower-Leben und Respawn
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]):
def update(
self,
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()
total_damage = 0
# versucht, den Tower zu bauen wenn der Spieler auf dem Feld steht
self.try_build(player_rect, keys, ects)
self.try_spawn(room_rect, blocked_rects, ects)
# schießen dann, wenn Tower aktiv und Monster vorhanden sind
if self.tower_rect and monster_rect:
if self.tower_rect is not None and monster_rect is not None:
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
dy = monster_rect.centery - self.tower_rect.centery
distance = math.sqrt(dx * dx + dy * dy) or 1
# fügt neues Projektil mit Startposition und Geschwindigkeit hinzu
self.projectiles.append({
self.projectiles.append(
{
"x": float(self.tower_rect.centerx),
"y": float(self.tower_rect.centery),
"vel_x": (dx / distance) * PROJECTILE_SPEED,
"vel_y": (dy / distance) * PROJECTILE_SPEED,
})
}
)
self._play_sfx("tower_shoot")
self.sound_manager.play_sfx("tower_shoot")
self.last_shot = now
def _play_sfx(self, name: str) -> None:
if self.sound_manager is not None:
self.sound_manager.play_sfx(name)
# Bewegung der Projektile und Kollision mit Monster prüfen
for p in self.projectiles[:]: # [:] = Kopie der Liste, dass man sicher löschen kann
p["x"] += p["vel_x"]
p["y"] += p["vel_y"]
@staticmethod
def _random_free_rect(room_rect: pygame.Rect, blocked_rects: list[pygame.Rect]) -> Optional[pygame.Rect]:
for _ 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)
# 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)
if not any(rect.colliderect(blocked) for blocked in blocked_rects):
return rect
# wenn der Tower abgelaufen ist, soll er despawnen und ein Timer starten
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
return None