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)