feat: implement game loop for monster interaction
This commit is contained in:
+207
-11
@@ -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
|
||||
|
||||
The demo also places a few bad/angry actors as monsters in the rooms.
|
||||
@@ -16,9 +16,15 @@ 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.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 DEMO_ACTOR_HEIGHT, DOOR_HEIGHT, DOOR_HP, DOOR_WIDTH, FPS, PLAYER_SPEED
|
||||
from src.startscreen import Startscreen
|
||||
from src.tower import Tower
|
||||
|
||||
|
||||
# Keep the actor close to the map scale. The map tiles are 30x30, so 2 tiles
|
||||
@@ -40,20 +46,34 @@ class Game:
|
||||
self.running = True
|
||||
|
||||
self.startscreen = Startscreen(self.screen)
|
||||
self.hud = HUD()
|
||||
self.ects = ECTS()
|
||||
self.tower = Tower()
|
||||
self.state = "startscreen"
|
||||
self.player_name: str = ""
|
||||
self.game_start_time = 0
|
||||
self.endscreen: Optional[Endscreen] = None
|
||||
|
||||
self.map = Map()
|
||||
self.active_door: Optional[Door] = None
|
||||
self.doors_by_room: dict[tuple[int, int, int, int], Door] = {}
|
||||
|
||||
# resolve the assets from the folder
|
||||
# Use Niklas as the player sprite.
|
||||
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("No normal_*/angry_* actor sprite pairs found in assets/")
|
||||
|
||||
# Use the first pair for the player. With current assets this is Knoll.
|
||||
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:
|
||||
"""Run the demo. ``max_frames`` is only used for automated smoke tests."""
|
||||
@@ -68,11 +88,20 @@ 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"
|
||||
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
|
||||
@@ -97,6 +126,16 @@ 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.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()
|
||||
@@ -124,6 +163,143 @@ 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 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.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 = (room_rect.x, room_rect.y, room_rect.width, room_rect.height)
|
||||
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)
|
||||
return blockers
|
||||
|
||||
@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]:
|
||||
"""Find the first S tile and place the actor's feet near its center."""
|
||||
|
||||
@@ -139,10 +315,30 @@ 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)
|
||||
|
||||
if self.active_door is not None and self.active_door.is_closed:
|
||||
self.active_door.draw(self.screen)
|
||||
|
||||
self.tower.draw(self.screen, self.font)
|
||||
|
||||
# Draw by Y position so actors lower on the map appear in front.
|
||||
for actor in sorted([self.player], key=lambda item: item.position.y):
|
||||
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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user