nodes/player.pyΒΆ
Part of Dodge the Creeps.
1"""Player: moves with WASD/arrows, plays walk/up animations, emits `hit`."""
2
3from __future__ import annotations
4
5import math
6
7from simvx.core import (
8 AnimatedSprite2D,
9 CharacterBody2D,
10 CircleShape2D,
11 Input,
12 Property,
13 Signal,
14 Vec2,
15)
16
17from .sprite_sheets import player_sheet
18
19
20class Player(CharacterBody2D):
21 """Player character.
22
23 Implemented with `CharacterBody2D` (so we get group-based collision queries
24 for free) plus a child `AnimatedSprite2D` for the walk/up animations. The
25 `hit` signal is emitted when the main scene detects collision with a mob.
26 """
27
28 speed = Property(400.0, range=(0, 1000), hint="Pixels per second")
29
30 hit = Signal()
31
32 def __init__(self, screen_size: Vec2, **kwargs):
33 # Capsule-ish radius: generous enough to catch any mob touching the
34 # player sprite at scale 0.5 (~50px wide).
35 super().__init__(shape=CircleShape2D(24.0), **kwargs)
36 self.add_to_group("player")
37 self.screen_size = Vec2(screen_size)
38 self._alive = False # mirrors Godot's `hide()` / `show()` flow
39
40 walk_sheet, walk_w, walk_h = player_sheet("walk")
41 self.sprite = self.add_child(
42 AnimatedSprite2D(
43 texture=walk_sheet,
44 frames_h=2,
45 frames_v=1,
46 width=walk_w,
47 height=walk_h,
48 scale=Vec2(0.5, 0.5),
49 name="AnimatedSprite2D",
50 )
51 )
52 self.sprite.add_animation("walk", frames=[0, 1], fps=5.0, loop=True)
53 self.sprite.add_animation("up", frames=[0, 1], fps=5.0, loop=True)
54 # `player_sheet` is cached, so the "up" sheet is only built the first
55 # time the player walks upward.
56 self._current_anim = "walk"
57
58 def _switch_anim(self, name: str):
59 if self._current_anim == name and self.sprite.playing:
60 return
61 if self._current_anim != name:
62 # Assigning `texture` invalidates the sprite's GPU handle on its
63 # own, so the new sheet is uploaded on the next frame.
64 self.sprite.texture = player_sheet(name)[0]
65 self._current_anim = name
66 self.sprite.play(name)
67
68 def overlapping_mobs(self) -> list:
69 """Mobs whose circular collider currently overlaps the player's.
70
71 Replaces the old arcade ``CharacterBody2D.get_overlapping(group=)``: these
72 ports integrate motion by hand (no PhysicsRoot / broadphase), so collision
73 is a direct circle-circle distance test over the ``mobs`` group, exactly
74 what the old arcade query did internally.
75 """
76 if not self.tree:
77 return []
78 pr = float(self.shape.radius)
79 px, py = float(self.position.x), float(self.position.y)
80 hits = []
81 for mob in self.tree.group("mobs"):
82 mr = float(mob.shape.radius)
83 dx, dy = px - float(mob.position.x), py - float(mob.position.y)
84 if dx * dx + dy * dy <= (pr + mr) * (pr + mr):
85 hits.append(mob)
86 return hits
87
88 def start(self, position: Vec2):
89 """Spawn at *position*, restoring visibility and collision."""
90 self.position = Vec2(position)
91 self.rotation = 0.0
92 self.visible = True
93 self.sprite.visible = True
94 self._alive = True
95
96 def kill(self):
97 """Hide and disable. Mirrors Godot's deferred CollisionShape disable."""
98 self._alive = False
99 self.visible = False
100 self.sprite.visible = False
101 self.sprite.stop()
102
103 def on_update(self, dt: float):
104 if not self._alive:
105 return
106
107 # Keyboard / WASD / arrow input (action-based for web export).
108 velocity = Input.get_vector("move_left", "move_right", "move_up", "move_down")
109
110 # Mouse / touch fallback: if no keyboard input AND the left button is
111 # held, walk toward the cursor / finger. This makes the game playable
112 # on mobile (touch surfaces as a mouse press in the SimVX web runtime).
113 if velocity.length() == 0 and Input.is_action_pressed("touch_move"):
114 target = Input.mouse_position
115 delta = Vec2(float(target.x) - float(self.position.x), float(target.y) - float(self.position.y))
116 if delta.length() > 4.0: # dead-zone so we don't jitter on top of the tap
117 velocity = delta.normalized()
118
119 if velocity.length() > 0:
120 velocity = velocity.normalized() * self.speed
121 # Pick animation: prefer horizontal walk if any X movement, else up.
122 if velocity.x != 0:
123 self._switch_anim("walk")
124 # Flip horizontally by negative X scale; preserve magnitude.
125 sx = -0.5 if velocity.x < 0 else 0.5
126 self.sprite.scale = Vec2(sx, 0.5)
127 self.rotation = 0.0
128 else:
129 self._switch_anim("up")
130 self.sprite.scale = Vec2(0.5, 0.5)
131 # Face downward when moving down: flip the whole player.
132 self.rotation = math.pi if velocity.y > 0 else 0.0
133 else:
134 self.sprite.stop()
135
136 self.position += velocity * dt
137 # Clamp to the live window size so resize works correctly.
138 sw, sh = self.tree.screen_size if self.tree else (self.screen_size.x, self.screen_size.y)
139 self.position = Vec2(
140 max(0.0, min(float(sw), float(self.position.x))),
141 max(0.0, min(float(sh), float(self.position.y))),
142 )