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