415 lines
15 KiB
Python
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(),
|
|
)
|