nodes/player.pyΒΆ

Part of GDQuest Open RPG.

  1"""PlayerController: WASD/arrow grid movement + 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 .gameboard import bfs_path, cell_to_pixel, is_walkable
 12from .settings import TILE
 13
 14
 15class Player(Sprite2D):
 16    """Top-down player. Step-based movement (one tile per press, smooth lerp)."""
 17
 18    MOVE_SPEED = 4.0  # tiles/sec
 19
 20    def __init__(self, start_cell: tuple[int, int] = (15, 8)) -> None:
 21        super().__init__(texture=art.knight_sprite())
 22        self.filter = "nearest"
 23        self.width = TILE
 24        self.height = TILE
 25        self.cell: tuple[int, int] = tuple(start_cell)
 26        self.position = self.cell_to_world(self.cell)
 27        self._target: tuple[int, int] | None = None
 28        self._move_t = 0.0
 29        self._from_pos = Vec2(self.position)
 30        self._to_pos = Vec2(self.position)
 31        self._input_enabled = True
 32        self._path: list[tuple[int, int]] = []  # for click-to-path
 33
 34        self.cell_arrived = Signal()  # (cx, cy)
 35        self.interact_requested = Signal()  # (cell,) - the cell in front
 36
 37        self._facing: tuple[int, int] = (0, 1)  # last direction faced
 38
 39    @staticmethod
 40    def cell_to_world(cell: tuple[int, int]) -> Vec2:
 41        x, y = cell_to_pixel(*cell)
 42        return Vec2(x, y)
 43
 44    def set_input_enabled(self, enabled: bool) -> None:
 45        """Hand control to (or take it from) the player.
 46
 47        Disabling also drops any queued click-to-path steps: a path that kept
 48        walking through a dialogue or a fade would silently cross trigger cells
 49        and leave the player somewhere else when control came back.
 50        """
 51        self._input_enabled = enabled
 52        if not enabled:
 53            self._path = []
 54
 55    # ------------------------------------------------------------------
 56    # Step movement
 57    # ------------------------------------------------------------------
 58    def _try_step(self, dx: int, dy: int) -> bool:
 59        cx, cy = self.cell
 60        nx, ny = cx + dx, cy + dy
 61        if not is_walkable(nx, ny):
 62            return False
 63        self._target = (nx, ny)
 64        self._from_pos = Vec2(self.position)
 65        self._to_pos = self.cell_to_world(self._target)
 66        self._move_t = 0.0
 67        self._facing = (dx, dy) if (dx, dy) != (0, 0) else self._facing
 68        return True
 69
 70    def on_update(self, dt: float) -> None:
 71        # Movement lerp
 72        if self._target is not None:
 73            duration = 1.0 / self.MOVE_SPEED
 74            self._move_t += dt
 75            t = min(1.0, self._move_t / duration)
 76            # Ease-out for snappier feel
 77            ease = 1.0 - (1.0 - t) * (1.0 - t)
 78            self.position = Vec2(
 79                self._from_pos.x + (self._to_pos.x - self._from_pos.x) * ease,
 80                self._from_pos.y + (self._to_pos.y - self._from_pos.y) * ease,
 81            )
 82            if t >= 1.0:
 83                self.cell = self._target
 84                self._target = None
 85                self.position = self._to_pos
 86                self.cell_arrived.emit(self.cell[0], self.cell[1])
 87                # Continue path if click-to-path is active. `cell_arrived` may
 88                # have started a dialogue or an encounter, which revokes input:
 89                # re-read the flag rather than walking on through the fade.
 90                if self._path and self._input_enabled:
 91                    nxt = self._path.pop(0)
 92                    dx = nxt[0] - self.cell[0]
 93                    dy = nxt[1] - self.cell[1]
 94                    if not self._try_step(dx, dy):
 95                        self._path = []
 96                # Fall through to input check so a held key continues stepping.
 97            else:
 98                return
 99
100        if not self._input_enabled:
101            return
102
103        # Polled input: WASD / arrows. Hold to step continuously.
104        # New click cancels current path.
105        dx = dy = 0
106        if Input.is_action_pressed("up"):
107            dy = -1
108        elif Input.is_action_pressed("down"):
109            dy = 1
110        elif Input.is_action_pressed("left"):
111            dx = -1
112        elif Input.is_action_pressed("right"):
113            dx = 1
114        if dx != 0 or dy != 0:
115            self._path = []
116            self._try_step(dx, dy)
117            return
118
119        # Interact: emit interact for the cell in front (E key only; SPACE and
120        # ENTER are reserved for dialog/menu confirm to avoid double-trigger).
121        if Input.is_action_just_pressed("interact"):
122            tx = self.cell[0] + self._facing[0]
123            ty = self.cell[1] + self._facing[1]
124            self.interact_requested.emit((tx, ty))
125            return
126
127        # Click-to-path: BFS to clicked cell on left mouse press
128        if Input.is_action_just_pressed("primary"):
129            mx, my = Input.mouse_position
130            tx, ty = int(mx // TILE), int(my // TILE)
131            if 0 <= tx and 0 <= ty:
132                path = bfs_path(self.cell, (tx, ty))
133                if path:
134                    self._path = path[1:]  # first step happens immediately
135                    nxt = path[0]
136                    dx = nxt[0] - self.cell[0]
137                    dy = nxt[1] - self.cell[1]
138                    self._try_step(dx, dy)