480 lines
23 KiB
Python
480 lines
23 KiB
Python
"""Demonstration of a shared camera and basic fighting mechanics.
|
|
|
|
Shows LDtk level setup, one-way platforms, and simple combat interactions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import List
|
|
|
|
from Box2D import b2ContactListener, b2PolygonShape, b2Vec2
|
|
import pyray as rl
|
|
|
|
from engine.framework import GameObject, Scene
|
|
from engine.math_extensions import vec_add, vec_div, vec_mul, vec_sub, v2
|
|
from engine.prefabs.components import (AnimationController, BodyComponent, MultiComponent,
|
|
PlatformerMovementComponent, PlatformerMovementParams,
|
|
SoundComponent)
|
|
from engine.prefabs.game_objects import CameraObject, CharacterParams, StaticBox
|
|
from engine.prefabs.services import LevelService, PhysicsService, SoundService, TextureService
|
|
|
|
|
|
class FightingCharacter(GameObject):
|
|
"""Basic fighting character with attacks and one-way platform logic."""
|
|
def __init__(self, params: CharacterParams, player_number: int = 1) -> None:
|
|
"""Create a player-controlled fighter.
|
|
|
|
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.hit_sound: SoundComponent = None # type: ignore[assignment]
|
|
self.die_sound: SoundComponent = None # type: ignore[assignment]
|
|
self.guitar_hero_components: None
|
|
self.fall_through = False
|
|
self.fall_through_timer = 0.0
|
|
self.fall_through_duration = 0.2
|
|
self.attack_display_timer = 0.0
|
|
self.attack_display_duration = 0.1
|
|
self.attack = False
|
|
|
|
def init(self) -> None:
|
|
"""Initialize body, movement, sounds, and animations.
|
|
|
|
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)
|
|
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.hit_sound = self.sounds.add_component("hit", SoundComponent, "assets/sounds/hit.wav")
|
|
self.die_sound = self.sounds.add_component("die", SoundComponent, "assets/sounds/die.wav")
|
|
|
|
# TODO, james add more buttons
|
|
guitar_hero_key_bindings = {
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_UP: "assets/sounds/nes_harp/nes_harp_A4.wav",
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_DOWN: "assets/sounds/nes_harp/nes_harp_C4.wav",
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_LEFT: "assets/sounds/nes_harp/nes_harp_D4.wav",
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_RIGHT: "assets/sounds/nes_harp/nes_harp_G4.wav",
|
|
}
|
|
self.guitar_hero_components = {}
|
|
for key_press, wav_file in guitar_hero_key_bindings.items():
|
|
self.guitar_hero_components[key_press] = self.sounds.add_component(key_press, SoundComponent, wav_file)
|
|
|
|
self.animation = self.add_component(AnimationController(self.body))
|
|
if self.player_number == 1:
|
|
self.animation.add_animation_from_files("run",
|
|
["assets/sunnyland/fox/run-1.png",
|
|
"assets/sunnyland/fox/run-2.png",
|
|
"assets/sunnyland/fox/run-3.png",
|
|
"assets/sunnyland/fox/run-4.png",
|
|
"assets/sunnyland/fox/run-5.png",
|
|
"assets/sunnyland/fox/run-6.png"],
|
|
10.0)
|
|
self.animation.add_animation_from_files("idle",
|
|
["assets/sunnyland/fox/idle-1.png",
|
|
"assets/sunnyland/fox/idle-2.png",
|
|
"assets/sunnyland/fox/idle-3.png",
|
|
"assets/sunnyland/fox/idle-4.png"],
|
|
5.0)
|
|
self.animation.add_animation_from_files("jump", ["assets/sunnyland/fox/jump-1.png"], 0.0)
|
|
self.animation.add_animation_from_files("fall", ["assets/sunnyland/fox/jump-2.png"], 0.0)
|
|
self.animation.origin.y += 4
|
|
elif self.player_number == 2:
|
|
self.animation.add_animation_from_files("run",
|
|
["assets/sunnyland/bunny/run-1.png",
|
|
"assets/sunnyland/bunny/run-2.png",
|
|
"assets/sunnyland/bunny/run-3.png",
|
|
"assets/sunnyland/bunny/run-4.png",
|
|
"assets/sunnyland/bunny/run-5.png",
|
|
"assets/sunnyland/bunny/run-6.png"],
|
|
10.0)
|
|
self.animation.add_animation_from_files("idle",
|
|
["assets/sunnyland/bunny/idle-1.png",
|
|
"assets/sunnyland/bunny/idle-2.png",
|
|
"assets/sunnyland/bunny/idle-3.png",
|
|
"assets/sunnyland/bunny/idle-4.png"],
|
|
10.0)
|
|
self.animation.add_animation_from_files("jump", ["assets/sunnyland/bunny/jump-1.png"], 0.0)
|
|
self.animation.add_animation_from_files("fall", ["assets/sunnyland/bunny/jump-2.png"], 0.0)
|
|
self.animation.origin.y += 8
|
|
elif self.player_number == 3:
|
|
self.animation.add_animation_from_files("run",
|
|
["assets/sunnyland/squirrel/run-1.png",
|
|
"assets/sunnyland/squirrel/run-2.png",
|
|
"assets/sunnyland/squirrel/run-3.png",
|
|
"assets/sunnyland/squirrel/run-4.png",
|
|
"assets/sunnyland/squirrel/run-5.png",
|
|
"assets/sunnyland/squirrel/run-6.png"],
|
|
10.0)
|
|
self.animation.add_animation_from_files("idle",
|
|
["assets/sunnyland/squirrel/idle-1.png",
|
|
"assets/sunnyland/squirrel/idle-2.png",
|
|
"assets/sunnyland/squirrel/idle-3.png",
|
|
"assets/sunnyland/squirrel/idle-4.png",
|
|
"assets/sunnyland/squirrel/idle-5.png",
|
|
"assets/sunnyland/squirrel/idle-6.png",
|
|
"assets/sunnyland/squirrel/idle-7.png",
|
|
"assets/sunnyland/squirrel/idle-8.png"],
|
|
8.0)
|
|
self.animation.add_animation_from_files("jump",
|
|
["assets/sunnyland/squirrel/jump-1.png",
|
|
"assets/sunnyland/squirrel/jump-2.png",
|
|
"assets/sunnyland/squirrel/jump-3.png",
|
|
"assets/sunnyland/squirrel/jump-4.png"],
|
|
15.0)
|
|
self.animation.origin.y += 7
|
|
elif self.player_number == 4:
|
|
self.animation.add_animation_from_files("run",
|
|
["assets/sunnyland/imp/run-1.png",
|
|
"assets/sunnyland/imp/run-2.png",
|
|
"assets/sunnyland/imp/run-3.png",
|
|
"assets/sunnyland/imp/run-4.png",
|
|
"assets/sunnyland/imp/run-5.png",
|
|
"assets/sunnyland/imp/run-6.png",
|
|
"assets/sunnyland/imp/run-7.png",
|
|
"assets/sunnyland/imp/run-8.png"],
|
|
10.0)
|
|
self.animation.add_animation_from_files("idle",
|
|
["assets/sunnyland/imp/idle-1.png",
|
|
"assets/sunnyland/imp/idle-2.png",
|
|
"assets/sunnyland/imp/idle-3.png",
|
|
"assets/sunnyland/imp/idle-4.png"],
|
|
10.0)
|
|
self.animation.add_animation_from_files("jump", ["assets/sunnyland/imp/jump-1.png"], 0.0)
|
|
self.animation.add_animation_from_files("fall", ["assets/sunnyland/imp/jump-4.png"], 0.0)
|
|
self.animation.origin.y += 10
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Handle input, jumping, attacks, and respawn logic.
|
|
|
|
Args:
|
|
delta_time: Seconds since last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
|
|
# Add more buttons here
|
|
gamepad_buttons = [
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_UP,
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_DOWN,
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_LEFT,
|
|
rl.GAMEPAD_BUTTON_RIGHT_FACE_RIGHT,
|
|
]
|
|
for button in gamepad_buttons:
|
|
if rl.is_gamepad_button_pressed(self.gamepad, button):
|
|
self.guitar_hero_components[button].play()
|
|
|
|
|
|
return
|
|
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.play("idle")
|
|
|
|
if not self.movement.grounded:
|
|
if self.player_number != 3:
|
|
if self.body.get_velocity_meters().y < 0.0:
|
|
self.animation.play("jump")
|
|
else:
|
|
self.animation.play("fall")
|
|
else:
|
|
self.animation.play("jump")
|
|
|
|
move_y = rl.get_gamepad_axis_movement(self.gamepad, rl.GAMEPAD_AXIS_LEFT_Y)
|
|
if rl.is_key_pressed(rl.KEY_S) or rl.is_gamepad_button_pressed(self.gamepad, rl.GAMEPAD_BUTTON_LEFT_FACE_DOWN) or move_y > 0.5:
|
|
self.fall_through = True
|
|
self.fall_through_timer = self.fall_through_duration
|
|
|
|
if self.fall_through_timer > 0.0:
|
|
self.fall_through_timer = max(0.0, self.fall_through_timer - delta_time)
|
|
if self.fall_through_timer == 0.0:
|
|
self.fall_through = False
|
|
|
|
if rl.is_key_pressed(rl.KEY_SPACE) or rl.is_gamepad_button_pressed(self.gamepad, rl.GAMEPAD_BUTTON_RIGHT_FACE_RIGHT):
|
|
self.attack = True
|
|
self.attack_display_timer = self.attack_display_duration
|
|
position = self.body.get_position_pixels()
|
|
position.x += (self.width / 2.0 + 8.0) * (-1.0 if self.animation.flip_x else 1.0)
|
|
bodies = self.physics.circle_overlap(position, 8.0, self.body.body)
|
|
for other_body in bodies:
|
|
if other_body == self.body.body:
|
|
continue
|
|
impulse = b2Vec2(-10.0 if self.animation.flip_x else 10.0, -10.0)
|
|
other_body.ApplyLinearImpulse(impulse=impulse, point=other_body.worldCenter, wake=True)
|
|
self.hit_sound.play()
|
|
|
|
if self.attack_display_timer > 0.0:
|
|
self.attack_display_timer = max(0.0, self.attack_display_timer - delta_time)
|
|
if self.attack_display_timer == 0.0:
|
|
self.attack = False
|
|
|
|
if self.body.get_position_pixels().y > self.level.get_size().y + 200.0:
|
|
self.body.set_position(self.p.position)
|
|
self.body.set_velocity(v2(0.0, 0.0))
|
|
self.die_sound.play()
|
|
|
|
def draw(self) -> None:
|
|
"""Draw attack indicator (animations are drawn by controller).
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.attack:
|
|
position = self.body.get_position_pixels()
|
|
position.x += (self.width / 2.0 + 8.0) * (-1.0 if self.animation.flip_x else 1.0)
|
|
rl.draw_circle_v(position, 8.0, rl.Color(230, 41, 55, 128))
|
|
|
|
def pre_solve(self, body_a, body_b, contact, platforms: List[StaticBox]) -> bool:
|
|
"""Custom pre-solve for one-way platforms.
|
|
|
|
Args:
|
|
body_a: First body in the contact.
|
|
body_b: Second body in the contact.
|
|
contact: Box2D contact instance.
|
|
platforms: List of one-way platform StaticBox objects.
|
|
|
|
Returns:
|
|
True to enable contact, False to disable it.
|
|
"""
|
|
return True
|
|
normal = contact.worldManifold.normal
|
|
other = None
|
|
sign = 0.0
|
|
if body_a == self.body.body:
|
|
sign = 1.0
|
|
other = body_b
|
|
elif body_b == self.body.body:
|
|
sign = -1.0
|
|
other = body_a
|
|
if sign * normal.y < 0.5:
|
|
return False
|
|
if self.fall_through:
|
|
for platform in platforms:
|
|
if other == platform.body:
|
|
return False
|
|
return True
|
|
|
|
|
|
class FightingContactListener(b2ContactListener):
|
|
"""Routes Box2D PreSolve callbacks to the owning character."""
|
|
def __init__(self, scene: "FightingScene") -> None:
|
|
"""Capture the scene so contacts can be filtered.
|
|
|
|
Args:
|
|
scene: Scene containing fighters and one-way platforms.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.scene = scene
|
|
|
|
def PreSolve(self, contact, old_manifold):
|
|
"""Dispatch pre-solve handling to the matching character.
|
|
|
|
Args:
|
|
contact: Box2D contact.
|
|
old_manifold: Previous contact manifold.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
body_a = contact.fixtureA.body
|
|
body_b = contact.fixtureB.body
|
|
for character in self.scene.characters:
|
|
if body_a == character.body.body or body_b == character.body.body:
|
|
enabled = character.pre_solve(body_a, body_b, contact, self.scene.platforms)
|
|
contact.enabled = enabled
|
|
return
|
|
|
|
|
|
class FightingScene(Scene):
|
|
"""Scene demonstrating shared camera and arena combat."""
|
|
def __init__(self) -> None:
|
|
"""Initialize scene storage for platforms, fighters, and services.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.platforms: List[StaticBox] = []
|
|
self.characters: List[FightingCharacter] = []
|
|
self.level: LevelService = None # type: ignore[assignment]
|
|
self.physics: PhysicsService = None # type: ignore[assignment]
|
|
self.camera: CameraObject = None # type: ignore[assignment]
|
|
self.renderer = None
|
|
self.render_rect = None
|
|
|
|
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"]
|
|
self.level = self.add_service(LevelService, "assets/levels/fighting.ldtk", "Stage", collision_names)
|
|
|
|
def init(self) -> None:
|
|
"""Create platforms, players, camera, and render target.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
platform_entities = self.level.get_entities_by_name("One_way_platform")
|
|
for platform_entity in platform_entities:
|
|
position = self.level.convert_to_pixels(platform_entity.getPosition())
|
|
size = self.level.convert_to_pixels(platform_entity.getSize())
|
|
platform = self.add_game_object(StaticBox.from_vectors(vec_add(position, vec_div(size, 2.0)), size))
|
|
platform.is_visible = False
|
|
platform.add_tag("platform")
|
|
self.platforms.append(platform)
|
|
|
|
if self.physics.world:
|
|
self.physics.world.contactListener = FightingContactListener(self)
|
|
|
|
player_entities = self.level.get_entities_by_name("Start")
|
|
for i, player_entity in enumerate(player_entities[:1]):
|
|
params = CharacterParams()
|
|
params.position = self.level.convert_to_pixels(player_entity.getPosition())
|
|
params.width = 16
|
|
params.height = 24
|
|
character = self.add_game_object(FightingCharacter(params, i + 1))
|
|
character.add_tag("character")
|
|
self.characters.append(character)
|
|
|
|
self.camera = self.add_game_object(CameraObject(self.level.get_size(), v2(0.0, 0.0), v2(300.0, 300.0), 0.0, 0.0, 0.0, 0.0))
|
|
self.camera.target = vec_div(self.level.get_size(), 2.0)
|
|
|
|
self.level.set_layer_visibility("Background", False)
|
|
self.renderer = rl.load_render_texture(int(self.level.get_size().x), int(self.level.get_size().y))
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Update camera framing and compute render placement.
|
|
|
|
Args:
|
|
delta_time: Seconds since last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
avg_position = v2(0.0, 0.0)
|
|
min_point = v2(float("inf"), float("inf"))
|
|
max_point = v2(float("-inf"), float("-inf"))
|
|
for character in self.characters:
|
|
char_pos = character.body.get_position_pixels()
|
|
avg_position = vec_add(avg_position, char_pos)
|
|
min_point.x = min(min_point.x, char_pos.x)
|
|
min_point.y = min(min_point.y, char_pos.y)
|
|
max_point.x = max(max_point.x, char_pos.x)
|
|
max_point.y = max(max_point.y, char_pos.y)
|
|
avg_position = vec_div(avg_position, float(len(self.characters)))
|
|
self.camera.target = avg_position
|
|
self.camera.target.x = math.floor(self.camera.target.x)
|
|
self.camera.target.y = math.floor(self.camera.target.y)
|
|
|
|
distance = math.sqrt((max_point.x - min_point.x) ** 2 + (max_point.y - min_point.y) ** 2)
|
|
level_diag = math.sqrt(self.level.get_size().x ** 2 + self.level.get_size().y ** 2)
|
|
zoom = level_diag / (distance + 400.0)
|
|
zoom = max(0.5, min(2.0, zoom))
|
|
self.camera.camera.zoom += (zoom - self.camera.camera.zoom) * min(1.0, delta_time * 5.0)
|
|
|
|
aspect_ratio = self.level.get_size().x / self.level.get_size().y
|
|
render_scale = rl.get_screen_height() / self.level.get_size().y
|
|
render_size = v2(self.level.get_size().y * render_scale * aspect_ratio, self.level.get_size().y * render_scale)
|
|
pos = vec_div(vec_sub(v2(float(rl.get_screen_width()), float(rl.get_screen_height())), render_size), 2.0)
|
|
self.render_rect = rl.Rectangle(pos.x, pos.y, render_size.x, render_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 world to a texture and draw it centered.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
rl.begin_texture_mode(self.renderer)
|
|
rl.clear_background(rl.MAGENTA)
|
|
self.level.draw_layer("Background")
|
|
self.camera.draw_begin()
|
|
super().draw_scene()
|
|
self.camera.draw_end()
|
|
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)),
|
|
self.render_rect,
|
|
v2(0.0, 0.0),
|
|
0.0,
|
|
rl.Color(255, 255, 255, 255))
|