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

496 lines
21 KiB
Python

"""Demonstration of split screen cameras and sensor-based item collection."""
from __future__ import annotations
import math
from typing import List
from Box2D import b2CircleShape, b2PolygonShape
import pyray as rl
from engine.framework import GameObject, Scene
from engine.math_extensions import vec_div, vec_mul, vec_normalize, vec_sub, v2
from engine.prefabs.components import (AnimationController, BodyComponent, MultiComponent,
PlatformerMovementComponent, PlatformerMovementParams,
SoundComponent)
from engine.prefabs.game_objects import CharacterParams, SplitCamera
from engine.prefabs.managers import FontManager, WindowManager
from engine.prefabs.services import LevelService, PhysicsService, SoundService, TextureService
class CollectingCharacter(GameObject):
"""Basic collecting character.
Shows how to build a physics body, route input into a movement
component, and drive animations/sounds from gameplay events."""
def __init__(self, params: CharacterParams, player_number: int = 1) -> None:
"""Create a player-controlled collector.
Args:
params: Character sizing and physics parameters.
player_number: 1-based index used to map input/skins.
Returns:
None
"""
super().__init__()
self.p = params
self.player_number = player_number
self.gamepad = player_number - 1
self.width = params.width
self.height = params.height
self.physics: PhysicsService = None # type: ignore[assignment]
self.level: LevelService = None # type: ignore[assignment]
self.body: BodyComponent = None # type: ignore[assignment]
self.movement: PlatformerMovementComponent = None # type: ignore[assignment]
self.animation: AnimationController = None # type: ignore[assignment]
self.sounds: MultiComponent = None # type: ignore[assignment]
self.jump_sound: SoundComponent = None # type: ignore[assignment]
self.die_sound: SoundComponent = None # type: ignore[assignment]
self.score = 0
def init(self) -> None:
"""Initialize physics, movement, sounds, and animations.
Services are resolved here (not during update) so missing services are
discovered early and per-frame overhead is avoided.
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.p.position).x,
self.physics.convert_to_meters(self.p.position).y),
fixedRotation=True,
bullet=True)
body.userData = self
shape = b2PolygonShape(box=(self.physics.convert_length_to_meters(self.p.width / 2.0),
self.physics.convert_length_to_meters(self.p.height / 2.0)))
body.CreateFixture(shape=shape, density=self.p.density, friction=self.p.friction,
restitution=self.p.restitution)
component.body = body
self.body = self.add_component(BodyComponent(build=build_body))
movement_params = PlatformerMovementParams()
movement_params.width = self.p.width
movement_params.height = self.p.height
self.movement = self.add_component(PlatformerMovementComponent(movement_params))
self.level = self.scene.get_service(LevelService)
self.sounds = self.add_component(MultiComponent())
self.jump_sound = self.sounds.add_component("jump", SoundComponent, "assets/sounds/jump.wav")
self.die_sound = self.sounds.add_component("die", SoundComponent, "assets/sounds/die.wav")
self.animation = self.add_component(AnimationController(self.body))
if self.player_number == 1:
self.animation.add_animation_from_files("run",
["assets/pixel_platformer/characters/green_1.png",
"assets/pixel_platformer/characters/green_2.png"],
10.0)
elif self.player_number == 2:
self.animation.add_animation_from_files("run",
["assets/pixel_platformer/characters/blue_1.png",
"assets/pixel_platformer/characters/blue_2.png"],
10.0)
elif self.player_number == 3:
self.animation.add_animation_from_files("run",
["assets/pixel_platformer/characters/pink_1.png",
"assets/pixel_platformer/characters/pink_2.png"],
10.0)
elif self.player_number == 4:
self.animation.add_animation_from_files("run",
["assets/pixel_platformer/characters/yellow_1.png",
"assets/pixel_platformer/characters/yellow_2.png"],
10.0)
def update(self, delta_time: float) -> None:
"""Handle input and drive movement/animation.
Args:
delta_time: Seconds since last frame.
Returns:
None
"""
deadzone = 0.1
jump_pressed = rl.is_key_pressed(rl.KEY_W) or rl.is_gamepad_button_pressed(self.gamepad, rl.GAMEPAD_BUTTON_RIGHT_FACE_DOWN)
jump_held = rl.is_key_down(rl.KEY_W) or rl.is_gamepad_button_down(self.gamepad, rl.GAMEPAD_BUTTON_RIGHT_FACE_DOWN)
move_x = rl.get_gamepad_axis_movement(self.gamepad, rl.GAMEPAD_AXIS_LEFT_X)
if abs(move_x) < deadzone:
move_x = 0.0
if rl.is_key_down(rl.KEY_D) or rl.is_gamepad_button_down(self.gamepad, rl.GAMEPAD_BUTTON_LEFT_FACE_RIGHT):
move_x = 1.0
elif rl.is_key_down(rl.KEY_A) or rl.is_gamepad_button_down(self.gamepad, rl.GAMEPAD_BUTTON_LEFT_FACE_LEFT):
move_x = -1.0
self.movement.set_input(move_x, jump_pressed, jump_held)
if self.movement.grounded and jump_pressed:
self.jump_sound.play()
if abs(self.movement.move_x) > 0.1:
self.animation.play("run")
self.animation.flip_x = self.movement.move_x > 0.0
else:
self.animation.pause()
def die(self) -> None:
"""Respawn the character at the start position.
Returns:
None
"""
self.body.set_position(self.p.position)
self.body.set_velocity(v2(0.0, 0.0))
self.die_sound.play()
class EnemyType:
Bat = 0
DrillHead = 1
BlockHead = 2
class Enemy(GameObject):
"""Enemy that patrols between two points using a kinematic body."""
def __init__(self, enemy_type: int, start: rl.Vector2, end: rl.Vector2) -> None:
"""Configure the patrol endpoints and enemy type.
Args:
enemy_type: EnemyType constant selecting animation and behavior.
start: Starting world position in pixels.
end: Ending world position in pixels.
Returns:
None
"""
super().__init__()
self.start = start
self.end = end
self.type = enemy_type
self.physics: PhysicsService = None # type: ignore[assignment]
self.body: BodyComponent = None # type: ignore[assignment]
self.animation: AnimationController = None # type: ignore[assignment]
self.radius = 12.0
def init_object(self) -> None:
"""Create a sensor body, setup animation, and start movement.
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.CreateKinematicBody(position=(self.physics.convert_to_meters(self.start).x,
self.physics.convert_to_meters(self.start).y))
body.userData = self
shape = b2CircleShape(radius=self.physics.convert_length_to_meters(self.radius))
body.CreateFixture(shape=shape, density=1.0, isSensor=True)
component.body = body
self.body = self.add_component(BodyComponent(build=build_body))
self.animation = self.add_component(AnimationController(self.body))
if self.type == EnemyType.Bat:
self.animation.add_animation_from_files("move",
["assets/pixel_platformer/enemies/bat_1.png",
"assets/pixel_platformer/enemies/bat_2.png",
"assets/pixel_platformer/enemies/bat_3.png"],
5.0)
elif self.type == EnemyType.DrillHead:
self.animation.add_animation_from_files("move",
["assets/pixel_platformer/enemies/drill_head_1.png",
"assets/pixel_platformer/enemies/drill_head_2.png"],
5.0)
elif self.type == EnemyType.BlockHead:
self.animation.add_animation_from_files("move",
["assets/pixel_platformer/enemies/block_head_1.png",
"assets/pixel_platformer/enemies/block_head_2.png"],
5.0)
self.animation.play("move")
super().init_object()
to_end = vec_normalize(vec_sub(self.end, self.body.get_position_pixels()))
self.body.set_velocity(vec_mul(to_end, 50.0))
def update(self, delta_time: float) -> None:
"""Move between endpoints and detect sensor hits on players.
Args:
delta_time: Seconds since last frame.
Returns:
None
"""
pos = self.body.get_position_pixels()
if math.dist((self.end.x, self.end.y), (pos.x, pos.y)) <= self.radius * 2.0:
to_start = vec_normalize(vec_sub(self.start, pos))
self.body.set_velocity(vec_mul(to_start, 50.0))
elif math.dist((self.start.x, self.start.y), (pos.x, pos.y)) <= self.radius * 2.0:
to_end = vec_normalize(vec_sub(self.end, pos))
self.body.set_velocity(vec_mul(to_end, 50.0))
for contact_body in self.body.get_sensor_overlaps():
user_data = contact_body.userData
if user_data and user_data.has_tag("character"):
user_data.die()
velocity = self.body.get_velocity_pixels()
self.animation.flip_x = velocity.x > 0.0
class Coin(GameObject):
"""Collectible coin using a sensor body."""
def __init__(self, position: rl.Vector2) -> None:
"""Store the coin spawn position.
Args:
position: World position in pixels.
Returns:
None
"""
super().__init__()
self.position = position
self.physics: PhysicsService = None # type: ignore[assignment]
self.body: BodyComponent = None # type: ignore[assignment]
self.animation: AnimationController = None # type: ignore[assignment]
self.collect_sound: SoundComponent = None # type: ignore[assignment]
def init(self) -> None:
"""Create the sensor body, animation, and pickup 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.CreateStaticBody(position=(self.physics.convert_to_meters(self.position).x,
self.physics.convert_to_meters(self.position).y))
body.userData = self
shape = b2CircleShape(radius=self.physics.convert_length_to_meters(8.0))
body.CreateFixture(shape=shape, density=1.0, isSensor=True)
component.body = body
self.body = self.add_component(BodyComponent(build=build_body))
self.animation = self.add_component(AnimationController(self.body))
self.animation.add_animation_from_files("spin",
["assets/pixel_platformer/items/coin_1.png",
"assets/pixel_platformer/items/coin_2.png"],
5.0)
self.animation.play("spin")
self.collect_sound = self.add_component(SoundComponent("assets/sounds/coin.wav"))
def update(self, delta_time: float) -> None:
"""Check sensor overlaps and award score on pickup.
Args:
delta_time: Seconds since last frame.
Returns:
None
"""
for contact_body in self.body.get_sensor_overlaps():
user_data = contact_body.userData
if user_data and user_data.has_tag("character"):
self.collect_sound.play()
self.is_active = False
self.body.disable()
user_data.score += 1
break
class CollectingScene(Scene):
"""Scene demonstrating split-screen cameras and collectible items."""
def __init__(self) -> None:
"""Set up scene containers and cached services.
Returns:
None
"""
super().__init__()
self.window_manager: WindowManager = None # type: ignore[assignment]
self.font_manager: FontManager = None # type: ignore[assignment]
self.characters: List[CollectingCharacter] = []
self.level: LevelService = None # type: ignore[assignment]
self.physics: PhysicsService = None # type: ignore[assignment]
self.cameras: List[SplitCamera] = []
self.screen_size = v2(0.0, 0.0)
self.scale = 2.5
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)
collision_names = ["walls", "clouds", "trees"]
self.level = self.add_service(LevelService, "assets/levels/collecting.ldtk", "Level", collision_names)
def init(self) -> None:
"""Create characters, enemies, coins, and cameras.
Returns:
None
"""
self.window_manager = self.game.get_manager(WindowManager)
self.font_manager = self.game.get_manager(FontManager)
entities_layer = self.level.get_layer_by_name("Entities")
player_entities = self.level.get_entities_by_name("Start")
for i, player_entity in enumerate(player_entities[:4]):
params = CharacterParams()
params.position = self.level.convert_to_pixels(player_entity.getPosition())
params.width = 16
params.height = 24
character = self.add_game_object(CollectingCharacter(params, i + 1))
character.add_tag("character")
self.characters.append(character)
for bat_entity in self.level.get_entities_by_name("Bat"):
start_pos = self.level.convert_to_pixels(bat_entity.getPosition())
end_point = bat_entity.getField("end")
end_pos = self.level.convert_cells_to_pixels(end_point, entities_layer)
enemy = self.add_game_object(Enemy(EnemyType.Bat, start_pos, end_pos))
enemy.add_tag("enemy")
for drill_entity in self.level.get_entities_by_name("DrillHead"):
start_pos = self.level.convert_to_pixels(drill_entity.getPosition())
end_point = drill_entity.getField("end")
end_pos = self.level.convert_cells_to_pixels(end_point, entities_layer)
enemy = self.add_game_object(Enemy(EnemyType.DrillHead, start_pos, end_pos))
enemy.add_tag("enemy")
for block_entity in self.level.get_entities_by_name("BlockHead"):
start_pos = self.level.convert_to_pixels(block_entity.getPosition())
end_point = block_entity.getField("end")
end_pos = self.level.convert_cells_to_pixels(end_point, entities_layer)
enemy = self.add_game_object(Enemy(EnemyType.BlockHead, start_pos, end_pos))
enemy.add_tag("enemy")
for coin_entity in self.level.get_entities_by_name("Coin"):
coin_pos = self.level.convert_to_pixels(coin_entity.getPosition())
coin = self.add_game_object(Coin(coin_pos))
coin.add_tag("coin")
self.screen_size = v2(self.window_manager.get_width(), self.window_manager.get_height())
for _ in self.characters:
cam = self.add_game_object(SplitCamera(vec_div(self.screen_size, self.scale), self.level.get_size()))
self.cameras.append(cam)
def update(self, delta_time: float) -> None:
"""Update camera targets and handle window resizing.
Args:
delta_time: Seconds since last frame.
Returns:
None
"""
for idx, camera in enumerate(self.cameras):
camera.target = self.characters[idx].body.get_position_pixels()
new_screen_size = v2(float(rl.get_screen_width()), float(rl.get_screen_height()))
if new_screen_size.x != self.screen_size.x or new_screen_size.y != self.screen_size.y:
self.screen_size = new_screen_size
screen_scale = self.window_manager.get_width() / self.screen_size.x
for camera in self.cameras:
camera.size = vec_mul(vec_div(self.screen_size, self.scale), screen_scale)
camera.camera.offset = v2(camera.size.x / 2.0, camera.size.y / 2.0)
if camera.renderer:
rl.unload_render_texture(camera.renderer)
camera.renderer = rl.load_render_texture(int(camera.size.x), int(camera.size.y))
# 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:
"""Render the scene once per camera and composite the split view.
Returns:
None
"""
for camera in self.cameras:
camera.draw_begin()
super().draw_scene()
camera.draw_end()
rl.clear_background(rl.MAGENTA)
for i, camera in enumerate(self.cameras):
if i == 0:
camera.draw_texture_pro(0, 0, self.screen_size.x / 2.0, self.screen_size.y / 2.0)
rl.draw_text_ex(self.font_manager.get_font("Tiny5"),
f"Score: {self.characters[0].score}",
v2(20.0, 20.0),
40.0,
2.0,
rl.BLACK)
elif i == 1:
camera.draw_texture_pro(self.screen_size.x / 2.0, 0, self.screen_size.x / 2.0, self.screen_size.y / 2.0)
rl.draw_text_ex(self.font_manager.get_font("Tiny5"),
f"Score: {self.characters[1].score}",
v2(self.screen_size.x / 2.0 + 20.0, 20.0),
40.0,
2.0,
rl.BLACK)
elif i == 2:
camera.draw_texture_pro(0, self.screen_size.y / 2.0, self.screen_size.x / 2.0, self.screen_size.y / 2.0)
rl.draw_text_ex(self.font_manager.get_font("Tiny5"),
f"Score: {self.characters[2].score}",
v2(20.0, self.screen_size.y / 2.0 + 20.0),
40.0,
2.0,
rl.BLACK)
elif i == 3:
camera.draw_texture_pro(self.screen_size.x / 2.0,
self.screen_size.y / 2.0,
self.screen_size.x / 2.0,
self.screen_size.y / 2.0)
rl.draw_text_ex(self.font_manager.get_font("Tiny5"),
f"Score: {self.characters[3].score}",
v2(self.screen_size.x / 2.0 + 20.0, self.screen_size.y / 2.0 + 20.0),
40.0,
2.0,
rl.BLACK)
rl.draw_line_ex(v2(self.screen_size.x / 2.0, 0), v2(self.screen_size.x / 2.0, self.screen_size.y), 4.0, rl.Color(130, 130, 130, 255))
rl.draw_line_ex(v2(0, self.screen_size.y / 2.0), v2(self.screen_size.x, self.screen_size.y / 2.0), 4.0, rl.Color(130, 130, 130, 255))