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))