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)