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)