Files
MaidEngine/docs/superpowers/plans/2026-04-04-combat-tactics.md
2026-04-04 12:27:35 -04:00

23 KiB

Combat Tactics 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: Add selectable combat tactics to the combat proposal system so units can choose between different attacks (and Defend) during combat.

Architecture: Polymorphic resource hierarchy for both tactics and range checking. Tactics encapsulate their own stat logic via virtual methods. The combat system delegates stat resolution to tactics rather than interpreting configuration. The UI adds an OptionButton per combatant for tactic selection.

Tech Stack: Godot 4.6, GDScript

Spec: docs/superpowers/specs/2026-04-04-combat-tactics-design.md


File Map

New Files

File Responsibility
resources/resource_definitions/combat_tactic_range.gd Base range class with is_valid_range() virtual method
resources/resource_definitions/fixed_combat_tactic_range.gd Range check against a fixed tile distance
resources/resource_definitions/any_combat_tactic_range.gd Always-valid range (for Defend)
resources/resource_definitions/unit_matching_combat_tactic_range.gd Range check against the unit's atk_range stat
resources/resource_definitions/combat_tactic.gd Base tactic class with get_offensive_stats(), get_relevant_defense(), deals_damage()
resources/resource_definitions/attack_combat_tactic.gd Physical attack tactic (uses phys_atk/phys_def)
resources/resource_definitions/defend_combat_tactic.gd No-attack tactic

Modified Files

File Changes
nodes/unit.gd Add @export var tactics array, append built-in Attack/Defend at _ready()
resources/resource_definitions/combat_proposal.gd Add available_tactics, selected_tactic to CombatantStats
nodes/combat_system.gd Tactic-aware create_proposal(), new update_tactic(), AI selection, tactic-aware apply_proposal()
scripts/combat_ui.gd Add OptionButton per side, wire tactic changes to CombatSystem.update_tactic(), refresh stats
prefabs/combat_ui.tscn Add OptionButton nodes to attacker/defender stat columns
nodes/strategy_phase.gd Pass combat_system reference to combat_ui, pass distance to create_proposal()

Task 1: CombatTacticRange Hierarchy

Files:

  • Create: resources/resource_definitions/combat_tactic_range.gd

  • Create: resources/resource_definitions/fixed_combat_tactic_range.gd

  • Create: resources/resource_definitions/any_combat_tactic_range.gd

  • Create: resources/resource_definitions/unit_matching_combat_tactic_range.gd

  • Step 1: Create the base CombatTacticRange class

# resources/resource_definitions/combat_tactic_range.gd
class_name CombatTacticRange extends Resource

func is_valid_range(distance: int, unit: Unit) -> bool:
	return false
  • Step 2: Create FixedCombatTacticRange
# resources/resource_definitions/fixed_combat_tactic_range.gd
class_name FixedCombatTacticRange extends CombatTacticRange

@export var tactic_range: int = 1

func is_valid_range(distance: int, unit: Unit) -> bool:
	return distance <= tactic_range
  • Step 3: Create AnyCombatTacticRange
# resources/resource_definitions/any_combat_tactic_range.gd
class_name AnyCombatTacticRange extends CombatTacticRange

func is_valid_range(distance: int, unit: Unit) -> bool:
	return true
  • Step 4: Create UnitMatchingCombatTacticRange
# resources/resource_definitions/unit_matching_combat_tactic_range.gd
class_name UnitMatchingCombatTacticRange extends CombatTacticRange

func is_valid_range(distance: int, unit: Unit) -> bool:
	return distance <= unit.current_stats.atk_range
  • Step 5: Verify in Godot

Open the project in the Godot editor and confirm all four scripts load without errors in the Output panel.

  • Step 6: Commit
git add resources/resource_definitions/combat_tactic_range.gd resources/resource_definitions/fixed_combat_tactic_range.gd resources/resource_definitions/any_combat_tactic_range.gd resources/resource_definitions/unit_matching_combat_tactic_range.gd
git commit -m "feat: add CombatTacticRange hierarchy"

Task 2: CombatTactic Base Class and Subclasses

Files:

  • Create: resources/resource_definitions/combat_tactic.gd

  • Create: resources/resource_definitions/attack_combat_tactic.gd

  • Create: resources/resource_definitions/defend_combat_tactic.gd

  • Step 1: Create the base CombatTactic class

Note: tactic_name is used instead of name to avoid shadowing Resource.name. Subclasses override all three virtual methods.

# resources/resource_definitions/combat_tactic.gd
class_name CombatTactic extends Resource

@export var tactic_name: String = ""
@export var tactic_range: CombatTacticRange

func get_offensive_stats(unit: Unit) -> Variant:
	return null

func get_relevant_defense(unit: Unit) -> int:
	return unit.current_stats.phys_def

func deals_damage() -> bool:
	return false
  • Step 2: Create AttackCombatTactic
# resources/resource_definitions/attack_combat_tactic.gd
class_name AttackCombatTactic extends CombatTactic

func get_offensive_stats(unit: Unit) -> Variant:
	return {"atk": unit.current_stats.phys_atk, "hit": unit.current_stats.hit}

func get_relevant_defense(unit: Unit) -> int:
	return unit.current_stats.phys_def

func deals_damage() -> bool:
	return true
  • Step 3: Create DefendCombatTactic
# resources/resource_definitions/defend_combat_tactic.gd
class_name DefendCombatTactic extends CombatTactic

func get_offensive_stats(unit: Unit) -> Variant:
	return null

func get_relevant_defense(unit: Unit) -> int:
	return unit.current_stats.phys_def

func deals_damage() -> bool:
	return false
  • Step 4: Verify in Godot

Open the Godot editor and confirm all three scripts load without errors. In the inspector, verify that creating a new AttackCombatTactic resource shows tactic_name and tactic_range as exported properties.

  • Step 5: Commit
git add resources/resource_definitions/combat_tactic.gd resources/resource_definitions/attack_combat_tactic.gd resources/resource_definitions/defend_combat_tactic.gd
git commit -m "feat: add CombatTactic base class with Attack and Defend subclasses"

Task 3: Unit Tactic Assignment

Files:

  • Modify: nodes/unit.gd

  • Step 1: Add tactics export and built-in appending

In nodes/unit.gd, add the export var after the existing template exports (after line 8), and modify _ready() to append built-in tactics:

# After the existing @export vars (line 8):
@export var tactics: Array[CombatTactic] = []

Replace the existing _ready() function (lines 16-19) with:

func _ready() -> void:
	current_stats = stat_template.duplicate(true)
	current_info = info_template.duplicate(true)
	current_allegiance = allegiance_template.duplicate(true)
	_append_builtin_tactics()
	unit_allegiance_changed.emit(self, current_allegiance)

Add the new helper method after _ready():

func _append_builtin_tactics() -> void:
	var attack := AttackCombatTactic.new()
	attack.tactic_name = "Attack"
	attack.tactic_range = UnitMatchingCombatTacticRange.new()
	tactics.append(attack)

	var defend := DefendCombatTactic.new()
	defend.tactic_name = "Defend"
	defend.tactic_range = AnyCombatTacticRange.new()
	tactics.append(defend)
  • Step 2: Verify in Godot

Run the game. Units should initialize without errors. Add a temporary print(tactics) at the end of _ready() to confirm each unit has Attack and Defend in their tactics list. Remove the print after verifying.

  • Step 3: Commit
git add nodes/unit.gd
git commit -m "feat: add tactics list to Unit with built-in Attack and Defend"

Task 4: CombatProposal Tactic Support

Files:

  • Modify: resources/resource_definitions/combat_proposal.gd

  • Step 1: Add tactic fields to CombatantStats

Replace the entire file content with:

class_name CombatProposal extends Resource

class CombatantStats:
	var unit: Unit
	var max_hp: int
	var hp: int
	var sp: int
	var hit: int
	var atk: int
	var def: int
	var spd: int
	var available_tactics: Array[CombatTactic] = []
	var selected_tactic: CombatTactic

var attacker: CombatantStats
var defender: CombatantStats
  • Step 2: Verify in Godot

Open the Godot editor and confirm no errors. The existing combat_ui.gd and combat_system.gd still reference the same fields (atk, def, hit, etc.) so nothing should break yet.

  • Step 3: Commit
git add resources/resource_definitions/combat_proposal.gd
git commit -m "feat: add available_tactics and selected_tactic to CombatantStats"

Task 5: CombatSystem — Tactic-Aware Proposal Creation

Files:

  • Modify: nodes/combat_system.gd

This task rewrites the combat system to use tactics for stat resolution. The create_proposal method gains a distance parameter and filters/selects tactics.

  • Step 1: Rewrite create_proposal and _snapshot

Replace the entire content of nodes/combat_system.gd with:

class_name CombatSystem extends Node

func create_proposal(attacker: Unit, defender: Unit, distance: int) -> CombatProposal:
	var proposal := CombatProposal.new()

	var atk_tactics := _filter_tactics(attacker, distance)
	var def_tactics := _filter_tactics(defender, distance)

	var atk_tactic := _find_default_attack(atk_tactics)
	var def_tactic := _find_default_attack(def_tactics)

	proposal.attacker = _snapshot(attacker, defender, atk_tactics, atk_tactic, def_tactic)
	proposal.defender = _snapshot(defender, attacker, def_tactics, def_tactic, atk_tactic)

	return proposal


func _filter_tactics(unit: Unit, distance: int) -> Array[CombatTactic]:
	var valid: Array[CombatTactic] = []
	for tactic in unit.tactics:
		if tactic.tactic_range and tactic.tactic_range.is_valid_range(distance, unit):
			valid.append(tactic)
	return valid


func _find_default_attack(tactics: Array[CombatTactic]) -> CombatTactic:
	for tactic in tactics:
		if tactic is AttackCombatTactic:
			return tactic
	return tactics[0] if tactics.size() > 0 else null


func _snapshot(unit: Unit, opponent: Unit, available: Array[CombatTactic], selected: CombatTactic, opponent_selected: CombatTactic) -> CombatProposal.CombatantStats:
	var stats := CombatProposal.CombatantStats.new()
	stats.unit = unit
	stats.max_hp = unit.current_stats.max_hp
	stats.hp = unit.current_stats.current_hp
	stats.sp = unit.current_stats.current_sp
	stats.spd = unit.current_stats.spd
	stats.available_tactics = available
	stats.selected_tactic = selected

	if selected and selected.deals_damage():
		var offensive := selected.get_offensive_stats(unit)
		stats.atk = offensive["atk"]
		stats.hit = offensive["hit"] - opponent.current_stats.eva
	else:
		stats.atk = 0
		stats.hit = 0

	if opponent_selected and opponent_selected.deals_damage():
		stats.def = opponent_selected.get_relevant_defense(unit)
	else:
		stats.def = unit.current_stats.phys_def

	return stats


func update_tactic(proposal: CombatProposal, is_attacker: bool, tactic: CombatTactic) -> void:
	var self_stats: CombatProposal.CombatantStats
	var opp_stats: CombatProposal.CombatantStats
	if is_attacker:
		self_stats = proposal.attacker
		opp_stats = proposal.defender
	else:
		self_stats = proposal.defender
		opp_stats = proposal.attacker

	self_stats.selected_tactic = tactic

	# Recalculate this side's offensive stats
	if tactic and tactic.deals_damage():
		var offensive := tactic.get_offensive_stats(self_stats.unit)
		self_stats.atk = offensive["atk"]
		self_stats.hit = offensive["hit"] - opp_stats.unit.current_stats.eva
	else:
		self_stats.atk = 0
		self_stats.hit = 0

	# Recalculate opponent's def based on this side's new tactic
	if tactic and tactic.deals_damage():
		opp_stats.def = tactic.get_relevant_defense(opp_stats.unit)
	else:
		opp_stats.def = opp_stats.unit.current_stats.phys_def


func select_ai_tactic(unit: Unit, opponent: Unit, available_tactics: Array[CombatTactic]) -> CombatTactic:
	var best_tactic: CombatTactic = null
	var best_damage := -1

	for tactic in available_tactics:
		if not tactic.deals_damage():
			continue
		var offensive := tactic.get_offensive_stats(unit)
		var defense := tactic.get_relevant_defense(opponent)
		var damage := maxi(offensive["atk"] - defense, 0)
		if damage > best_damage:
			best_damage = damage
			best_tactic = tactic

	if best_tactic == null or best_damage <= 0:
		for tactic in available_tactics:
			if tactic is DefendCombatTactic:
				return tactic
		return available_tactics[0] if available_tactics.size() > 0 else null

	return best_tactic


func process_combat(attacker: Unit, defender: Unit, distance: int) -> void:
	if not attacker.is_alive() or not defender.is_alive():
		return
	var proposal := create_proposal(attacker, defender, distance)
	var atk_name := attacker.current_info.name
	var def_name := defender.current_info.name
	print("=== Combat: %s vs %s ===" % [atk_name, def_name])
	print("  %s — HP:%d ATK:%d DEF:%d HIT:%d" % [atk_name, proposal.attacker.hp, proposal.attacker.atk, proposal.attacker.def, proposal.attacker.hit])
	print("  %s — HP:%d ATK:%d DEF:%d HIT:%d" % [def_name, proposal.defender.hp, proposal.defender.atk, proposal.defender.def, proposal.defender.hit])
	apply_proposal(proposal)
	var atk_hp := attacker.current_stats.current_hp if is_instance_valid(attacker) else 0
	var def_hp := defender.current_stats.current_hp if is_instance_valid(defender) else 0
	print("  Result: %s HP=%d, %s HP=%d" % [atk_name, atk_hp, def_name, def_hp])


func apply_proposal(proposal: CombatProposal) -> void:
	var atk_stats := proposal.attacker
	var def_stats := proposal.defender
	var atk_unit := atk_stats.unit
	var def_unit := def_stats.unit

	# Attacker strikes (if their tactic deals damage)
	if atk_stats.selected_tactic and atk_stats.selected_tactic.deals_damage():
		var atk_roll := randi_range(1, 100)
		if atk_roll <= atk_stats.hit:
			var damage := maxi(atk_stats.atk - def_stats.def, 0)
			def_unit.take_damage(damage)

	# Counterattack if defender survives and their tactic deals damage
	if def_unit.is_alive() and def_stats.selected_tactic and def_stats.selected_tactic.deals_damage():
		var def_roll := randi_range(1, 100)
		if def_roll <= def_stats.hit:
			var damage := maxi(def_stats.atk - atk_stats.def, 0)
			atk_unit.take_damage(damage)
  • Step 2: Update StrategyPhase to pass distance

In nodes/strategy_phase.gd, replace _on_combat_requested (lines 22-25) with:

func _on_combat_requested(attacker: Unit, defender: Unit) -> void:
	var atk_coords := combat_map.world_to_coords(attacker.position)
	var def_coords := combat_map.world_to_coords(defender.position)
	var distance := absi(atk_coords.x - def_coords.x) + absi(atk_coords.y - def_coords.y)
	var proposal := combat_system.create_proposal(attacker, defender, distance)
	_set_input_disabled(true)
	combat_ui.show_proposal(proposal)
  • Step 3: Verify in Godot

Run the game. Select a unit, click an enemy to trigger combat proposal. The proposal panel should appear with stats displayed as before (Attack is auto-selected, same phys_atk/phys_def values). Confirm and verify combat resolves normally. Check the Output panel for the combat log prints.

  • Step 4: Commit
git add nodes/combat_system.gd nodes/strategy_phase.gd
git commit -m "feat: tactic-aware combat proposal creation and resolution"

Task 6: AI Tactic Selection Wiring

Files:

  • Modify: nodes/combat_system.gd

  • Modify: nodes/strategy_phase.gd

  • Step 1: Wire AI tactic selection into proposal creation

In nodes/combat_system.gd, modify create_proposal to auto-select for non-player units. Replace the create_proposal method with:

func create_proposal(attacker: Unit, defender: Unit, distance: int) -> CombatProposal:
	var proposal := CombatProposal.new()

	var atk_tactics := _filter_tactics(attacker, distance)
	var def_tactics := _filter_tactics(defender, distance)

	var atk_tactic := _find_default_attack(atk_tactics)
	var def_tactic := _find_default_attack(def_tactics)

	# AI auto-selects for non-player units
	if not _is_player_controlled(defender):
		def_tactic = select_ai_tactic(defender, attacker, def_tactics)

	proposal.attacker = _snapshot(attacker, defender, atk_tactics, atk_tactic, def_tactic)
	proposal.defender = _snapshot(defender, attacker, def_tactics, def_tactic, atk_tactic)

	return proposal

Add the _is_player_controlled helper at the bottom of the file:

func _is_player_controlled(unit: Unit) -> bool:
	return unit.current_allegiance.type == UnitAllegiance.AllegianceType.PLAYER
  • Step 2: Verify in Godot

Run the game and initiate combat against an enemy unit. The defender should auto-select their best tactic (Attack with base stats, since no custom tactics exist yet). Add a temporary print in select_ai_tactic to confirm it's being called for enemy units: print("AI selected: ", best_tactic.tactic_name). Remove after verifying.

  • Step 3: Commit
git add nodes/combat_system.gd
git commit -m "feat: wire AI tactic auto-selection for non-player defenders"

Task 7: Combat UI — Tactic Selector

Files:

  • Modify: prefabs/combat_ui.tscn

  • Modify: scripts/combat_ui.gd

  • Modify: nodes/strategy_phase.gd

  • Step 1: Add OptionButton nodes to the .tscn

In prefabs/combat_ui.tscn, add an OptionButton node to each side's stat column. Insert after the AttackerNameLabel node (after line 93) and after the DefenderNameLabel node (after line 127).

Add these nodes to the .tscn file:

After the AttackerNameLabel node (line 93), insert:

[node name="AttackerTacticSelect" type="OptionButton" parent="CombatProposalPanel/MarginContainer/VBoxContainer/StatsContainer/AttackerStats"]
unique_name_in_owner = true
layout_mode = 2

After the DefenderNameLabel node (line 127, which shifts due to insertion above), insert:

[node name="DefenderTacticSelect" type="OptionButton" parent="CombatProposalPanel/MarginContainer/VBoxContainer/StatsContainer/DefenderStats"]
unique_name_in_owner = true
layout_mode = 2
  • Step 2: Update combat_ui.gd with tactic selector references and logic

In scripts/combat_ui.gd, add the new @onready vars after the existing ones (after line 25):

@onready var atk_tactic_select: OptionButton = %AttackerTacticSelect
@onready var def_tactic_select: OptionButton = %DefenderTacticSelect

Add a combat_system variable that will be set by StrategyPhase (after line 28):

var combat_system: CombatSystem

In _ready(), connect the OptionButton signals. Add after the cancel_button.pressed.connect line (after line 34):

atk_tactic_select.item_selected.connect(_on_atk_tactic_selected)
def_tactic_select.item_selected.connect(_on_def_tactic_selected)

Replace the show_proposal method (lines 77-96) with:

func show_proposal(proposal: CombatProposal) -> void:
	_current_proposal = proposal
	_populate_tactic_select(atk_tactic_select, proposal.attacker)
	_populate_tactic_select(def_tactic_select, proposal.defender)
	_refresh_stats()
	background_tint.visible = true
	proposal_panel.visible = true

Add the new helper methods at the bottom of the file:

func _populate_tactic_select(button: OptionButton, combatant: CombatProposal.CombatantStats) -> void:
	button.clear()
	var selected_idx := 0
	for i in combatant.available_tactics.size():
		var tactic := combatant.available_tactics[i]
		button.add_item(tactic.tactic_name)
		if tactic == combatant.selected_tactic:
			selected_idx = i
	button.selected = selected_idx
	# Disable dropdown for AI-controlled units (read-only display)
	var is_player := combatant.unit.current_allegiance.type == UnitAllegiance.AllegianceType.PLAYER
	button.disabled = not is_player


func _refresh_stats() -> void:
	var atk := _current_proposal.attacker
	var def := _current_proposal.defender
	atk_name_label.text = atk.unit.current_info.name
	atk_hp_bar.max_value = atk.max_hp
	atk_hp_bar.value = atk.hp
	atk_atk_label.text = "ATK: %d" % atk.atk
	atk_def_label.text = "DEF: %d" % atk.def
	atk_hit_label.text = "HIT: %d%%" % atk.hit
	atk_spd_label.text = "SPD: %d" % atk.spd
	def_name_label.text = def.unit.current_info.name
	def_hp_bar.max_value = def.max_hp
	def_hp_bar.value = def.hp
	def_atk_label.text = "ATK: %d" % def.atk
	def_def_label.text = "DEF: %d" % def.def
	def_hit_label.text = "HIT: %d%%" % def.hit
	def_spd_label.text = "SPD: %d" % def.spd


func _on_atk_tactic_selected(index: int) -> void:
	if not _current_proposal or not combat_system:
		return
	var tactic := _current_proposal.attacker.available_tactics[index]
	combat_system.update_tactic(_current_proposal, true, tactic)
	_refresh_stats()


func _on_def_tactic_selected(index: int) -> void:
	if not _current_proposal or not combat_system:
		return
	var tactic := _current_proposal.defender.available_tactics[index]
	combat_system.update_tactic(_current_proposal, false, tactic)
	_refresh_stats()
  • Step 3: Wire combat_system reference in StrategyPhase

In nodes/strategy_phase.gd, add this line at the end of _ready() (after line 14):

combat_ui.combat_system = combat_system
  • Step 4: Verify in Godot

Run the game and trigger a combat proposal:

  1. Confirm the OptionButton appears on each side showing "Attack" selected
  2. Open the dropdown — should show "Attack" and "Defend"
  3. Select "Defend" on the attacker side — ATK and HIT should drop to 0
  4. Select "Attack" again — stats should restore
  5. Confirm the defender's dropdown is disabled (greyed out) for enemy units
  6. Click Fight — combat should resolve correctly using the selected tactics
  7. Click Cancel — proposal should dismiss normally
  • Step 5: Commit
git add prefabs/combat_ui.tscn scripts/combat_ui.gd nodes/strategy_phase.gd
git commit -m "feat: add tactic selector UI to combat proposal panel"

Task 8: End-to-End Verification

  • Step 1: Test basic Attack vs Attack

Run the game. Select a player unit, click an enemy. Both should default to Attack. Stats should match the pre-tactic behavior (phys_atk, phys_def, hit - eva). Confirm fight, verify damage is applied correctly via the combat log prints.

  • Step 2: Test Defend selection

Trigger a combat proposal. Switch the attacker to Defend. ATK and HIT should show 0. Confirm fight — the attacker should deal no damage, defender should still counterattack.

  • Step 3: Test defender Defend (for player vs player scenario)

If possible, set up two player-allegiance units (or temporarily change an enemy's allegiance to PLAYER in the editor). Trigger combat between them. Both dropdowns should be enabled. Set defender to Defend — defender should deal no damage on counterattack.

  • Step 4: Test AI auto-selection

Trigger combat against an enemy. The enemy's tactic dropdown should show their auto-selected tactic (Attack, since it's the only damage-dealing option) and be disabled/greyed out.

  • Step 5: Verify no regressions

Move units around the map, select/deselect, trigger multiple combats in sequence. Verify no errors in the Output panel. Verify unit death still works (HP drops to 0, unit removed).