Files
silent-fh/src/game.py
T
2026-05-08 19:23:19 +02:00

415 lines
15 KiB
Python

"""Small playable demo that uses ``src.map.Map`` and the actor system.
Controls:
- WASD / arrow keys: move Niklas around the map
- Space: toggle player state
- Esc / Q: quit
"""
from __future__ import annotations
from pathlib import Path
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.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]:
"""Return the pixel center of a map tile."""
return column * tile_size + tile_size // 2, row * tile_size + tile_size // 2
class Game:
def __init__(self) -> None:
pygame.init()
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"
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(),
)
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
while self.running and (max_frames is None or frame_count < max_frames):
dt = min(self.clock.tick(FPS) / 1000.0, 0.05)
if self.state == "startscreen":
next_state = self.startscreen.run()
if next_state == "quit":
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")
elif self.state == "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
pygame.quit()
def handle_events(self) -> None:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.running = False
elif event.type == pygame.KEYDOWN:
if event.key in (pygame.K_ESCAPE, pygame.K_q):
self.running = False
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:
self.active_monster = self.player
self.sound_manager.play_bgm("creepy")
else:
self.active_monster = None
self.sound_manager.play_bgm("normal")
movement = self.get_player_movement(dt)
self.player.move_by(movement)
if not self.is_position_walkable(self.player.position):
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()
movement = pygame.Vector2(
(keys[pygame.K_RIGHT] or keys[pygame.K_d])
- (keys[pygame.K_LEFT] or keys[pygame.K_a]),
(keys[pygame.K_DOWN] or keys[pygame.K_s])
- (keys[pygame.K_UP] or keys[pygame.K_w]),
)
if movement.length_squared() == 0:
return movement
if abs(movement.x) > abs(movement.y):
self.player.direction = "right" if movement.x > 0 else "left"
else:
self.player.direction = "down" if movement.y > 0 else "up"
return movement.normalize() * PLAYER_SPEED * dt
def is_position_walkable(self, position: pygame.Vector2) -> bool:
column = int(position.x // tile_size)
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")
if column_index != -1:
return (
column_index * tile_size + tile_size // 2,
(row_index + 1) * tile_size + tile_size // 2,
)
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)
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(),
)