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

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