Merge branch 'feat/monsters' into 'main'
feat: implement game loop See merge request swd25-bootcamp/silent-fh!28
This commit is contained in:
+2
-2
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
+30
-17
@@ -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,9 +125,13 @@ class ItemManager:
|
||||
# check if player press "e" to buy
|
||||
def check_collect(self, player_rect, keys):
|
||||
if keys[pygame.K_e]:
|
||||
for item in self.items:
|
||||
if player_rect.colliderect(item.rect):
|
||||
return item
|
||||
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
|
||||
return None
|
||||
|
||||
# delete item from list after buying it
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
+17
-23
@@ -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,26 +35,22 @@ 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)
|
||||
START_ECTS_VALUE = 1 # how many ECTS are generated at the beginning
|
||||
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
|
||||
|
||||
TOWER_TIMER = 30000 # how long the tower stays and attack before it disappears
|
||||
|
||||
# --- 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_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"
|
||||
@@ -118,8 +112,8 @@ PLAYER_SPEED = 180.0
|
||||
DEMO_ACTOR_HEIGHT = tile_size * 3
|
||||
FPS = 60
|
||||
|
||||
HUD_X_OFFSET = 20 # left startpoint for texts
|
||||
HUD_BAR_X = 150 # left startpoint for hp bars
|
||||
HUD_X_OFFSET = 20 # left startpoint for texts
|
||||
HUD_BAR_X = 150 # left startpoint for hp bars
|
||||
|
||||
HUD_Y_DOOR = 20
|
||||
HUD_Y_ZOMBIE = 50
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+128
-135
@@ -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
|
||||
fire_interval = 1000 # ms zwischen Schüssen
|
||||
respawn_delay = 10000 # ms bis neues Feld spawnt
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
if room_rect is None or self.tower_rect is not None:
|
||||
return False
|
||||
if ects.ects_balance < TOWER_COST:
|
||||
return False
|
||||
|
||||
# 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):
|
||||
rect = self._random_free_rect(room_rect, blocked_rects)
|
||||
if rect is None:
|
||||
self._play_sfx("buy_fail")
|
||||
return False
|
||||
|
||||
# bricht ab wenn kein Feld vorhanden ist oder ein Tower bereits aktiv ist
|
||||
if not self.field_rect or self.tower_rect:
|
||||
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.decrease_balance(TOWER_COST)
|
||||
self.tower_rect = rect
|
||||
self.tower_start = pygame.time.get_ticks()
|
||||
self.projectiles = []
|
||||
self._play_sfx("buy_success")
|
||||
return True
|
||||
|
||||
# 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
|
||||
self.tower_start = pygame.time.get_ticks()
|
||||
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
|
||||
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({
|
||||
"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.sound_manager.play_sfx("tower_shoot")
|
||||
self._shoot_at(monster_rect)
|
||||
self.last_shot = now
|
||||
|
||||
# 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"]
|
||||
for projectile in self.projectiles[:]:
|
||||
projectile["x"] += projectile["vel_x"]
|
||||
projectile["y"] += projectile["vel_y"]
|
||||
|
||||
# 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):
|
||||
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.sound_manager.play_sfx("monster_hit")
|
||||
self.projectiles.remove(p)
|
||||
self.projectiles.remove(projectile)
|
||||
self._play_sfx("monster_hit")
|
||||
|
||||
# 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
|
||||
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,
|
||||
)
|
||||
|
||||
# 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))
|
||||
seconds_left = max(0, TOWER_TIMER - (pygame.time.get_ticks() - self.tower_start)) // 1000
|
||||
|
||||
# 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)
|
||||
if self.tower_image is not None:
|
||||
surface.blit(self.tower_image, draw_rect)
|
||||
else:
|
||||
# kein Sprite -> Fallback grünes Rechteck
|
||||
pygame.draw.rect(surface, (40, 160, 80), r)
|
||||
pygame.draw.rect(surface, (80, 220, 120), r, 2)
|
||||
pygame.draw.rect(surface, (40, 160, 80), draw_rect)
|
||||
pygame.draw.rect(surface, (80, 220, 120), draw_rect, 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))
|
||||
surface.blit(font.render(str(seconds_left), True, (255, 255, 255)), (draw_rect.x + 8, draw_rect.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))
|
||||
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:
|
||||
# Fallback - gelbes Rechteck
|
||||
pygame.draw.rect(surface, COLOR_PROJECTILE,
|
||||
pygame.Rect(draw_x, draw_y, projectile_size, projectile_size))
|
||||
pygame.draw.rect(surface, COLOR_PROJECTILE, projectile_rect)
|
||||
|
||||
|
||||
|
||||
# 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
|
||||
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
|
||||
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")
|
||||
|
||||
def _play_sfx(self, name: str) -> None:
|
||||
if self.sound_manager is not None:
|
||||
self.sound_manager.play_sfx(name)
|
||||
|
||||
@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)
|
||||
|
||||
if not any(rect.colliderect(blocked) for blocked in blocked_rects):
|
||||
return rect
|
||||
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user