10 KiB
Input Centralization Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Centralize all non-UI input handling in PlayerController so gameplay input can be disabled with a single flag.
Architecture: PlayerController owns all mouse input (clicks, drags, grid tracking) and emits signals. CameraController and TileHighlight become purely reactive — no direct input reading. strategy_phase.gd wires everything together and toggles input_disabled during overlays.
Tech Stack: Godot 4, GDScript
Task 1: Add drag detection and input_disabled to PlayerController
Files:
-
Modify:
nodes/player_controller.gd -
Step 1: Add new signals, properties, and drag state variables
Add after the existing signal combat_requested line (line 7) and update the variable block:
signal combat_requested(attacker: Unit, defender: Unit)
signal mouse_grid_changed(coords: Vector2i)
signal camera_drag(delta: Vector2)
var input_disabled := false
var _selected_unit: Unit = null
var _target_pos: Vector2
var _goal_pos: Vector2
var _moving := false
var _left_pending := false
var _drag_start := Vector2.ZERO
var _dragging := false
var _current_grid_coords := Vector2i(-99999, -99999)
const DRAG_THRESHOLD := 8.0
- Step 2: Add mouse grid tracking to
_process
Add a new _process method. This replaces the mouse tracking that tile_highlight.gd currently does:
func _process(_delta: float) -> void:
if input_disabled:
return
var mouse_pos := get_viewport().get_canvas_transform().affine_inverse() * get_viewport().get_mouse_position()
var coords := dl_map.world_to_coords(mouse_pos)
if coords != _current_grid_coords:
_current_grid_coords = coords
mouse_grid_changed.emit(coords)
- Step 3: Add drag handling to
_unhandled_input
Replace the existing _unhandled_input method with this version that integrates drag detection (ported from camera_controller.gd):
func _unhandled_input(event: InputEvent) -> void:
if input_disabled:
return
if event is InputEventMouseButton:
match event.button_index:
MOUSE_BUTTON_LEFT:
if event.pressed:
_left_pending = true
_drag_start = event.position
else:
if _dragging:
_dragging = false
_left_pending = false
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
get_viewport().set_input_as_handled()
else:
_left_pending = false
_handle_left_click(event.position)
MOUSE_BUTTON_MIDDLE:
if event.pressed:
_dragging = true
_drag_start = event.position
Input.set_default_cursor_shape(Input.CURSOR_DRAG)
else:
_dragging = false
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
get_viewport().set_input_as_handled()
elif event is InputEventMouseMotion:
if _left_pending and not _dragging:
if event.position.distance_to(_drag_start) >= DRAG_THRESHOLD:
_dragging = true
_left_pending = false
Input.set_default_cursor_shape(Input.CURSOR_DRAG)
if _dragging:
var delta: Vector2 = _drag_start - event.position
_drag_start = event.position
camera_drag.emit(delta)
get_viewport().set_input_as_handled()
- Step 4: Extract click logic into
_handle_left_click
Add this method — it's the existing click logic from _unhandled_input, refactored out so the drag/click split is clean:
func _handle_left_click(screen_pos: Vector2) -> void:
var world_pos: Vector2 = get_viewport().get_canvas_transform().affine_inverse() * screen_pos
var clicked_unit := _get_unit_at(world_pos)
if clicked_unit:
if _selected_unit and clicked_unit != _selected_unit and _selected_unit.is_alive() and clicked_unit.is_alive():
combat_requested.emit(_selected_unit, clicked_unit)
else:
_select_unit(clicked_unit)
get_viewport().set_input_as_handled()
elif _selected_unit:
var snapped_pos := dl_map.snap_to_grid(world_pos)
var grid_coords := dl_map.world_to_coords(world_pos)
if dl_map.is_wall(grid_coords):
return
_goal_pos = snapped_pos
get_viewport().set_input_as_handled()
- Step 5: Verify in editor
Run the scene. Confirm:
-
Clicking units still selects/moves them
-
Left-dragging pans camera (after 8px threshold)
-
Middle-dragging pans camera immediately
-
Grid highlight still works (will be wired in Task 4, but shouldn't crash)
-
Step 6: Commit
git add nodes/player_controller.gd
git commit -m "feat: add drag detection and input_disabled to PlayerController"
Task 2: Make CameraController reactive
Files:
-
Modify:
scripts/camera_controller.gd -
Step 1: Replace CameraController with reactive version
Replace the entire file content with:
class_name CameraController extends Camera2D
func apply_drag(delta: Vector2) -> void:
position += delta / zoom
- Step 2: Verify in editor
Run the scene. Camera drag won't work yet (signal not wired until Task 4) but the scene should load without errors.
- Step 3: Commit
git add scripts/camera_controller.gd
git commit -m "refactor: make CameraController reactive, remove direct input handling"
Task 3: Make TileHighlight reactive
Files:
-
Modify:
scripts/tile_highlight.gd -
Step 1: Replace TileHighlight with reactive version
Replace the entire file content with:
extends ColorRect
@export var tile_size: float = 48.0
var _time: float = 0.0
func _ready() -> void:
size = Vector2(tile_size, tile_size)
color = Color(1.0, 1.0, 1.0, 0.25)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _process(delta: float) -> void:
_time += delta
color.a = 0.25 + 0.1 * sin(_time * 4.0)
func set_grid_coords(coords: Vector2i) -> void:
global_position = Vector2(coords) * tile_size
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_MOUSE_EXIT:
hide()
elif what == NOTIFICATION_WM_MOUSE_ENTER:
show()
The tile_hovered signal is removed — PlayerController now owns mouse_grid_changed. The _process only handles the pulse animation. set_grid_coords positions the highlight from external coords.
- Step 2: Verify in editor
Run the scene. Tile highlight won't track mouse yet (not wired until Task 4) but the scene should load without errors and the pulse animation should work if visible.
- Step 3: Commit
git add scripts/tile_highlight.gd
git commit -m "refactor: make TileHighlight reactive, remove direct mouse tracking"
Task 4: Wire signals in strategy_phase.gd and update CombatMap
Files:
-
Modify:
nodes/strategy_phase.gd -
Modify:
nodes/combat_map.gd -
Step 1: Update CombatMap.set_highlight_enabled to accept no args
In nodes/combat_map.gd, the set_highlight_enabled method currently controls tile highlight visibility. It stays, but now it also needs to be callable from strategy_phase. No changes needed to the method itself — it already does what we need. However, remove the tile_hovered signal from CombatMap since PlayerController now owns grid change signaling.
Remove line 4 from combat_map.gd:
signal tile_hovered(coords: Vector2i)
And update target_tile (line 83-85) to remove the signal emit:
func target_tile(coords: Vector2i) -> void:
highlight_map.target_tile(coords)
- Step 2: Wire signals in strategy_phase.gd
Replace the entire file content of nodes/strategy_phase.gd:
class_name StrategyPhase extends Node2D
@onready var player_controller: PlayerController = $PlayerController
@onready var combat_system: CombatSystem = $CombatSystem
@onready var combat_ui: CombatUI = $CombatUI
@onready var combat_map: CombatMap = $CombatMap
@onready var camera: CameraController = $Camera2D
func _ready() -> void:
player_controller.combat_requested.connect(_on_combat_requested)
player_controller.mouse_grid_changed.connect(_on_mouse_grid_changed)
player_controller.camera_drag.connect(camera.apply_drag)
combat_ui.fight_confirmed.connect(_on_fight_confirmed)
combat_ui.fight_cancelled.connect(_on_fight_cancelled)
func _on_mouse_grid_changed(coords: Vector2i) -> void:
combat_map.target_tile(coords)
combat_map.tile_highlight.set_grid_coords(coords)
func _on_combat_requested(attacker: Unit, defender: Unit) -> void:
var proposal := combat_system.create_proposal(attacker, defender)
_set_input_disabled(true)
combat_ui.show_proposal(proposal)
func _on_fight_confirmed(proposal: CombatProposal) -> void:
combat_system.apply_proposal(proposal)
_set_input_disabled(false)
func _on_fight_cancelled() -> void:
_set_input_disabled(false)
func _set_input_disabled(disabled: bool) -> void:
player_controller.input_disabled = disabled
combat_map.set_highlight_enabled(not disabled)
- Step 3: Verify in editor
Run the scene. Confirm all behaviors work end-to-end:
-
Mouse movement highlights tiles on the grid
-
Left-click selects units and sets movement targets
-
Left-drag (past threshold) pans the camera
-
Middle-drag pans the camera immediately
-
Opening combat proposal disables all input (no highlight, no clicks, no drags)
-
Confirming or cancelling combat re-enables input
-
Cursor resets to arrow when drag ends
-
Step 4: Commit
git add nodes/strategy_phase.gd nodes/combat_map.gd
git commit -m "feat: wire centralized input signals, toggle input_disabled on overlays"
Task 5: Clean up unused code
Files:
-
Modify:
nodes/combat_map.gd(verify no remaining references to removed signal) -
Step 1: Check for remaining references to
tile_hoveredsignal on CombatMap
Search the codebase for any connections to combat_map.tile_hovered or CombatMap.tile_hovered. If found, remove or update them.
- Step 2: Verify full scene runs cleanly
Run the scene one final time. Confirm no errors in the Godot console and all input behaviors work as expected.
- Step 3: Commit if any cleanup was needed
git add -A
git commit -m "chore: remove stale tile_hovered references"