Files
MaidEngine/docs/superpowers/plans/2026-04-02-input-centralization.md
2026-04-02 23:02:18 -04:00

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_hovered signal 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"