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        )