nodes/bird.pyΒΆ

Part of Clumsy Bird.

  1"""Player bird: gravity, flap, rotation."""
  2
  3import math
  4
  5from config import ASSETS, BIRD_X, FLAP_IMPULSE, GRAVITY, HEIGHT, MAX_FALL_SPEED
  6
  7from simvx.core import (
  8    AnimatedSprite2D,
  9    AudioPlayer,
 10    CharacterBody2D,
 11    Property,
 12    RectangleShape2D,
 13    Signal,
 14    Vec2,
 15)
 16
 17from .audio import make_wing
 18
 19
 20class Bird(CharacterBody2D):
 21    """Player bird. Vertical-only motion; horizontal x is fixed.
 22
 23    Motion is integrated by hand in ``on_fixed_update`` rather than with
 24    ``move_and_slide``, so the body's collider is never swept: it exists purely
 25    for the overlap queries in :meth:`overlaps_group`.
 26    """
 27
 28    gravity = Property(GRAVITY, range=(200, 4000), hint="Pixels/sec^2 downward acceleration")
 29    flap_impulse = Property(FLAP_IMPULSE, range=(100, 1200), hint="Upward velocity on flap")
 30    max_fall_speed = Property(MAX_FALL_SPEED, range=(100, 2000))
 31
 32    flapped = Signal()
 33    crashed = Signal()
 34
 35    def __init__(self, **kwargs):
 36        # Slightly tighter collision than the full sprite so the bird doesn't
 37        # clip through the visible pipe rim.
 38        super().__init__(shape=RectangleShape2D(half_extents=Vec2(28, 18)), **kwargs)
 39        self.add_to_group("bird")
 40        self.alive = True
 41        self.frozen = True  # frozen until first flap (Get Ready phase)
 42        self._idle_time = 0.0
 43        self.position = Vec2(BIRD_X, HEIGHT // 2 - 60)
 44
 45        # Sprite (3-frame flap animation; 85x60 per frame, sheet 255x60).
 46        self.sprite = self.add_child(
 47            AnimatedSprite2D(
 48                texture=str(ASSETS / "clumsy.png"),
 49                frames_h=3,
 50                frames_v=1,
 51                width=85,
 52                height=60,
 53                name="Sprite",
 54            )
 55        )
 56        self.sprite.add_animation("flying", [0, 1, 2, 1], fps=12, loop=True)
 57        self.sprite.add_animation("idle", [0], fps=1, loop=False)
 58        self.sprite.play("flying")
 59
 60        # Wing SFX (procedural; see nodes/audio.py).
 61        self._wing_player = self.add_child(AudioPlayer(stream=make_wing(), bus="SFX", name="WingSFX"))
 62
 63    # ------------------------------------------------------------------
 64    # Lifecycle
 65    # ------------------------------------------------------------------
 66
 67    def on_fixed_update(self, dt: float):
 68        if not self.alive:
 69            # Dead-fall continues until the bird hits the ground; rotate to
 70            # face plant.
 71            self.velocity.y += self.gravity * dt
 72            self.velocity.y = min(self.velocity.y, self.max_fall_speed)
 73            self.position.y += self.velocity.y * dt
 74            self.rotation = min(self.rotation + math.radians(360) * dt, math.radians(90))
 75            return
 76
 77        if self.frozen:
 78            # Idle bob until the first flap unfreezes us (see flap()).
 79            self._idle_time += dt
 80            self.position.y = HEIGHT // 2 - 60 + math.sin(self._idle_time * 4.0) * 8.0
 81            return
 82
 83        # Gravity
 84        self.velocity.y += self.gravity * dt
 85        self.velocity.y = min(self.velocity.y, self.max_fall_speed)
 86        self.position.y += self.velocity.y * dt
 87
 88        # Tilt runs from -25 degrees (rising fast) to +90 (falling fast). The
 89        # sprite is a child, so it inherits this rotation: setting it again on
 90        # the sprite would double the tilt.
 91        target_deg = max(-25.0, min(90.0, self.velocity.y * 0.18))
 92        self.rotation = math.radians(target_deg)
 93
 94        # Crash on ceiling
 95        if self.position.y < -20:
 96            self.die()
 97
 98    def overlaps_group(self, group: str) -> bool:
 99        """True if the bird's rectangular collider overlaps any body in *group*.
100
101        A direct AABB rect-vs-rect test over the group's rectangular colliders:
102        this port integrates motion itself and has no ``PhysicsRoot``, so there
103        is no broadphase to query.
104        """
105        if not self.tree:
106            return False
107        bh = self.shape.half_extents
108        bp = self.world_position
109        for body in self.tree.group(group):
110            oh = body.shape.half_extents
111            op = body.world_position
112            if abs(float(bp.x) - float(op.x)) <= float(bh[0]) + float(oh[0]) and abs(
113                float(bp.y) - float(op.y)
114            ) <= float(bh[1]) + float(oh[1]):
115                return True
116        return False
117
118    # ------------------------------------------------------------------
119    # API
120    # ------------------------------------------------------------------
121
122    def flap(self):
123        """Apply one upward impulse. Ignored once the bird is dead.
124
125        ``flapped`` is emitted before the impulse: PlayScene connects it
126        ``once`` and answers by calling :meth:`start`, which zeroes velocity.
127        """
128        if not self.alive:
129            return
130        self.flapped()
131        self.velocity.y = -self.flap_impulse
132        self._wing_player.stop()
133        self._wing_player.play()
134
135    def start(self):
136        """Unfreeze: bird begins responding to gravity/input."""
137        self.frozen = False
138        self.position.y = HEIGHT // 2 - 60
139        self.velocity = Vec2(0, 0)
140
141    def die(self):
142        if not self.alive:
143            return
144        self.alive = False
145        self.frozen = False
146        self.sprite.stop()
147        self.crashed()