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
32START_X = 80.0 # how far in from the left edge the runner stands
33HALF_HEIGHT = 42.0 # centre-to-feet, so the sprite rests on the ground line
34JUMP_VELOCITY = -700 # px/sec; pygame used -20 px/frame at 60 Hz; tuned for the new dt-based path
35GRAVITY = 1800 # px/sec²
36
37_PLAYER_GFX = GRAPHICS / "player"
38
39
40class Player(Node2D):
41 """Side-view runner with two-frame walk cycle and a single jump frame."""
42
43 speed_jump = Property(float(JUMP_VELOCITY), range=(-2000, 0), hint="Jump impulse (negative = up)")
44 gravity = Property(float(GRAVITY), range=(0, 5000), hint="Pixels/sec² downward")
45
46 def __init__(self, **kwargs):
47 super().__init__(name="Player", **kwargs)
48 # The runner owns the ground line and hands it over with set_horizon
49 # before anything updates, so there is no window size baked in here.
50 self._horizon = 0.0
51 self.position = Vec2(START_X, -HALF_HEIGHT)
52 self.velocity_y = 0.0
53 self._airborne = False
54
55 # Walk cycle: from_frames stitches the two PNGs into one strip atlas and
56 # registers them as an animation. Its ``name`` is the *animation* name,
57 # so the node keeps its own name separately.
58 self.walk_anim = self.add_child(
59 AnimatedSprite2D.from_frames(
60 [str(_PLAYER_GFX / "player_walk_1.png"), str(_PLAYER_GFX / "player_walk_2.png")],
61 fps=8.0,
62 loop=True,
63 name="walk",
64 play=True,
65 )
66 )
67 self.walk_anim.name = "Walk"
68 # Single-frame jump pose, shown instead of the walk cycle while airborne.
69 self.sprite_jump = self.add_child(
70 Sprite2D(
71 texture=str(_PLAYER_GFX / "jump.png"),
72 width=72,
73 height=84,
74 name="Jump",
75 )
76 )
77 self.sprite_jump.visible = False
78
79 # Jump SFX: child node so backend wiring is automatic on enter_tree.
80 # Generated procedurally (no bundled audio files; see nodes/audio.py).
81 self.jump_sfx = self.add_child(
82 AudioPlayer(
83 stream=make_jump(),
84 bus="SFX",
85 volume_db=-6.0,
86 name="JumpSFX",
87 )
88 )
89
90 def set_horizon(self, horizon: float) -> None:
91 """Update the ground line so the player rests on it after window resize."""
92 self._horizon = float(horizon)
93 if not self._airborne:
94 self.position = Vec2(float(self.position.x), self._horizon - HALF_HEIGHT)
95
96 def _set_airborne_visual(self, airborne: bool) -> None:
97 """Show jump frame while airborne, walk animation while grounded."""
98 self.walk_anim.visible = not airborne
99 self.sprite_jump.visible = airborne
100 if airborne:
101 self.walk_anim.stop()
102 else:
103 if not self.walk_anim.playing:
104 self.walk_anim.play("walk")
105
106 def reset(self):
107 """Restore midbottom on the current horizon (called from runner restart)."""
108 self.position = Vec2(START_X, self._horizon - HALF_HEIGHT)
109 self.velocity_y = 0.0
110 self._airborne = False
111 self._set_airborne_visual(False)
112
113 @property
114 def rect(self) -> Rect2:
115 """Tight axis-aligned collision box.
116
117 We intentionally use the walk-frame footprint (64x84) whichever frame is
118 showing, pygame did the same.
119 """
120 return Rect2(float(self.position.x) - 32.0, float(self.position.y) - HALF_HEIGHT, 64.0, 84.0)
121
122 def on_update(self, dt: float):
123 # One action covers space, the up arrow, W, a tap anywhere on the
124 # screen and the pad's A button, so there is nothing to branch on.
125 if Input.just_pressed("jump") and not self._airborne:
126 self.velocity_y = self.speed_jump
127 self._airborne = True
128 self.jump_sfx.play()
129
130 # Apply gravity and integrate.
131 self.velocity_y += self.gravity * dt
132 new_y = float(self.position.y) + self.velocity_y * dt
133 ground_centre_y = self._horizon - HALF_HEIGHT
134 if new_y >= ground_centre_y:
135 new_y = ground_centre_y
136 self.velocity_y = 0.0
137 self._airborne = False
138 self.position = Vec2(float(self.position.x), new_y)
139
140 # Visual: jump frame while airborne, otherwise the walking AnimatedSprite2D.
141 self._set_airborne_visual(self._airborne)