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        )