guitarHeroButBetter/py/samples/fighting_game.py

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))