nodes/player.pyΒΆ
Part of Dodge the Creeps.
1"""Player: moves with keys, a stick or the drawn pad, and emits `hit`."""
2
3from __future__ import annotations
4
5import math
6
7from simvx.core import (
8 AnimatedSprite2D,
9 Axis2D,
10 CharacterBody2D,
11 CircleShape2D,
12 Controller,
13 Input,
14 InputContext,
15 Key,
16 Property,
17 Signal,
18 Vec2,
19)
20
21from .sprite_sheets import player_sheet
22
23
24class Player(CharacterBody2D):
25 """Player character.
26
27 Implemented with `CharacterBody2D` (so we get group-based collision queries
28 for free) plus a child `AnimatedSprite2D` for the walk/up animations. The
29 `hit` signal is emitted when the main scene detects collision with a mob.
30 """
31
32 # Walking is one typed action rather than four opposed ones, so the two key
33 # sets, a controller's left stick and the drawn pad's stick all feed the
34 # same binding and the one furthest from rest wins. The context is live
35 # exactly while this node is in the tree, so a test that loads the player
36 # alone can steer it without registering anything.
37 input_context = InputContext(
38 {
39 "move": [
40 Axis2D(up=Key.W, down=Key.S, left=Key.A, right=Key.D),
41 Axis2D(up=Key.UP, down=Key.DOWN, left=Key.LEFT, right=Key.RIGHT),
42 Controller.LEFT_STICK,
43 ],
44 },
45 name="player",
46 )
47
48 speed = Property(400.0, range=(0, 1000), hint="Pixels per second")
49
50 #: How far sideways the player has to lean before the walk sprite is the
51 #: one to show, as a fraction of a fully pushed stick.
52 SIDEWAYS = 0.25
53
54 hit = Signal()
55
56 def __init__(self, **kwargs):
57 # Capsule-ish radius: generous enough to catch any mob touching the
58 # player sprite at scale 0.5 (~50px wide).
59 super().__init__(shape=CircleShape2D(24.0), **kwargs)
60 self.add_to_group("player")
61 self._alive = False # mirrors Godot's `hide()` / `show()` flow
62
63 walk_sheet, walk_w, walk_h = player_sheet("walk")
64 self.sprite = self.add_child(
65 AnimatedSprite2D(
66 texture=walk_sheet,
67 frames_h=2,
68 frames_v=1,
69 width=walk_w,
70 height=walk_h,
71 scale=Vec2(0.5, 0.5),
72 name="AnimatedSprite2D",
73 )
74 )
75 self.sprite.add_animation("walk", frames=[0, 1], fps=5.0, loop=True)
76 self.sprite.add_animation("up", frames=[0, 1], fps=5.0, loop=True)
77 # `player_sheet` is cached, so the "up" sheet is only built the first
78 # time the player walks upward.
79 self._current_anim = "walk"
80
81 def _switch_anim(self, name: str):
82 if self._current_anim == name and self.sprite.playing:
83 return
84 if self._current_anim != name:
85 # Assigning `texture` invalidates the sprite's GPU handle on its
86 # own, so the new sheet is uploaded on the next frame.
87 self.sprite.texture = player_sheet(name)[0]
88 self._current_anim = name
89 self.sprite.play(name)
90
91 def overlapping_mobs(self) -> list:
92 """Mobs whose circular collider currently overlaps the player's.
93
94 Replaces the old arcade ``CharacterBody2D.get_overlapping(group=)``: these
95 ports integrate motion by hand (no PhysicsRoot / broadphase), so collision
96 is a direct circle-circle distance test over the ``mobs`` group, exactly
97 what the old arcade query did internally.
98 """
99 if not self.tree:
100 return []
101 pr = float(self.shape.radius)
102 px, py = float(self.position.x), float(self.position.y)
103 hits = []
104 for mob in self.tree.group("mobs"):
105 mr = float(mob.shape.radius)
106 dx, dy = px - float(mob.position.x), py - float(mob.position.y)
107 if dx * dx + dy * dy <= (pr + mr) * (pr + mr):
108 hits.append(mob)
109 return hits
110
111 def start(self, position: Vec2):
112 """Spawn at *position*, restoring visibility and collision."""
113 self.position = Vec2(position)
114 self.rotation = 0.0
115 self.visible = True
116 self.sprite.visible = True
117 self._alive = True
118
119 def kill(self):
120 """Hide and disable. Mirrors Godot's deferred CollisionShape disable."""
121 self._alive = False
122 self.visible = False
123 self.sprite.visible = False
124 self.sprite.stop()
125
126 def on_update(self, dt: float):
127 if not self._alive:
128 return
129
130 # One read covers the keys, a controller's stick and the drawn pad.
131 # A held key reads as a full unit, a stick as how far it is pushed, so
132 # the same line walks at full speed on a keyboard and eases on a stick.
133 direction = Input.value("move")
134 velocity = direction * self.speed
135
136 if direction.length() > 0:
137 # Pick animation: the sideways walk once the player is leaning far
138 # enough sideways for it to read, otherwise the up/down sprite.
139 if abs(float(direction.x)) > self.SIDEWAYS:
140 self._switch_anim("walk")
141 # Flip horizontally by negative X scale; preserve magnitude.
142 sx = -0.5 if direction.x < 0 else 0.5
143 self.sprite.scale = Vec2(sx, 0.5)
144 self.rotation = 0.0
145 else:
146 self._switch_anim("up")
147 self.sprite.scale = Vec2(0.5, 0.5)
148 # Face downward when moving down: flip the whole player.
149 self.rotation = math.pi if direction.y > 0 else 0.0
150 else:
151 self.sprite.stop()
152
153 self.position += velocity * dt
154 # Clamp to the live size of whatever this node draws into, so a resize
155 # moves the walls with it.
156 width, height = self.viewport_size
157 self.position = Vec2(
158 max(0.0, min(float(width), float(self.position.x))),
159 max(0.0, min(float(height), float(self.position.y))),
160 )