1215 lines
34 KiB
Python
1215 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from Box2D import (b2Body, b2CircleShape, b2FixtureDef, b2PolygonShape,
|
|
b2Vec2)
|
|
import pyray as rl
|
|
|
|
from engine.framework import Component
|
|
from engine.math_extensions import vec_add, vec_div, vec_len, vec_mul, vec_normalize, vec_sub, v2
|
|
from engine.raycasts import raycast_closest
|
|
from engine.prefabs.managers import FontManager
|
|
from engine.prefabs.services import PhysicsService, SoundService, TextureService
|
|
|
|
|
|
class MultiComponent(Component):
|
|
"""Container component that allows multiple components of the same type."""
|
|
def __init__(self) -> None:
|
|
""" init .
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.components: Dict[str, Component] = {}
|
|
|
|
def init(self) -> None:
|
|
"""Initialize all contained components.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
for component in self.components.values():
|
|
component.init()
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Update all contained components.
|
|
|
|
Args:
|
|
delta_time: Seconds since the last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
for component in self.components.values():
|
|
component.update(delta_time)
|
|
|
|
def draw(self) -> None:
|
|
"""Draw all contained components.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
for component in self.components.values():
|
|
component.draw()
|
|
|
|
def add_component(self, name: str, component_or_cls: Any, *args: Any, **kwargs: Any) -> Component:
|
|
"""Add a component under a name.
|
|
|
|
Args:
|
|
name: Component name key.
|
|
component_or_cls: Component instance or class.
|
|
*args: Positional args forwarded to constructor.
|
|
**kwargs: Keyword args forwarded to constructor.
|
|
|
|
Returns:
|
|
The component instance added.
|
|
"""
|
|
if isinstance(component_or_cls, Component):
|
|
component = component_or_cls
|
|
else:
|
|
component = component_or_cls(*args, **kwargs)
|
|
component.owner = self.owner
|
|
self.components[name] = component
|
|
return component
|
|
|
|
def get_component(self, name: str) -> Optional[Component]:
|
|
"""Get a component by name.
|
|
|
|
Args:
|
|
name: Component name key.
|
|
|
|
Returns:
|
|
The component if present, otherwise None.
|
|
"""
|
|
return self.components.get(name)
|
|
|
|
|
|
class TextComponent(Component):
|
|
"""Component for rendering text. Depends on FontManager."""
|
|
def __init__(self, text: str, font_name: str = "default", font_size: int = 20, color: rl.Color = rl.WHITE) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
text: Parameter.
|
|
font_name: Parameter.
|
|
font_size: Parameter.
|
|
color: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.font_manager: Optional[FontManager] = None
|
|
self.text = text
|
|
self.font_name = font_name
|
|
self.font_size = font_size
|
|
self.color = color
|
|
self.position = v2(0.0, 0.0)
|
|
self.rotation = 0.0
|
|
|
|
def init(self) -> None:
|
|
"""Resolve FontManager from the owning scene.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.owner and self.owner.scene and self.owner.scene.game:
|
|
self.font_manager = self.owner.scene.game.get_manager(FontManager)
|
|
|
|
def draw(self) -> None:
|
|
"""Draw the text if a font is available.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.font_manager:
|
|
return
|
|
rl.draw_text_ex(self.font_manager.get_font(self.font_name),
|
|
self.text,
|
|
self.position,
|
|
float(self.font_size),
|
|
1.0,
|
|
self.color)
|
|
|
|
def set_text(self, text: str) -> None:
|
|
"""Set the displayed text.
|
|
|
|
Args:
|
|
text: New text string.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.text = text
|
|
|
|
def set_color(self, color: rl.Color) -> None:
|
|
"""Set the text color.
|
|
|
|
Args:
|
|
color: Raylib color.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.color = color
|
|
|
|
def set_font_size(self, font_size: int) -> None:
|
|
"""Set the font size.
|
|
|
|
Args:
|
|
font_size: New font size.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.font_size = font_size
|
|
|
|
def set_font(self, font_name: str) -> None:
|
|
"""Set the font by name.
|
|
|
|
Args:
|
|
font_name: Registered font name.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.font_name = font_name
|
|
|
|
def set_position(self, position: rl.Vector2) -> None:
|
|
"""Set the text position.
|
|
|
|
Args:
|
|
position: Vector2 in pixels.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.position = position
|
|
|
|
def set_rotation(self, rotation: float) -> None:
|
|
"""Set the text rotation.
|
|
|
|
Args:
|
|
rotation: Rotation in degrees.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.rotation = rotation
|
|
|
|
|
|
class SoundComponent(Component):
|
|
"""Component for playing sounds. Depends on SoundService."""
|
|
def __init__(self, filename: str, volume: float = 1.0, pitch: float = 1.0, pan: float = 0.5) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
filename: Parameter.
|
|
volume: Parameter.
|
|
pitch: Parameter.
|
|
pan: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.filename = filename
|
|
self.sound = None
|
|
self.volume = volume
|
|
self.pitch = pitch
|
|
self.pan = pan
|
|
|
|
def init(self) -> None:
|
|
"""Load the sound from SoundService.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.owner and self.owner.scene:
|
|
sound_service = self.owner.scene.get_service(SoundService)
|
|
self.sound = sound_service.get_sound(self.filename)
|
|
|
|
def play(self) -> None:
|
|
"""Play the sound.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.sound:
|
|
rl.play_sound(self.sound)
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the sound.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.sound:
|
|
rl.stop_sound(self.sound)
|
|
|
|
def set_volume(self, volume: float) -> None:
|
|
"""Set playback volume.
|
|
|
|
Args:
|
|
volume: Volume scalar.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.volume = volume
|
|
if self.sound:
|
|
rl.set_sound_volume(self.sound, volume)
|
|
|
|
def set_pitch(self, pitch: float) -> None:
|
|
"""Set playback pitch.
|
|
|
|
Args:
|
|
pitch: Pitch scalar.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.pitch = pitch
|
|
if self.sound:
|
|
rl.set_sound_pitch(self.sound, pitch)
|
|
|
|
def set_pan(self, pan: float) -> None:
|
|
"""Set playback pan.
|
|
|
|
Args:
|
|
pan: Pan value from 0.0 (left) to 1.0 (right).
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.pan = pan
|
|
if self.sound:
|
|
rl.set_sound_pan(self.sound, pan)
|
|
|
|
def is_playing(self) -> bool:
|
|
"""Check if the sound is currently playing.
|
|
|
|
Returns:
|
|
True if playing, otherwise False.
|
|
"""
|
|
return bool(self.sound and rl.is_sound_playing(self.sound))
|
|
|
|
|
|
class BodyComponent(Component):
|
|
"""Component that owns a Box2D body. Depends on PhysicsService."""
|
|
def __init__(self, body: Optional[b2Body] = None, build: Optional[Any] = None) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
body: Parameter.
|
|
build: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.body = body
|
|
self.build = build
|
|
self.physics: Optional[PhysicsService] = None
|
|
|
|
def init(self) -> None:
|
|
"""Resolve PhysicsService and build the body if provided.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.owner or not self.owner.scene:
|
|
return
|
|
self.physics = self.owner.scene.get_service(PhysicsService)
|
|
if self.build:
|
|
self.build(self)
|
|
|
|
def enable(self) -> None:
|
|
"""Enable the body in the physics simulation.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.body:
|
|
self.body.awake = True
|
|
self.body.active = True
|
|
|
|
def disable(self) -> None:
|
|
"""Disable the body in the physics simulation.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.body:
|
|
self.body.active = False
|
|
|
|
def get_position_meters(self) -> b2Vec2:
|
|
"""Get position in meters.
|
|
|
|
Returns:
|
|
b2Vec2 position in meters.
|
|
"""
|
|
return self.body.position if self.body else b2Vec2(0.0, 0.0)
|
|
|
|
def get_position_pixels(self) -> rl.Vector2:
|
|
"""Get position in pixels.
|
|
|
|
Returns:
|
|
Vector2 position in pixels.
|
|
"""
|
|
if not self.physics or not self.body:
|
|
return v2(0.0, 0.0)
|
|
pos = self.physics.convert_to_pixels(self.body.position)
|
|
return v2(pos.x, pos.y)
|
|
|
|
def set_position(self, pos) -> None:
|
|
"""Set position (meters if b2Vec2, else pixels).
|
|
|
|
Args:
|
|
pos: b2Vec2 in meters or Vector2 in pixels.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.body:
|
|
return
|
|
if isinstance(pos, b2Vec2):
|
|
self.body.position = pos
|
|
else:
|
|
if not self.physics:
|
|
return
|
|
self.body.position = self.physics.convert_to_meters(pos)
|
|
|
|
def set_rotation(self, degrees: float) -> None:
|
|
"""Set rotation in degrees.
|
|
|
|
Args:
|
|
degrees: Rotation in degrees.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.body:
|
|
self.body.angle = math.radians(degrees)
|
|
|
|
def get_velocity_meters(self) -> b2Vec2:
|
|
"""Get linear velocity in meters/sec.
|
|
|
|
Returns:
|
|
b2Vec2 velocity in meters/sec.
|
|
"""
|
|
return self.body.linearVelocity if self.body else b2Vec2(0.0, 0.0)
|
|
|
|
def get_velocity_pixels(self) -> rl.Vector2:
|
|
"""Get linear velocity in pixels/sec.
|
|
|
|
Returns:
|
|
Vector2 velocity in pixels/sec.
|
|
"""
|
|
if not self.physics or not self.body:
|
|
return v2(0.0, 0.0)
|
|
vel = self.physics.convert_to_pixels(self.body.linearVelocity)
|
|
return v2(vel.x, vel.y)
|
|
|
|
def set_velocity(self, vel) -> None:
|
|
"""Set linear velocity (meters if b2Vec2, else pixels).
|
|
|
|
Args:
|
|
vel: b2Vec2 in meters/sec or Vector2 in pixels/sec.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.body:
|
|
return
|
|
if isinstance(vel, b2Vec2):
|
|
self.body.linearVelocity = vel
|
|
else:
|
|
if not self.physics:
|
|
return
|
|
self.body.linearVelocity = self.physics.convert_to_meters(vel)
|
|
|
|
def get_rotation(self) -> float:
|
|
"""Get rotation in degrees.
|
|
|
|
Returns:
|
|
Rotation in degrees.
|
|
"""
|
|
return math.degrees(self.body.angle) if self.body else 0.0
|
|
|
|
def get_contacts(self) -> List[b2Body]:
|
|
"""Get bodies currently touching this body.
|
|
|
|
Returns:
|
|
List of bodies in contact.
|
|
"""
|
|
if not self.body:
|
|
return []
|
|
contacts: List[b2Body] = []
|
|
for edge in self.body.contacts:
|
|
contact = edge.contact
|
|
if contact.touching:
|
|
other = edge.other
|
|
if other not in contacts:
|
|
contacts.append(other)
|
|
return contacts
|
|
|
|
def get_sensor_overlaps(self) -> List[b2Body]:
|
|
"""Get bodies overlapping sensor fixtures on this body.
|
|
|
|
Returns:
|
|
List of bodies overlapping sensor fixtures.
|
|
"""
|
|
if not self.body:
|
|
return []
|
|
contacts: List[b2Body] = []
|
|
for edge in self.body.contacts:
|
|
contact = edge.contact
|
|
if not contact.touching:
|
|
continue
|
|
fixture_a = contact.fixtureA
|
|
fixture_b = contact.fixtureB
|
|
if fixture_a.body == self.body and fixture_a.sensor:
|
|
if fixture_b.body not in contacts:
|
|
contacts.append(fixture_b.body)
|
|
elif fixture_b.body == self.body and fixture_b.sensor:
|
|
if fixture_a.body not in contacts:
|
|
contacts.append(fixture_a.body)
|
|
return contacts
|
|
|
|
|
|
class SpriteComponent(Component):
|
|
"""Component for rendering a sprite. Depends on TextureService."""
|
|
def __init__(self, filename: str, body: Optional[BodyComponent] = None) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
filename: Parameter.
|
|
body: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.filename = filename
|
|
self.body = body
|
|
self.sprite: Optional[rl.Texture2D] = None
|
|
self.position = v2(0.0, 0.0)
|
|
self.rotation = 0.0
|
|
self.scale = 1.0
|
|
self.tint = rl.WHITE
|
|
self.is_active = True
|
|
|
|
def init(self) -> None:
|
|
"""Load the sprite texture via TextureService.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.owner and self.owner.scene:
|
|
texture_service = self.owner.scene.get_service(TextureService)
|
|
self.sprite = texture_service.get_texture(self.filename)
|
|
|
|
def draw(self) -> None:
|
|
"""Draw the sprite if active.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.is_active or not self.sprite:
|
|
return
|
|
if self.body:
|
|
self.position = self.body.get_position_pixels()
|
|
self.rotation = self.body.get_rotation()
|
|
source = rl.Rectangle(0.0, 0.0, float(self.sprite.width), float(self.sprite.height))
|
|
dest = rl.Rectangle(self.position.x, self.position.y,
|
|
float(self.sprite.width) * self.scale,
|
|
float(self.sprite.height) * self.scale)
|
|
origin = v2(float(self.sprite.width) / 2.0 * self.scale,
|
|
float(self.sprite.height) / 2.0 * self.scale)
|
|
rl.draw_texture_pro(self.sprite, source, dest, origin, self.rotation, self.tint)
|
|
|
|
def set_position(self, position: rl.Vector2) -> None:
|
|
"""Set the sprite position in pixels.
|
|
|
|
Args:
|
|
position: Vector2 position.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.position = position
|
|
|
|
def set_rotation(self, rotation: float) -> None:
|
|
"""Set the sprite rotation in degrees.
|
|
|
|
Args:
|
|
rotation: Rotation in degrees.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.rotation = rotation
|
|
|
|
def set_scale(self, scale: float) -> None:
|
|
"""Set the sprite scale.
|
|
|
|
Args:
|
|
scale: Scale multiplier.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.scale = scale
|
|
|
|
def set_tint(self, tint: rl.Color) -> None:
|
|
"""Set the sprite tint color.
|
|
|
|
Args:
|
|
tint: Raylib color.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.tint = tint
|
|
|
|
def set_active(self, active: bool) -> None:
|
|
"""Enable or disable sprite rendering.
|
|
|
|
Args:
|
|
active: True to render, False to hide.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.is_active = active
|
|
|
|
|
|
class Animation:
|
|
"""Frame-based animation helper."""
|
|
def __init__(self, frames: List[rl.Texture2D], fps: float = 15.0, loop: bool = True) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
frames: Parameter.
|
|
fps: Parameter.
|
|
loop: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.frames = frames
|
|
self.fps = fps
|
|
self.frame_timer = 1.0 / fps if fps > 0 else 0.0
|
|
self.loop = loop
|
|
self.current_frame = 0
|
|
self.playing = True
|
|
self.is_active = True
|
|
|
|
@classmethod
|
|
def from_files(cls, texture_service: TextureService, filenames: List[str], fps: float = 15.0, loop: bool = True):
|
|
"""From files.
|
|
|
|
Args:
|
|
texture_service: Parameter.
|
|
filenames: Parameter.
|
|
fps: Parameter.
|
|
loop: Parameter.
|
|
|
|
Returns:
|
|
Result of the operation.
|
|
"""
|
|
frames = [texture_service.get_texture(name) for name in filenames]
|
|
return cls(frames, fps, loop)
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Advance the animation by delta time.
|
|
|
|
Args:
|
|
delta_time: Seconds since the last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.frames or not self.playing or not self.is_active or self.fps <= 0:
|
|
return
|
|
self.frame_timer -= delta_time
|
|
if self.frame_timer <= 0.0:
|
|
self.frame_timer = 1.0 / self.fps
|
|
self.current_frame += 1
|
|
if self.current_frame > len(self.frames) - 1:
|
|
self.current_frame = 0 if self.loop else len(self.frames) - 1
|
|
|
|
def draw(self, position: rl.Vector2, rotation: float = 0.0, tint: rl.Color = rl.WHITE) -> None:
|
|
"""Draw the animation at a position.
|
|
|
|
Args:
|
|
position: Position in pixels.
|
|
rotation: Rotation in degrees.
|
|
tint: Color tint.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.is_active or not self.frames:
|
|
return
|
|
sprite = self.frames[self.current_frame]
|
|
rl.draw_texture_pro(sprite,
|
|
rl.Rectangle(0.0, 0.0, float(sprite.width), float(sprite.height)),
|
|
rl.Rectangle(position.x, position.y, float(sprite.width), float(sprite.height)),
|
|
v2(float(sprite.width) / 2.0, float(sprite.height) / 2.0),
|
|
rotation,
|
|
tint)
|
|
|
|
def draw_with_origin(self, position: rl.Vector2, origin: rl.Vector2, rotation: float = 0.0,
|
|
scale: float = 1.0, flip_x: bool = False, flip_y: bool = False,
|
|
tint: rl.Color = rl.WHITE) -> None:
|
|
"""Draw the animation with origin, scale, and flip options.
|
|
|
|
Args:
|
|
position: Position in pixels.
|
|
origin: Origin for rotation/scaling.
|
|
rotation: Rotation in degrees.
|
|
scale: Scale multiplier.
|
|
flip_x: True to flip horizontally.
|
|
flip_y: True to flip vertically.
|
|
tint: Color tint.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.is_active or not self.frames:
|
|
return
|
|
sprite = self.frames[self.current_frame]
|
|
src = rl.Rectangle(0.0, 0.0,
|
|
float(sprite.width) * (-1.0 if flip_x else 1.0),
|
|
float(sprite.height) * (-1.0 if flip_y else 1.0))
|
|
dest = rl.Rectangle(position.x, position.y,
|
|
float(sprite.width) * scale,
|
|
float(sprite.height) * scale)
|
|
rl.draw_texture_pro(sprite, src, dest, vec_mul(origin, scale), rotation, tint)
|
|
|
|
def play(self) -> None:
|
|
"""Start or resume playback.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.playing = True
|
|
|
|
def pause(self) -> None:
|
|
"""Pause playback.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.playing = False
|
|
|
|
def stop(self) -> None:
|
|
"""Stop playback and reset to the first frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.playing = False
|
|
self.frame_timer = 1.0 / self.fps if self.fps > 0 else 0.0
|
|
self.current_frame = 0
|
|
|
|
|
|
class AnimationController(Component):
|
|
"""Component for controlling animations. Depends on TextureService."""
|
|
def __init__(self, body: Optional[BodyComponent] = None) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
body: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.animations: Dict[str, Animation] = {}
|
|
self.current_animation: Optional[Animation] = None
|
|
self.position = v2(0.0, 0.0)
|
|
self.rotation = 0.0
|
|
self.origin = v2(0.0, 0.0)
|
|
self.scale = 1.0
|
|
self.flip_x = False
|
|
self.flip_y = False
|
|
self.body = body
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Update the current animation.
|
|
|
|
Args:
|
|
delta_time: Seconds since the last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.current_animation:
|
|
self.current_animation.update(delta_time)
|
|
|
|
def draw(self) -> None:
|
|
"""Draw the current animation.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.body:
|
|
self.position = self.body.get_position_pixels()
|
|
self.rotation = self.body.get_rotation()
|
|
if self.current_animation:
|
|
self.current_animation.draw_with_origin(self.position, self.origin, self.rotation, self.scale,
|
|
self.flip_x, self.flip_y)
|
|
|
|
def add_animation(self, name: str, animation: Animation) -> None:
|
|
"""Add an Animation to the controller.
|
|
|
|
Args:
|
|
name: Animation name.
|
|
animation: Animation instance.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.animations[name] = animation
|
|
if not self.current_animation:
|
|
self.current_animation = animation
|
|
sprite = animation.frames[animation.current_frame]
|
|
self.origin = v2(float(sprite.width) / 2.0, float(sprite.height) / 2.0)
|
|
|
|
def add_animation_from_files(self, name: str, filenames: List[str], fps: float = 15.0, loop: bool = True) -> Animation:
|
|
"""Create an Animation from files and add it.
|
|
|
|
Args:
|
|
name: Animation name.
|
|
filenames: List of frame image paths.
|
|
fps: Frames per second.
|
|
loop: True to loop.
|
|
|
|
Returns:
|
|
The created Animation.
|
|
"""
|
|
texture_service = self.owner.scene.get_service(TextureService) if self.owner and self.owner.scene else None
|
|
if not texture_service:
|
|
raise RuntimeError("TextureService not available")
|
|
animation = Animation.from_files(texture_service, filenames, fps, loop)
|
|
self.add_animation(name, animation)
|
|
return animation
|
|
|
|
def get_animation(self, name: str) -> Optional[Animation]:
|
|
"""Get an animation by name.
|
|
|
|
Args:
|
|
name: Animation name.
|
|
|
|
Returns:
|
|
The Animation or None.
|
|
"""
|
|
return self.animations.get(name)
|
|
|
|
def play(self, name: Optional[str] = None) -> None:
|
|
"""Play the current animation or switch by name then play.
|
|
|
|
Args:
|
|
name: Optional animation name to switch to.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if name:
|
|
animation = self.animations.get(name)
|
|
if animation:
|
|
self.current_animation = animation
|
|
if self.current_animation:
|
|
self.current_animation.play()
|
|
|
|
def pause(self) -> None:
|
|
"""Pause the current animation.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.current_animation:
|
|
self.current_animation.pause()
|
|
|
|
def set_play(self, play: bool) -> None:
|
|
"""Set play/pause state for the current animation.
|
|
|
|
Args:
|
|
play: True to play, False to pause.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.current_animation:
|
|
self.current_animation.play() if play else self.current_animation.pause()
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the current animation.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if self.current_animation:
|
|
self.current_animation.stop()
|
|
|
|
def set_position(self, position: rl.Vector2) -> None:
|
|
"""Set animation draw position.
|
|
|
|
Args:
|
|
position: Vector2 in pixels.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.position = position
|
|
|
|
def set_rotation(self, rotation: float) -> None:
|
|
"""Set animation rotation in degrees.
|
|
|
|
Args:
|
|
rotation: Rotation in degrees.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.rotation = rotation
|
|
|
|
def set_origin(self, origin: rl.Vector2) -> None:
|
|
"""Set animation origin point.
|
|
|
|
Args:
|
|
origin: Vector2 origin.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.origin = origin
|
|
|
|
def set_scale(self, scale: float) -> None:
|
|
"""Set animation scale.
|
|
|
|
Args:
|
|
scale: Scale multiplier.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.scale = scale
|
|
|
|
def set_flip_x(self, flip: bool) -> None:
|
|
"""Set horizontal flip.
|
|
|
|
Args:
|
|
flip: True to flip horizontally.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.flip_x = flip
|
|
|
|
def set_flip_y(self, flip: bool) -> None:
|
|
"""Set vertical flip.
|
|
|
|
Args:
|
|
flip: True to flip vertically.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.flip_y = flip
|
|
|
|
|
|
class PlatformerMovementParams:
|
|
"""Parameter bag for platformer movement."""
|
|
def __init__(self) -> None:
|
|
""" init .
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.width = 24.0
|
|
self.height = 40.0
|
|
self.max_speed = 220.0
|
|
self.accel = 2000.0
|
|
self.decel = 2500.0
|
|
self.gravity = 1400.0
|
|
self.jump_speed = 520.0
|
|
self.fall_speed = 1200.0
|
|
self.jump_cutoff_multiplier = 0.45
|
|
self.coyote_time = 0.08
|
|
self.jump_buffer = 0.10
|
|
|
|
|
|
class PlatformerMovementComponent(Component):
|
|
"""Component for 2D platformer movement."""
|
|
def __init__(self, params: PlatformerMovementParams) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
params: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.p = params
|
|
self.physics: Optional[PhysicsService] = None
|
|
self.body: Optional[BodyComponent] = None
|
|
self.grounded = False
|
|
self.on_wall_left = False
|
|
self.on_wall_right = False
|
|
self.coyote_timer = 0.0
|
|
self.jump_buffer_timer = 0.0
|
|
self.move_x = 0.0
|
|
self.jump_pressed = False
|
|
self.jump_held = False
|
|
|
|
def init(self) -> None:
|
|
"""Resolve PhysicsService and BodyComponent.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.owner or not self.owner.scene:
|
|
return
|
|
self.physics = self.owner.scene.get_service(PhysicsService)
|
|
self.body = self.owner.get_component(BodyComponent)
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Update movement and apply velocity to the body.
|
|
|
|
Args:
|
|
delta_time: Seconds since the last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.physics or not self.body or not self.body.body:
|
|
return
|
|
self.coyote_timer = max(0.0, self.coyote_timer - delta_time)
|
|
self.jump_buffer_timer = max(0.0, self.jump_buffer_timer - delta_time)
|
|
if self.jump_pressed:
|
|
self.jump_buffer_timer = self.p.jump_buffer
|
|
|
|
self.grounded = False
|
|
self.on_wall_left = False
|
|
self.on_wall_right = False
|
|
|
|
ray_length = self.physics.convert_length_to_meters(4.0)
|
|
half_width = self.physics.convert_length_to_meters(self.p.width) / 2.0
|
|
half_height = self.physics.convert_length_to_meters(self.p.height) / 2.0
|
|
|
|
pos = self.body.get_position_meters()
|
|
ground_left_start = b2Vec2(pos.x - half_width, pos.y + half_height)
|
|
ground_right_start = b2Vec2(pos.x + half_width, pos.y + half_height)
|
|
ground_translation = b2Vec2(0.0, ray_length)
|
|
|
|
left_ground_hit = raycast_closest(self.physics.world, self.body.body, ground_left_start, ground_translation)
|
|
right_ground_hit = raycast_closest(self.physics.world, self.body.body, ground_right_start, ground_translation)
|
|
self.grounded = left_ground_hit.hit or right_ground_hit.hit
|
|
|
|
mid = b2Vec2(pos.x, pos.y)
|
|
wall_left_start = b2Vec2(pos.x - half_width, mid.y)
|
|
wall_left_translation = b2Vec2(-ray_length, 0.0)
|
|
wall_right_start = b2Vec2(pos.x + half_width, mid.y)
|
|
wall_right_translation = b2Vec2(ray_length, 0.0)
|
|
|
|
left_wall_hit = raycast_closest(self.physics.world, self.body.body, wall_left_start, wall_left_translation)
|
|
right_wall_hit = raycast_closest(self.physics.world, self.body.body, wall_right_start, wall_right_translation)
|
|
self.on_wall_left = left_wall_hit.hit
|
|
self.on_wall_right = right_wall_hit.hit
|
|
|
|
if self.grounded:
|
|
self.coyote_timer = self.p.coyote_time
|
|
|
|
target_vx = self.move_x * self.p.max_speed
|
|
v = self.body.get_velocity_pixels()
|
|
|
|
if abs(target_vx) > 0.001:
|
|
v.x = self.move_towards(v.x, target_vx, self.p.accel * delta_time)
|
|
else:
|
|
v.x = self.move_towards(v.x, 0.0, self.p.decel * delta_time)
|
|
|
|
v.y += self.p.gravity * delta_time
|
|
v.y = max(-self.p.fall_speed, min(self.p.fall_speed, v.y))
|
|
|
|
can_jump = self.grounded or self.coyote_timer > 0.0
|
|
if self.jump_buffer_timer > 0.0 and can_jump:
|
|
v.y = -self.p.jump_speed
|
|
self.jump_buffer_timer = 0.0
|
|
self.coyote_timer = 0.0
|
|
self.grounded = False
|
|
|
|
if not self.jump_held and v.y < 0.0:
|
|
v.y *= self.p.jump_cutoff_multiplier
|
|
|
|
self.body.set_velocity(v)
|
|
|
|
@staticmethod
|
|
def move_towards(current: float, target: float, max_delta: float) -> float:
|
|
"""Move a value toward a target by at most max_delta.
|
|
|
|
Args:
|
|
current: Current value.
|
|
target: Target value.
|
|
max_delta: Maximum change allowed.
|
|
|
|
Returns:
|
|
The updated value.
|
|
"""
|
|
delta = target - current
|
|
if abs(delta) <= max_delta:
|
|
return target
|
|
return current + (max_delta if delta > 0 else -max_delta)
|
|
|
|
def set_input(self, horizontal_speed: float, jump_pressed: bool, jump_held: bool) -> None:
|
|
"""Set movement input for this frame.
|
|
|
|
Args:
|
|
horizontal_speed: Horizontal input (-1 to 1).
|
|
jump_pressed: True if jump pressed this frame.
|
|
jump_held: True if jump is held.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.move_x = horizontal_speed
|
|
self.jump_pressed = jump_pressed
|
|
self.jump_held = jump_held
|
|
|
|
|
|
class TopDownMovementParams:
|
|
"""Parameter bag for top-down movement."""
|
|
def __init__(self) -> None:
|
|
""" init .
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.max_speed = 300.0
|
|
self.accel = 1200.0
|
|
self.friction = 1200.0
|
|
self.deadzone = 0.1
|
|
|
|
|
|
class TopDownMovementComponent(Component):
|
|
"""Component for 2D top-down movement."""
|
|
def __init__(self, params: TopDownMovementParams) -> None:
|
|
""" init .
|
|
|
|
Args:
|
|
params: Parameter.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
super().__init__()
|
|
self.p = params
|
|
self.physics: Optional[PhysicsService] = None
|
|
self.body: Optional[BodyComponent] = None
|
|
self.move_x = 0.0
|
|
self.move_y = 0.0
|
|
self.facing_dir = 0.0
|
|
|
|
def init(self) -> None:
|
|
"""Resolve PhysicsService and BodyComponent.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.owner or not self.owner.scene:
|
|
return
|
|
self.physics = self.owner.scene.get_service(PhysicsService)
|
|
self.body = self.owner.get_component(BodyComponent)
|
|
|
|
def update(self, delta_time: float) -> None:
|
|
"""Update movement and apply velocity to the body.
|
|
|
|
Args:
|
|
delta_time: Seconds since the last frame.
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
if not self.body or not self.body.body:
|
|
return
|
|
v = self.body.get_velocity_pixels()
|
|
input_vec = v2(self.move_x, self.move_y)
|
|
input_len_sq = input_vec.x * input_vec.x + input_vec.y * input_vec.y
|
|
desired = v2(0.0, 0.0)
|
|
|
|
if input_len_sq > self.p.deadzone * self.p.deadzone:
|
|
desired = v2(input_vec.x * self.p.max_speed, input_vec.y * self.p.max_speed)
|
|
self.facing_dir = math.degrees(math.atan2(input_vec.y, input_vec.x))
|
|
v = self.move_towards_vec(v, desired, self.p.accel * delta_time)
|
|
else:
|
|
v = self.apply_friction(v, self.p.friction * delta_time)
|
|
|
|
speed_sq = v.x * v.x + v.y * v.y
|
|
max_speed_sq = self.p.max_speed * self.p.max_speed
|
|
if speed_sq > max_speed_sq:
|
|
speed = math.sqrt(speed_sq)
|
|
scale = self.p.max_speed / speed
|
|
v.x *= scale
|
|
v.y *= scale
|
|
|
|
self.body.set_velocity(v)
|
|
|
|
@staticmethod
|
|
def move_towards_vec(current: rl.Vector2, target: rl.Vector2, max_delta: float) -> rl.Vector2:
|
|
"""Move a vector toward a target by at most max_delta.
|
|
|
|
Args:
|
|
current: Current vector.
|
|
target: Target vector.
|
|
max_delta: Maximum change length.
|
|
|
|
Returns:
|
|
The updated vector.
|
|
"""
|
|
delta = v2(target.x - current.x, target.y - current.y)
|
|
length = math.sqrt(delta.x * delta.x + delta.y * delta.y)
|
|
if length <= max_delta or length < 1e-5:
|
|
return target
|
|
scale = max_delta / length
|
|
return v2(current.x + delta.x * scale, current.y + delta.y * scale)
|
|
|
|
@staticmethod
|
|
def apply_friction(v: rl.Vector2, friction_delta: float) -> rl.Vector2:
|
|
"""Apply friction to reduce vector magnitude.
|
|
|
|
Args:
|
|
v: Current velocity vector.
|
|
friction_delta: Speed to subtract this frame.
|
|
|
|
Returns:
|
|
The updated velocity vector.
|
|
"""
|
|
speed = math.sqrt(v.x * v.x + v.y * v.y)
|
|
if speed < 1e-5:
|
|
return v2(0.0, 0.0)
|
|
new_speed = speed - friction_delta
|
|
if new_speed <= 0.0:
|
|
return v2(0.0, 0.0)
|
|
scale = new_speed / speed
|
|
return v2(v.x * scale, v.y * scale)
|
|
|
|
def set_input(self, horizontal: float, vertical: float) -> None:
|
|
"""Set movement input for this frame.
|
|
|
|
Args:
|
|
horizontal: Horizontal input (-1 to 1).
|
|
vertical: Vertical input (-1 to 1).
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
self.move_x = horizontal
|
|
self.move_y = vertical
|