scripts/click_controller.pyΒΆ
Part of Dungeon Explorer.
1"""Click-to-path controller -- click ground to move, click enemy to attack, click interactable to interact."""
2
3import math
4
5from simvx.core import Input, Mouse, Vec2
6
7
8class ClickController:
9 """Click-to-path: click ground to move, click enemy to attack, click interactable to interact.
10
11 Uses NavGrid2D pathfinding and Camera2D screen-to-world conversion.
12 Single attack per click -- player must click again to attack again.
13 """
14
15 def __init__(self):
16 self._path: list[tuple[float, float]] = []
17 self._path_index = 0
18 self._target_enemy = None
19 self._target_interactable = None
20 self._click_pos: Vec2 | None = None # World-space click destination
21 self._click_pulse = 0.0 # Visual feedback timer
22 self._player = None
23 self._camera = None
24 self._nav_grid = None
25 self._enemies_fn = None # Callable returning list of enemies
26 self._interactables_fn = None # Callable returning list of interactables
27 self._attack_fn = None # Callable to trigger attack
28 self._interact_fn = None # Callable to trigger interact
29 self._attack_range = 30.0
30 self._interact_range = 40.0
31 self._active = False
32
33 def setup(
34 self,
35 player,
36 camera,
37 nav_grid,
38 enemies_fn=None,
39 interactables_fn=None,
40 attack_fn=None,
41 interact_fn=None,
42 ):
43 """Configure controller with game references."""
44 self._player = player
45 self._camera = camera
46 self._nav_grid = nav_grid
47 self._enemies_fn = enemies_fn
48 self._interactables_fn = interactables_fn
49 self._attack_fn = attack_fn
50 self._interact_fn = interact_fn
51
52 def on_update(self, dt: float):
53 """Process click input and path following each frame."""
54 if self._player is None:
55 return
56
57 self._click_pulse = max(0.0, self._click_pulse - dt)
58
59 # Cancel on any keyboard movement
60 if (
61 Input.pressed("move_up")
62 or Input.pressed("move_down")
63 or Input.pressed("move_left")
64 or Input.pressed("move_right")
65 ):
66 self.cancel()
67 return
68
69 # Cancel on right-click
70 if Input.is_mouse_button_just_pressed(Mouse.RIGHT):
71 self.cancel()
72 return
73
74 # Handle new click
75 if Input.is_mouse_button_just_pressed(Mouse.LEFT):
76 mx, my = Input.mouse_position
77 self._handle_click(mx, my)
78
79 # Follow path
80 if self._path and self._path_index < len(self._path):
81 self._follow_path(dt)
82 elif self._target_enemy or self._target_interactable:
83 self._approach_target(dt)
84
85 def _handle_click(self, mx: float, my: float):
86 """Classify click and start pathfinding."""
87 if not self._camera or not self._nav_grid:
88 return
89
90 # Convert screen to world coordinates. screen_to_world reads the
91 # camera's own viewport when it is not handed a size, so a resize
92 # cannot leave the click landing somewhere else than it was aimed.
93 world_pos = self._camera.screen_to_world(Vec2(mx, my))
94
95 # Check proximity to enemies
96 self._target_enemy = None
97 self._target_interactable = None
98 if self._enemies_fn:
99 for enemy in self._enemies_fn():
100 dx = enemy.position.x - world_pos.x
101 dy = enemy.position.y - world_pos.y
102 if math.sqrt(dx * dx + dy * dy) < 40:
103 self._target_enemy = enemy
104 break
105
106 # Check proximity to interactables
107 if not self._target_enemy and self._interactables_fn:
108 for obj in self._interactables_fn():
109 ox = obj.get("world_x", 0) - world_pos.x
110 oy = obj.get("world_y", 0) - world_pos.y
111 if math.sqrt(ox * ox + oy * oy) < 40:
112 self._target_interactable = obj
113 break
114
115 # Pathfind to destination
116 target = world_pos
117 if self._target_enemy:
118 target = Vec2(self._target_enemy.position.x, self._target_enemy.position.y)
119 elif self._target_interactable:
120 target = Vec2(self._target_interactable.get("world_x", 0), self._target_interactable.get("world_y", 0))
121
122 from_pos = (float(self._player.position.x), float(self._player.position.y))
123 to_pos = (float(target.x), float(target.y))
124 self._path = self._nav_grid.find_path_world(from_pos, to_pos)
125 self._path_index = 0
126 self._click_pos = target
127 self._click_pulse = 1.0
128 self._active = True
129
130 def _follow_path(self, dt: float):
131 """Move player toward next waypoint."""
132 if self._path_index >= len(self._path):
133 self._finish()
134 return
135
136 wp = self._path[self._path_index]
137 dx = wp[0] - self._player.position.x
138 dy = wp[1] - self._player.position.y
139 dist = math.sqrt(dx * dx + dy * dy)
140
141 if dist < 8.0:
142 self._path_index += 1
143 if self._path_index >= len(self._path):
144 self._arrive()
145 return
146 return
147
148 # Set player velocity toward waypoint
149 nx, ny = dx / dist, dy / dist
150 self._player.velocity = Vec2(nx * self._player.speed, ny * self._player.speed)
151 self._player._facing = Vec2(nx, ny)
152 self._player.move_and_slide(dt)
153 self._player._walk_timer += dt * 8.0
154
155 def _approach_target(self, dt: float):
156 """Move toward target enemy/interactable when path is done."""
157 target_pos = None
158 range_needed = self._attack_range
159
160 if self._target_enemy and self._target_enemy.hp > 0:
161 target_pos = self._target_enemy.position
162 range_needed = self._attack_range
163 elif self._target_interactable:
164 target_pos = Vec2(self._target_interactable.get("world_x", 0), self._target_interactable.get("world_y", 0))
165 range_needed = self._interact_range
166
167 if target_pos is None:
168 self._finish()
169 return
170
171 dx = target_pos.x - self._player.position.x
172 dy = target_pos.y - self._player.position.y
173 dist = math.sqrt(dx * dx + dy * dy)
174
175 if dist <= range_needed:
176 self._arrive()
177 elif dist > 0.01:
178 nx, ny = dx / dist, dy / dist
179 self._player.velocity = Vec2(nx * self._player.speed, ny * self._player.speed)
180 self._player._facing = Vec2(nx, ny)
181 self._player.move_and_slide(dt)
182 self._player._walk_timer += dt * 8.0
183
184 def _arrive(self):
185 """Arrived at destination -- perform action."""
186 if self._target_enemy and self._target_enemy.hp > 0:
187 if self._attack_fn:
188 self._attack_fn()
189 self._target_enemy = None
190 elif self._target_interactable:
191 if self._interact_fn:
192 self._interact_fn()
193 self._target_interactable = None
194 self._finish()
195
196 def _finish(self):
197 """Clear path and stop movement."""
198 self._path = []
199 self._path_index = 0
200 self._active = False
201 if self._player:
202 self._player.velocity = Vec2()
203
204 def cancel(self):
205 """Cancel current movement."""
206 self._target_enemy = None
207 self._target_interactable = None
208 self._finish()
209
210 @property
211 def is_active(self) -> bool:
212 return self._active
213
214 def on_draw(self, renderer):
215 """Draw visual feedback -- click destination indicator and path dots."""
216 if self._click_pos is None or self._click_pulse <= 0:
217 return
218
219 alpha = self._click_pulse
220 cx, cy = self._click_pos.x, self._click_pos.y
221
222 # Target indicator
223 if self._target_enemy:
224 colour = (1.0, 0.3, 0.3, alpha * 0.6)
225 elif self._target_interactable:
226 colour = (0.3, 1.0, 0.3, alpha * 0.6)
227 else:
228 colour = (1.0, 1.0, 1.0, alpha * 0.4)
229
230 # Pulsing circle
231 r = 6 + (1.0 - alpha) * 8
232 renderer.circle((cx, cy), r, fill=colour)
233
234 # Path dots (faint)
235 if self._path and self._path_index < len(self._path):
236 for i in range(self._path_index, min(len(self._path), self._path_index + 20)):
237 wp = self._path[i]
238 renderer.rect((wp[0] - 1, wp[1] - 1), (2, 2), fill=(0.7, 0.7, 0.7, 0.2))