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