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