nodes/player.py¶
Part of Pixel Runner.
1"""Player node: replaces Pygame ``Player(sprite.Sprite)``.
2
3The pygame original swapped ``self.image`` between three Surfaces every frame to
4animate. SimVX splits that into two children with one job each: an
5``AnimatedSprite2D`` built by ``from_frames`` plays the two walk PNGs as a
6looping flipbook (one strip atlas, no per-frame upload), and a plain ``Sprite2D``
7holds the single jump frame. Only one of the two is visible at a time.
8
9Each frame:
10- jump while grounded if the "jump" action was just pressed
11- integrate gravity, clamp to the ground line
12- show the jump frame while airborne, the walk cycle while grounded
13
14This is the SimVX equivalent of pygame.sprite.Sprite + manual gravity, but
15expressed as a real scene-tree node with Properties for inspector tunables.
16"""
17
18from simvx.core import (
19 AnimatedSprite2D,
20 AudioPlayer,
21 Input,
22 Node2D,
23 Property,
24 Rect2,
25 Sprite2D,
26 Vec2,
27)
28
29from .assets import GRAPHICS
30from .audio import make_jump
31
32GROUND_Y = 300 # baseline (player feet): matches upstream pygame coordinates
33JUMP_VELOCITY = -700 # px/sec; pygame used -20 px/frame at 60 Hz; tuned for the new dt-based path
34GRAVITY = 1800 # px/sec²
35
36_PLAYER_GFX = GRAPHICS / "player"
37
38
39class Player(Node2D):
40 """Side-view runner with two-frame walk cycle and a single jump frame."""
41
42 speed_jump = Property(float(JUMP_VELOCITY), range=(-2000, 0), hint="Jump impulse (negative = up)")
43 gravity = Property(float(GRAVITY), range=(0, 5000), hint="Pixels/sec² downward")
44
45 def __init__(self, **kwargs):
46 super().__init__(name="Player", **kwargs)
47 self._horizon = float(GROUND_Y)
48 self.position = Vec2(80, self._horizon - 42)
49 self.velocity_y = 0.0
50 self._airborne = False
51
52 # Walk cycle: from_frames stitches the two PNGs into one strip atlas and
53 # registers them as an animation. Its ``name`` is the *animation* name,
54 # so the node keeps its own name separately.
55 self.walk_anim = self.add_child(
56 AnimatedSprite2D.from_frames(
57 [str(_PLAYER_GFX / "player_walk_1.png"), str(_PLAYER_GFX / "player_walk_2.png")],
58 fps=8.0,
59 loop=True,
60 name="walk",
61 play=True,
62 )
63 )
64 self.walk_anim.name = "Walk"
65 # Single-frame jump pose, shown instead of the walk cycle while airborne.
66 self.sprite_jump = self.add_child(
67 Sprite2D(
68 texture=str(_PLAYER_GFX / "jump.png"),
69 width=72,
70 height=84,
71 name="Jump",
72 )
73 )
74 self.sprite_jump.visible = False
75
76 # Jump SFX: child node so backend wiring is automatic on enter_tree.
77 # Generated procedurally (no bundled audio files; see nodes/audio.py).
78 self.jump_sfx = self.add_child(
79 AudioPlayer(
80 stream=make_jump(),
81 bus="SFX",
82 volume_db=-6.0,
83 name="JumpSFX",
84 )
85 )
86
87 def set_horizon(self, horizon: float) -> None:
88 """Update the ground line so the player rests on it after window resize."""
89 self._horizon = float(horizon)
90 if not self._airborne:
91 self.position = Vec2(float(self.position.x), self._horizon - 42)
92
93 def _set_airborne_visual(self, airborne: bool) -> None:
94 """Show jump frame while airborne, walk animation while grounded."""
95 self.walk_anim.visible = not airborne
96 self.sprite_jump.visible = airborne
97 if airborne:
98 self.walk_anim.stop()
99 else:
100 if not self.walk_anim.playing:
101 self.walk_anim.play("walk")
102
103 def reset(self):
104 """Restore midbottom on the current horizon (called from runner restart)."""
105 self.position = Vec2(80, self._horizon - 42)
106 self.velocity_y = 0.0
107 self._airborne = False
108 self._set_airborne_visual(False)
109
110 @property
111 def rect(self) -> Rect2:
112 """Tight axis-aligned collision box.
113
114 We intentionally use the walk-frame footprint (64x84) whichever frame is
115 showing, pygame did the same.
116 """
117 return Rect2(float(self.position.x) - 32.0, float(self.position.y) - 42.0, 64.0, 84.0)
118
119 def on_update(self, dt: float):
120 # Jump on SPACE if grounded.
121 if Input.is_action_just_pressed("jump") and not self._airborne:
122 self.velocity_y = self.speed_jump
123 self._airborne = True
124 self.jump_sfx.play()
125
126 # Apply gravity and integrate.
127 self.velocity_y += self.gravity * dt
128 new_y = float(self.position.y) + self.velocity_y * dt
129 ground_centre_y = self._horizon - 42.0
130 if new_y >= ground_centre_y:
131 new_y = ground_centre_y
132 self.velocity_y = 0.0
133 self._airborne = False
134 self.position = Vec2(float(self.position.x), new_y)
135
136 # Visual: jump frame while airborne, otherwise the walking AnimatedSprite2D.
137 self._set_airborne_visual(self._airborne)