468 lines
18 KiB
Python
468 lines
18 KiB
Python
"""Demonstration of a top-down shooter with light masking."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import List
|
|
|
|
from Box2D import b2CircleShape, b2Vec2
|
|
import pyray as rl
|
|
|
|
from engine.framework import GameObject, Scene
|
|
from engine.math_extensions import vec_add, vec_mul, vec_sub, v2
|
|
from engine.prefabs.components import (BodyComponent, MultiComponent, SoundComponent,
|
|
SpriteComponent, TopDownMovementComponent,
|
|
TopDownMovementParams)
|
|
from engine.prefabs.managers import FontManager
|
|
from engine.prefabs.services import LevelService, PhysicsService, SoundService, TextureService
|
|
|
|
RLGL_SRC_ALPHA = 0x0302
|
|
RLGL_MIN = 0x8007
|
|
|
|
|
|
class Bullet(GameObject):
|
|
"""Projectile fired by a player character."""
|
|
def __init__(self) -> None:
|
|
"""Prepare bullet component references and cached services.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.physics: PhysicsService = None # type: ignore[assignment]
|
|
self.body: BodyComponent = None # type: ignore[assignment]
|
|
self.sprite: SpriteComponent = None # type: ignore[assignment]
|
|
self.hit_sound: SoundComponent = None # type: ignore[assignment]
|
|
self.speed = 800.0
|
|
|
|
def init(self) -> None:
|
|
"""Create the bullet body, sprite, and hit sound.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.physics = self.scene.get_service(PhysicsService)
|
|
|
|
def build_body(component: BodyComponent):
|
|
"""Build body.
|
|
|
|
Args:
|
|
component: Parameter.
|
|
|
|
Returns:
|
|
Result of the operation.
|
|
"""
|
|
world = self.physics.world
|
|
body = world.CreateDynamicBody(position=(self.physics.convert_to_meters(v2(-1000.0, -1000.0)).x,
|
|
self.physics.convert_to_meters(v2(-1000.0, -1000.0)).y),
|
|
bullet=True)
|
|
body.userData = self
|
|
shape = b2CircleShape(radius=self.physics.convert_length_to_meters(8.0))
|
|
body.CreateFixture(shape=shape, density=0.25, friction=0.0, restitution=0.0)
|
|
component.body = body
|
|
|
|
self.body = self.add_component(BodyComponent(build=build_body))
|
|
self.sprite = self.add_component(SpriteComponent("assets/zombie_shooter/bullet.png", self.body))
|
|
self.hit_sound = self.add_component(SoundComponent("assets/sounds/hit.wav"))
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Handle collisions and deactivate on impact.
|
|
|
|
Args:
|
|
delta_time: Seconds since last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
for contact_body in self.body.get_contacts():
|
|
self.is_active = False
|
|
self.body.set_position(v2(-1000.0, -1000.0))
|
|
self.body.set_velocity(v2(0.0, 0.0))
|
|
other = contact_body.userData
|
|
if other and other.has_tag("zombie"):
|
|
self.hit_sound.play()
|
|
other.is_active = False
|
|
zombie_body = other.get_component(BodyComponent)
|
|
if zombie_body:
|
|
zombie_body.set_position(v2(-1000.0, -1000.0))
|
|
zombie_body.set_velocity(v2(0.0, 0.0))
|
|
zombie_body.disable()
|
|
zombie_sprite = other.get_component(SpriteComponent)
|
|
if zombie_sprite:
|
|
zombie_sprite.set_position(v2(-1000.0, -1000.0))
|
|
break
|
|
|
|
|
|
class TopDownCharacter(GameObject):
|
|
"""Top-down character controlled by player input."""
|
|
def __init__(self, position: rl.Vector2, bullets: List["Bullet"], player_num: int) -> None:
|
|
"""Store spawn data, shared bullet pool, and player index.
|
|
|
|
Args:
|
|
position: Spawn position in pixels.
|
|
bullets: Shared list of Bullet objects to reuse.
|
|
player_num: Index used for controls and UI.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.position = position
|
|
self.bullets = bullets
|
|
self.player_num = player_num
|
|
self.health = 10
|
|
self.contact_timer = 1.0
|
|
self.contact_cooldown = 0.3
|
|
self.body: BodyComponent = None # type: ignore[assignment]
|
|
self.physics: PhysicsService = None # type: ignore[assignment]
|
|
self.sprite: SpriteComponent = None # type: ignore[assignment]
|
|
self.movement: TopDownMovementComponent = None # type: ignore[assignment]
|
|
self.sounds: MultiComponent = None # type: ignore[assignment]
|
|
self.shoot_sound: SoundComponent = None # type: ignore[assignment]
|
|
|
|
def init(self) -> None:
|
|
"""Create body, movement, sounds, and sprite.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.physics = self.scene.get_service(PhysicsService)
|
|
|
|
def build_body(component: BodyComponent):
|
|
"""Build body.
|
|
|
|
Args:
|
|
component: Parameter.
|
|
|
|
Returns:
|
|
Result of the operation.
|
|
"""
|
|
world = self.physics.world
|
|
body = world.CreateDynamicBody(position=(self.physics.convert_to_meters(self.position).x,
|
|
self.physics.convert_to_meters(self.position).y),
|
|
fixedRotation=True)
|
|
body.userData = self
|
|
shape = b2CircleShape(radius=self.physics.convert_length_to_meters(16.0))
|
|
body.CreateFixture(shape=shape, density=1.0)
|
|
component.body = body
|
|
|
|
self.body = self.add_component(BodyComponent(build=build_body))
|
|
|
|
params = TopDownMovementParams()
|
|
params.accel = 5000.0
|
|
params.friction = 5000.0
|
|
params.max_speed = 350.0
|
|
self.movement = self.add_component(TopDownMovementComponent(params))
|
|
|
|
self.sounds = self.add_component(MultiComponent())
|
|
self.shoot_sound = self.sounds.add_component("shoot", SoundComponent, "assets/sounds/shoot.wav")
|
|
|
|
self.sprite = self.add_component(SpriteComponent(f"assets/zombie_shooter/player_{self.player_num + 1}.png"))
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Handle movement, shooting, and damage over time.
|
|
|
|
Args:
|
|
delta_time: Seconds since last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
move = v2(0.0, 0.0)
|
|
move = v2(rl.get_gamepad_axis_movement(self.player_num, rl.GAMEPAD_AXIS_LEFT_X),
|
|
rl.get_gamepad_axis_movement(self.player_num, rl.GAMEPAD_AXIS_LEFT_Y))
|
|
|
|
if rl.is_key_down(rl.KEY_W) or rl.is_gamepad_button_down(self.player_num, rl.GAMEPAD_BUTTON_LEFT_FACE_UP):
|
|
move.y -= 1.0
|
|
if rl.is_key_down(rl.KEY_S) or rl.is_gamepad_button_down(self.player_num, rl.GAMEPAD_BUTTON_LEFT_FACE_DOWN):
|
|
move.y += 1.0
|
|
if rl.is_key_down(rl.KEY_A) or rl.is_gamepad_button_down(self.player_num, rl.GAMEPAD_BUTTON_LEFT_FACE_LEFT):
|
|
move.x -= 1.0
|
|
if rl.is_key_down(rl.KEY_D) or rl.is_gamepad_button_down(self.player_num, rl.GAMEPAD_BUTTON_LEFT_FACE_RIGHT):
|
|
move.x += 1.0
|
|
|
|
self.movement.set_input(move.x, move.y)
|
|
self.sprite.set_position(self.body.get_position_pixels())
|
|
self.sprite.set_rotation(self.movement.facing_dir)
|
|
|
|
if rl.is_key_pressed(rl.KEY_SPACE) or rl.is_gamepad_button_pressed(self.player_num, rl.GAMEPAD_BUTTON_RIGHT_FACE_RIGHT):
|
|
for bullet in self.bullets:
|
|
if not bullet.is_active:
|
|
self.shoot_sound.play()
|
|
char_pos = self.body.get_position_pixels()
|
|
shoot_dir = v2(math.cos(math.radians(self.movement.facing_dir)),
|
|
math.sin(math.radians(self.movement.facing_dir)))
|
|
bullet_start = v2(char_pos.x + shoot_dir.x * 48.0, char_pos.y + shoot_dir.y * 48.0)
|
|
bullet.body.set_position(bullet_start)
|
|
bullet.body.set_rotation(self.movement.facing_dir + 90.0)
|
|
bullet.body.set_velocity(v2(shoot_dir.x * bullet.speed, shoot_dir.y * bullet.speed))
|
|
bullet.is_active = True
|
|
break
|
|
|
|
for contact_body in self.body.get_contacts():
|
|
other = contact_body.userData
|
|
if other and other.has_tag("zombie"):
|
|
if self.contact_timer > 0.0:
|
|
self.contact_timer -= delta_time
|
|
if self.contact_timer <= 0.0:
|
|
self.health -= 1
|
|
self.contact_timer = self.contact_cooldown
|
|
if self.health <= 0:
|
|
self.is_active = False
|
|
self.body.set_position(v2(-1000.0, -1000.0))
|
|
self.body.set_velocity(v2(0.0, 0.0))
|
|
|
|
|
|
class Zombie(GameObject):
|
|
"""Enemy that chases the closest player."""
|
|
def __init__(self, players: List[TopDownCharacter]) -> None:
|
|
"""Store the list of players to chase.
|
|
|
|
Args:
|
|
players: Player characters to target.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.players = players
|
|
self.body: BodyComponent = None # type: ignore[assignment]
|
|
self.physics: PhysicsService = None # type: ignore[assignment]
|
|
self.sprite: SpriteComponent = None # type: ignore[assignment]
|
|
self.movement: TopDownMovementComponent = None # type: ignore[assignment]
|
|
|
|
def init(self) -> None:
|
|
"""Create body, movement, and sprite (starts inactive).
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.physics = self.scene.get_service(PhysicsService)
|
|
|
|
def build_body(component: BodyComponent):
|
|
"""Build body.
|
|
|
|
Args:
|
|
component: Parameter.
|
|
|
|
Returns:
|
|
Result of the operation.
|
|
"""
|
|
world = self.physics.world
|
|
body = world.CreateDynamicBody(position=(self.physics.convert_to_meters(v2(-1000.0, -1000.0)).x,
|
|
self.physics.convert_to_meters(v2(-1000.0, -1000.0)).y),
|
|
fixedRotation=True)
|
|
body.userData = self
|
|
shape = b2CircleShape(radius=self.physics.convert_length_to_meters(16.0))
|
|
body.CreateFixture(shape=shape, density=1.0)
|
|
body.active = False
|
|
component.body = body
|
|
|
|
self.body = self.add_component(BodyComponent(build=build_body))
|
|
|
|
params = TopDownMovementParams()
|
|
params.accel = 5000.0
|
|
params.friction = 5000.0
|
|
params.max_speed = 100.0
|
|
self.movement = self.add_component(TopDownMovementComponent(params))
|
|
|
|
self.sprite = self.add_component(SpriteComponent("assets/zombie_shooter/zombie.png"))
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Move toward the closest player and update sprite.
|
|
|
|
Args:
|
|
delta_time: Seconds since last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
closest_pos = v2(0.0, 0.0)
|
|
closest_dist_sq = float("inf")
|
|
for player in self.players:
|
|
player_pos = player.body.get_position_pixels()
|
|
to_player = vec_sub(player_pos, self.body.get_position_pixels())
|
|
dist_sq = to_player.x * to_player.x + to_player.y * to_player.y
|
|
if dist_sq < closest_dist_sq:
|
|
closest_dist_sq = dist_sq
|
|
closest_pos = player_pos
|
|
to_closest = vec_sub(closest_pos, self.body.get_position_pixels())
|
|
length = math.sqrt(to_closest.x * to_closest.x + to_closest.y * to_closest.y)
|
|
if length > 0.0:
|
|
to_closest.x /= length
|
|
to_closest.y /= length
|
|
self.movement.set_input(to_closest.x, to_closest.y)
|
|
self.sprite.set_position(self.body.get_position_pixels())
|
|
self.sprite.set_rotation(self.movement.facing_dir)
|
|
|
|
|
|
class Spawner(GameObject):
|
|
"""Spawner that activates zombies from a pool."""
|
|
def __init__(self, position: rl.Vector2, size: rl.Vector2, zombies: List[Zombie]) -> None:
|
|
"""Configure spawn region and zombie pool.
|
|
|
|
Args:
|
|
position: Center of the spawn rectangle in pixels.
|
|
size: Size of the spawn rectangle in pixels.
|
|
zombies: Pool of zombie objects to activate.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.spawn_timer = 0.0
|
|
self.spawn_interval = 1.0
|
|
self.position = vec_sub(position, vec_mul(size, 0.5))
|
|
self.size = size
|
|
self.zombie_pool = zombies
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Spawn zombies at an interval within a rectangle.
|
|
|
|
Args:
|
|
delta_time: Seconds since last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.spawn_timer -= delta_time
|
|
if self.spawn_timer <= 0.0:
|
|
self.spawn_timer = self.spawn_interval
|
|
x = self.position.x + float(rl.get_random_value(0, int(self.size.x)))
|
|
y = self.position.y + float(rl.get_random_value(0, int(self.size.y)))
|
|
spawn_pos = v2(x, y)
|
|
for zombie in self.zombie_pool:
|
|
if not zombie.is_active:
|
|
zombie.body.set_position(spawn_pos)
|
|
zombie.is_active = True
|
|
zombie.body.enable()
|
|
return
|
|
|
|
|
|
class ZombieScene(Scene):
|
|
"""Scene for the zombie shooter game."""
|
|
def __init__(self) -> None:
|
|
"""Initialize scene storage for services, actors, and render targets.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.font_manager: FontManager = None # type: ignore[assignment]
|
|
self.physics: PhysicsService = None # type: ignore[assignment]
|
|
self.level: LevelService = None # type: ignore[assignment]
|
|
self.renderer: rl.RenderTexture = None # type: ignore[assignment]
|
|
self.light_map: rl.RenderTexture = None # type: ignore[assignment]
|
|
self.light_texture: rl.Texture2D = None # type: ignore[assignment]
|
|
self.bullets: List[Bullet] = []
|
|
self.characters: List[TopDownCharacter] = []
|
|
self.zombies: List[Zombie] = []
|
|
|
|
def init_services(self) -> None:
|
|
"""Register services required by the scene.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.add_service(TextureService)
|
|
self.add_service(SoundService)
|
|
self.physics = self.add_service(PhysicsService, b2Vec2(0.0, 0.0))
|
|
collision_names = ["walls", "obstacles"]
|
|
self.level = self.add_service(LevelService, "assets/levels/top_down.ldtk", "Level", collision_names)
|
|
self.font_manager = self.game.get_manager(FontManager)
|
|
|
|
def init(self) -> None:
|
|
"""Create pools, characters, spawner, and render textures.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
for _ in range(100):
|
|
bullet = self.add_game_object(Bullet())
|
|
bullet.is_active = False
|
|
self.bullets.append(bullet)
|
|
|
|
player_entities = self.level.get_entities_by_name("Start")
|
|
for i, player_entity in enumerate(player_entities[:4]):
|
|
position = self.level.convert_to_pixels(player_entity.getPosition())
|
|
character = self.add_game_object(TopDownCharacter(position, self.bullets, i))
|
|
character.add_tag("player")
|
|
self.characters.append(character)
|
|
|
|
for _ in range(100):
|
|
zombie = self.add_game_object(Zombie(self.characters))
|
|
zombie.is_active = False
|
|
zombie.add_tag("zombie")
|
|
self.zombies.append(zombie)
|
|
|
|
spawn_entity = self.level.get_entities_by_name("Spawn")[0]
|
|
spawn_position = self.level.convert_to_pixels(spawn_entity.getPosition())
|
|
spawn_size = self.level.convert_to_pixels(spawn_entity.getSize())
|
|
self.add_game_object(Spawner(spawn_position, spawn_size, self.zombies))
|
|
|
|
self.level.set_layer_visibility("Foreground", False)
|
|
|
|
self.renderer = rl.load_render_texture(int(self.level.get_size().x), int(self.level.get_size().y))
|
|
self.light_map = rl.load_render_texture(int(self.level.get_size().x), int(self.level.get_size().y))
|
|
self.light_texture = self.get_service(TextureService).get_texture("assets/zombie_shooter/light.png")
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
# Trigger scene change on Enter key or gamepad start button.
|
|
if rl.is_key_pressed(rl.KEY_ENTER) or rl.is_gamepad_button_pressed(0, rl.GAMEPAD_BUTTON_MIDDLE_RIGHT):
|
|
self.game.go_to_scene_next()
|
|
|
|
def draw_scene(self) -> None:
|
|
"""Build light mask and render the final frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
|
|
"""Draw the scene.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
rl.begin_texture_mode(self.light_map)
|
|
rl.clear_background(rl.BLACK)
|
|
rl.rl_set_blend_factors(RLGL_SRC_ALPHA, RLGL_SRC_ALPHA, RLGL_MIN)
|
|
rl.rl_set_blend_mode(rl.BLEND_CUSTOM)
|
|
|
|
for i in range(min(4, len(self.characters))):
|
|
pos = self.characters[i].body.get_position_pixels()
|
|
rl.draw_texture(self.light_texture,
|
|
int(pos.x - self.light_texture.width / 2),
|
|
int(pos.y - self.light_texture.height / 2),
|
|
rl.WHITE)
|
|
|
|
rl.rl_draw_render_batch_active()
|
|
rl.rl_set_blend_mode(rl.BLEND_ALPHA)
|
|
rl.end_texture_mode()
|
|
|
|
rl.begin_texture_mode(self.renderer)
|
|
rl.clear_background(rl.Color(255, 0, 255, 255))
|
|
super().draw_scene()
|
|
self.level.draw_layer("Foreground")
|
|
rl.draw_texture_pro(self.light_map.texture,
|
|
rl.Rectangle(0.0, 0.0, float(self.light_map.texture.width), -float(self.light_map.texture.height)),
|
|
rl.Rectangle(0.0, 0.0, float(self.light_map.texture.width), float(self.light_map.texture.height)),
|
|
v2(0.0, 0.0),
|
|
0.0,
|
|
rl.color_alpha(rl.WHITE, 0.92))
|
|
rl.draw_rectangle(10, 10, 210, 210, rl.color_alpha(rl.WHITE, 0.3))
|
|
health_lines = [f"Health: {char.health}" for char in self.characters[:4]]
|
|
rl.draw_text_ex(self.font_manager.get_font("Roboto"),
|
|
"\n".join(health_lines),
|
|
v2(20.0, 20.0),
|
|
45.0,
|
|
1.0,
|
|
rl.Color(230, 41, 55, 255))
|
|
rl.end_texture_mode()
|
|
|
|
rl.draw_texture_pro(self.renderer.texture,
|
|
rl.Rectangle(0.0, 0.0, float(self.renderer.texture.width), -float(self.renderer.texture.height)),
|
|
rl.Rectangle(0.0, 0.0, float(rl.get_screen_width()), float(rl.get_screen_height())),
|
|
v2(0.0, 0.0),
|
|
0.0,
|
|
rl.WHITE)
|