nodes/player.pyΒΆ

Part of GDQuest Open RPG.

  1"""PlayerController: grid movement on one typed action, plus interact."""
  2
  3from __future__ import annotations
  4
  5from simvx.core import Sprite2D
  6from simvx.core.input.state import Input
  7from simvx.core.math.types import Vec2
  8from simvx.core.signals import Signal
  9
 10from . import sprites as art
 11from .controls import EXPLORING
 12from .gameboard import bfs_path, cell_to_pixel, is_walkable
 13from .settings import TILE
 14
 15
 16class Player(Sprite2D):
 17    """Top-down player. Step-based movement (one tile per press, smooth lerp)."""
 18
 19    #: Walking, talking and click-to-path are live exactly while the player is
 20    #: in the tree, so a test that mounts the player alone can drive them.
 21    input_context = EXPLORING
 22
 23    MOVE_SPEED = 4.0  # tiles/sec
 24    #: How far ``move`` must be pushed before it counts as a step. A key is full
 25    #: deflection, so this only matters to a stick and a drawn thumb.
 26    STEP_DEADZONE = 0.5
 27
 28    def __init__(self, start_cell: tuple[int, int] = (15, 8)) -> None:
 29        super().__init__(texture=art.knight_sprite())
 30        self.filter = "nearest"
 31        self.width = TILE
 32        self.height = TILE
 33        self.cell: tuple[int, int] = tuple(start_cell)
 34        self.position = self.cell_to_world(self.cell)
 35        self._target: tuple[int, int] | None = None
 36        self._move_t = 0.0
 37        self._from_pos = Vec2(self.position)
 38        self._to_pos = Vec2(self.position)
 39        self._input_enabled = True
 40        self._path: list[tuple[int, int]] = []  # for click-to-path
 41
 42        self.cell_arrived = Signal()  # (cx, cy)
 43        self.interact_requested = Signal()  # (cell,) - the cell in front
 44
 45        self._facing: tuple[int, int] = (0, 1)  # last direction faced
 46
 47    @staticmethod
 48    def cell_to_world(cell: tuple[int, int]) -> Vec2:
 49        x, y = cell_to_pixel(*cell)
 50        return Vec2(x, y)
 51
 52    def set_input_enabled(self, enabled: bool) -> None:
 53        """Hand control to (or take it from) the player.
 54
 55        Disabling also drops any queued click-to-path steps: a path that kept
 56        walking through a dialogue or a fade would silently cross trigger cells
 57        and leave the player somewhere else when control came back.
 58        """
 59        self._input_enabled = enabled
 60        if not enabled:
 61            self._path = []
 62
 63    # ------------------------------------------------------------------
 64    # Step movement
 65    # ------------------------------------------------------------------
 66    def _try_step(self, dx: int, dy: int) -> bool:
 67        cx, cy = self.cell
 68        nx, ny = cx + dx, cy + dy
 69        if not is_walkable(nx, ny):
 70            return False
 71        self._target = (nx, ny)
 72        self._from_pos = Vec2(self.position)
 73        self._to_pos = self.cell_to_world(self._target)
 74        self._move_t = 0.0
 75        self._facing = (dx, dy) if (dx, dy) != (0, 0) else self._facing
 76        return True
 77
 78    def on_update(self, dt: float) -> None:
 79        # Movement lerp
 80        if self._target is not None:
 81            duration = 1.0 / self.MOVE_SPEED
 82            self._move_t += dt
 83            t = min(1.0, self._move_t / duration)
 84            # Ease-out for snappier feel
 85            ease = 1.0 - (1.0 - t) * (1.0 - t)
 86            self.position = Vec2(
 87                self._from_pos.x + (self._to_pos.x - self._from_pos.x) * ease,
 88                self._from_pos.y + (self._to_pos.y - self._from_pos.y) * ease,
 89            )
 90            if t >= 1.0:
 91                self.cell = self._target
 92                self._target = None
 93                self.position = self._to_pos
 94                self.cell_arrived.emit(self.cell[0], self.cell[1])
 95                # Continue path if click-to-path is active. `cell_arrived` may
 96                # have started a dialogue or an encounter, which revokes input:
 97                # re-read the flag rather than walking on through the fade.
 98                if self._path and self._input_enabled:
 99                    nxt = self._path.pop(0)
100                    dx = nxt[0] - self.cell[0]
101                    dy = nxt[1] - self.cell[1]
102                    if not self._try_step(dx, dy):
103                        self._path = []
104                # Fall through to input check so a held key continues stepping.
105            else:
106                return
107
108        if not self._input_enabled:
109            return
110
111        # Polled input: one typed action, whichever device is driving it. Hold
112        # to step continuously. A new step cancels the current click path.
113        # The grid takes one direction at a time, so a diagonal push resolves to
114        # its longer half, and a tie goes to the vertical.
115        move = Input.value("move")
116        dx = dy = 0
117        if move.length() >= self.STEP_DEADZONE:
118            if abs(move.y) >= abs(move.x):
119                dy = 1 if move.y > 0 else -1
120            else:
121                dx = 1 if move.x > 0 else -1
122        if dx != 0 or dy != 0:
123            self._path = []
124            self._try_step(dx, dy)
125            return
126
127        # Interact: emit interact for the cell in front. E or the pad's bottom
128        # face button; SPACE and ENTER stay with dialogue and the battle menu so
129        # one press never does two things.
130        if Input.just_pressed("interact"):
131            tx = self.cell[0] + self._facing[0]
132            ty = self.cell[1] + self._facing[1]
133            self.interact_requested.emit((tx, ty))
134            return
135
136        # Click-to-path: BFS to the tapped cell.
137        if Input.just_pressed("walk_to"):
138            mx, my = Input.mouse_position
139            tx, ty = int(mx // TILE), int(my // TILE)
140            if 0 <= tx and 0 <= ty:
141                path = bfs_path(self.cell, (tx, ty))
142                if path:
143                    self._path = path[1:]  # first step happens immediately
144                    nxt = path[0]
145                    dx = nxt[0] - self.cell[0]
146                    dy = nxt[1] - self.cell[1]
147                    self._try_step(dx, dy)