nodes/bird.pyΒΆ

Part of Clumsy Bird.

  1"""Player bird: gravity, flap, rotation."""
  2
  3import math
  4
  5from config import ASSETS, BIRD_REST_FRACTION, BIRD_X, FLAP_IMPULSE, GRAVITY, 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
 44        # Sprite (3-frame flap animation; 85x60 per frame, sheet 255x60).
 45        self.sprite = self.add_child(
 46            AnimatedSprite2D(
 47                texture=str(ASSETS / "clumsy.png"),
 48                frames_h=3,
 49                frames_v=1,
 50                width=85,
 51                height=60,
 52                name="Sprite",
 53            )
 54        )
 55        self.sprite.add_animation("flying", [0, 1, 2, 1], fps=12, loop=True)
 56        self.sprite.add_animation("idle", [0], fps=1, loop=False)
 57        self.sprite.play("flying")
 58
 59        # Wing SFX (procedural; see nodes/audio.py).
 60        self._wing_player = self.add_child(AudioPlayer(stream=make_wing(), bus="SFX", name="WingSFX"))
 61
 62    # ------------------------------------------------------------------
 63    # Lifecycle
 64    # ------------------------------------------------------------------
 65
 66    @property
 67    def rest_y(self) -> float:
 68        """Height the bird hovers at before the first flap, for this window."""
 69        return float(self.viewport_size.y) * BIRD_REST_FRACTION
 70
 71    def on_enter_tree(self):
 72        # CharacterBody2D registers its body here, so the base hook runs first.
 73        super().on_enter_tree()
 74        # The viewport size is only knowable once the node is in a tree, so
 75        # the spawn point is set here rather than in __init__.
 76        self.position = Vec2(BIRD_X, self.rest_y)
 77
 78    def on_fixed_update(self, dt: float):
 79        if not self.alive:
 80            # Dead-fall continues until the bird hits the ground; rotate to
 81            # face plant.
 82            self.velocity.y += self.gravity * dt
 83            self.velocity.y = min(self.velocity.y, self.max_fall_speed)
 84            self.position.y += self.velocity.y * dt
 85            self.rotation = min(self.rotation + math.radians(360) * dt, math.radians(90))
 86            return
 87
 88        if self.frozen:
 89            # Idle bob until the first flap unfreezes us (see flap()).
 90            self._idle_time += dt
 91            self.position.y = self.rest_y + math.sin(self._idle_time * 4.0) * 8.0
 92            return
 93
 94        # Gravity
 95        self.velocity.y += self.gravity * dt
 96        self.velocity.y = min(self.velocity.y, self.max_fall_speed)
 97        self.position.y += self.velocity.y * dt
 98
 99        # Tilt runs from -25 degrees (rising fast) to +90 (falling fast). The
100        # sprite is a child, so it inherits this rotation: setting it again on
101        # the sprite would double the tilt.
102        target_deg = max(-25.0, min(90.0, self.velocity.y * 0.18))
103        self.rotation = math.radians(target_deg)
104
105        # Crash on ceiling
106        if self.position.y < -20:
107            self.die()
108
109    def overlaps_group(self, group: str) -> bool:
110        """True if the bird's rectangular collider overlaps any body in *group*.
111
112        A direct AABB rect-vs-rect test over the group's rectangular colliders:
113        this port integrates motion itself and has no ``PhysicsRoot``, so there
114        is no broadphase to query.
115        """
116        if not self.tree:
117            return False
118        bh = self.shape.half_extents
119        bp = self.world_position
120        for body in self.tree.group(group):
121            oh = body.shape.half_extents
122            op = body.world_position
123            if abs(float(bp.x) - float(op.x)) <= float(bh[0]) + float(oh[0]) and abs(
124                float(bp.y) - float(op.y)
125            ) <= float(bh[1]) + float(oh[1]):
126                return True
127        return False
128
129    # ------------------------------------------------------------------
130    # API
131    # ------------------------------------------------------------------
132
133    def flap(self):
134        """Apply one upward impulse. Ignored once the bird is dead.
135
136        ``flapped`` is emitted before the impulse: PlayScene connects it
137        ``once`` and answers by calling :meth:`start`, which zeroes velocity.
138        """
139        if not self.alive:
140            return
141        self.flapped.emit()
142        self.velocity.y = -self.flap_impulse
143        self._wing_player.stop()
144        self._wing_player.play()
145
146    def start(self):
147        """Unfreeze: bird begins responding to gravity/input."""
148        self.frozen = False
149        self.position.y = self.rest_y
150        self.velocity = Vec2(0, 0)
151
152    def die(self):
153        if not self.alive:
154            return
155        self.alive = False
156        self.frozen = False
157        self.sprite.stop()
158        self.crashed.emit()