guitarHeroButBetter/py/samples/zombie_game.py
James Whiteman 3f7b409302 Python code
2026-01-30 21:16:42 -08:00

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)