nodes/player.py¶
Part of PirateMaker.
1"""PirateMaker Player node: controls, gravity, AABB collision, animation."""
2
3from __future__ import annotations
4
5from settings import GFX
6from support import player_subfolders
7
8from simvx.core import (
9 AudioClip,
10 AudioPlayer,
11 Input,
12 Node2D,
13 Vec2,
14)
15
16from .folder_sprite import FolderSprite
17
18# Tunables
19SPEED = 300.0
20GRAVITY = 1500.0 # px / s²
21JUMP_VELOCITY = -700.0 # initial v_y on jump
22DAMAGE_INVUL = 0.6 # seconds
23
24
25class Player(Node2D):
26 """Side-scrolling platformer player with 4-state animation."""
27
28 def __init__(
29 self,
30 position: Vec2,
31 collision_rects: list[tuple[float, float, float, float]],
32 jump_stream: AudioClip,
33 level: Node2D,
34 pad,
35 ):
36 super().__init__(position=position)
37 self.collision_rects = collision_rects
38 self._jump_stream = jump_stream
39 self._level = level
40 # On-screen movement pad, so the level is playable by touch and mouse.
41 self._pad = pad
42 self._jump_was_held = False
43
44 # Movement state
45 self.direction = Vec2(0, 0)
46 self.on_floor = False
47 self.orientation = "right"
48 self.status = "idle"
49
50 # Hitbox half-sizes (centred on position)
51 self.hw = 14.0
52 self.hh = 28.0
53
54 # Animations: 8 named (idle/run/jump/fall × left/right)
55 self._anim_set = player_subfolders(GFX / "player")
56 self._sprite = FolderSprite(
57 frames=self._anim_set.get("idle_right", []),
58 fps=8.0,
59 )
60 self.add_child(self._sprite)
61
62 self._invul_t = 0.0
63
64 def try_damage(self) -> bool:
65 if self._invul_t > 0:
66 return False
67 self._invul_t = DAMAGE_INVUL
68 # Knockback up
69 self.direction = Vec2(self.direction.x, -350.0)
70 return True
71
72 def _held(self, action: str) -> bool:
73 """Whether ``action`` is held on the keyboard or on the movement pad."""
74 return Input.is_action_pressed(action) or self._pad.is_held(action)
75
76 def on_update(self, dt: float) -> None:
77 self._invul_t = max(0.0, self._invul_t - dt)
78
79 # Input
80 if self._held("move_left"):
81 self.direction = Vec2(-1.0, self.direction.y)
82 self.orientation = "left"
83 elif self._held("move_right"):
84 self.direction = Vec2(1.0, self.direction.y)
85 self.orientation = "right"
86 else:
87 self.direction = Vec2(0.0, self.direction.y)
88
89 # Jump on the rising edge, so holding the key (or the pad button) does
90 # not re-trigger the moment the player lands.
91 jump_held = self._held("jump")
92 if jump_held and not self._jump_was_held and self.on_floor:
93 self.direction = Vec2(self.direction.x, JUMP_VELOCITY)
94 self._level.add_child(AudioPlayer(stream=self._jump_stream, autoplay=True, volume_db=-14.0))
95 self._jump_was_held = jump_held
96
97 # Gravity
98 self.direction = Vec2(self.direction.x, self.direction.y + GRAVITY * dt)
99
100 # Move horizontally + collide
101 new_x = self.position.x + self.direction.x * SPEED * dt
102 new_x = self._collide_axis(new_x, self.position.y, axis_x=True)
103
104 # Move vertically + collide
105 new_y = self.position.y + self.direction.y * dt
106 new_y, vert_hit = self._collide_axis(new_x, new_y, axis_x=False, return_hit=True)
107 if vert_hit:
108 self.direction = Vec2(self.direction.x, 0.0)
109
110 self.position = Vec2(new_x, new_y)
111 self._update_floor_check()
112 self._update_animation()
113
114 def _collide_axis(self, nx: float, ny: float, *, axis_x: bool, return_hit: bool = False):
115 """AABB collision against world rects on a single axis."""
116 my_l = nx - self.hw
117 my_r = nx + self.hw
118 my_t = ny - self.hh
119 my_b = ny + self.hh
120 hit = False
121 for rx, ry, rw, rh in self.collision_rects:
122 if my_r <= rx or my_l >= rx + rw:
123 continue
124 if my_b <= ry or my_t >= ry + rh:
125 continue
126 # Overlap → resolve along axis
127 hit = True
128 if axis_x:
129 if self.direction.x > 0:
130 nx = rx - self.hw
131 elif self.direction.x < 0:
132 nx = rx + rw + self.hw
133 else:
134 if self.direction.y > 0:
135 ny = ry - self.hh
136 elif self.direction.y < 0:
137 ny = ry + rh + self.hh
138 my_l = nx - self.hw
139 my_r = nx + self.hw
140 my_t = ny - self.hh
141 my_b = ny + self.hh
142 if return_hit:
143 if axis_x:
144 return nx, hit
145 return ny, hit
146 return nx if axis_x else ny
147
148 def _update_floor_check(self) -> None:
149 # Probe a 2px-tall rect just below the hitbox
150 probe_t = self.position.y + self.hh
151 probe_b = probe_t + 2
152 probe_l = self.position.x - self.hw
153 probe_r = self.position.x + self.hw
154 self.on_floor = False
155 for rx, ry, rw, rh in self.collision_rects:
156 if probe_r <= rx or probe_l >= rx + rw:
157 continue
158 if probe_b <= ry or probe_t >= ry + rh:
159 continue
160 self.on_floor = True
161 return
162
163 def _update_animation(self) -> None:
164 if self.direction.y < -50:
165 self.status = "jump"
166 elif self.direction.y > 50 and not self.on_floor:
167 self.status = "fall"
168 else:
169 self.status = "run" if abs(self.direction.x) > 0.1 else "idle"
170 frames = self._anim_set.get(f"{self.status}_{self.orientation}")
171 if frames:
172 self._sprite.play(frames)
173
174 # Damage flash: tint while invuln
175 if self._invul_t > 0:
176 self._sprite.colour = (1.0, 1.0, 1.0, 0.5)
177 else:
178 self._sprite.colour = (1.0, 1.0, 1.0, 1.0)