Combat tactics

This commit is contained in:
gamer147
2026-04-04 12:27:35 -04:00
parent 595e389033
commit 68d1406632
22 changed files with 1094 additions and 33 deletions

View File

@@ -0,0 +1,667 @@
# 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**
```gdscript
# 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**
```gdscript
# 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**
```gdscript
# 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**
```gdscript
# 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**
```bash
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.
```gdscript
# 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**
```gdscript
# 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**
```gdscript
# 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**
```bash
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:
```gdscript
# After the existing @export vars (line 8):
@export var tactics: Array[CombatTactic] = []
```
Replace the existing `_ready()` function (lines 16-19) with:
```gdscript
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()`:
```gdscript
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**
```bash
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:
```gdscript
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**
```bash
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:
```gdscript
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:
```gdscript
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**
```bash
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:
```gdscript
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:
```gdscript
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**
```bash
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):
```gdscript
@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):
```gdscript
var combat_system: CombatSystem
```
In `_ready()`, connect the OptionButton signals. Add after the `cancel_button.pressed.connect` line (after line 34):
```gdscript
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:
```gdscript
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:
```gdscript
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):
```gdscript
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**
```bash
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).

View File

@@ -0,0 +1,169 @@
# Combat Tactics System Design
## Overview
Units gain a list of selectable combat tactics that modify how they participate in combat. Tactics encapsulate their own stat logic — each tactic knows how to produce offensive stats and determine the relevant defensive stat, rather than exposing configuration for the combat system to interpret. Both attacker and defender select a tactic during the combat proposal phase, with AI auto-selecting for non-player units.
## Data Model
### CombatTactic Base Class
A new base `CombatTactic` resource (`resources/resource_definitions/combat_tactic.gd`):
**Properties:**
- `name: String` — display name (e.g., "Attack", "Heavy Strike", "Fireball", "Defend")
- `range: CombatTacticRange` — exported resource determining range validity (see below)
**Methods (virtual, overridden by subclasses):**
- `func get_offensive_stats(unit: Unit) -> Dictionary` — returns `{"atk": int, "hit": int}` with the tactic's effective offensive values pulled from the unit's stats and modified as needed. Returns `null` if the tactic does not attack (e.g., Defend).
- `func get_relevant_defense(unit: Unit) -> int` — returns the defense value from the given unit that applies against this tactic's attack. E.g., a physical tactic returns `unit.current_stats.phys_def`, a magical one returns `unit.current_stats.magic_def`.
- `func deals_damage() -> bool` — returns whether this tactic produces an attack. Base implementation returns `get_offensive_stats() != null`. Subclasses like Defend override to return `false`.
The combat system never interprets damage types or applies modifiers — it just calls these methods.
### CombatTactic Subclasses
**`AttackCombatTactic`** extends `CombatTactic`:
- `get_offensive_stats(unit)` returns `{"atk": unit.current_stats.phys_atk, "hit": unit.current_stats.hit}`
- `get_relevant_defense(unit)` returns `unit.current_stats.phys_def`
**`DefendCombatTactic`** extends `CombatTactic`:
- `get_offensive_stats(unit)` returns `null`
- `deals_damage()` returns `false`
- `get_relevant_defense(unit)` returns `unit.current_stats.phys_def` (default for display; unused in resolution)
Future subclasses (not built now, examples for illustration):
- `MagicAttackCombatTactic` — pulls `magic_atk`/`magic_def`, could apply modifiers
- `HeavyAttackCombatTactic` — pulls `phys_atk + 5` and `hit - 20`
### CombatTacticRange Hierarchy
Range checking is polymorphic via a resource subclass hierarchy:
**`CombatTacticRange`** (base Resource, `resources/resource_definitions/combat_tactic_range.gd`):
- `func is_valid_range(distance: int, unit: Unit) -> bool` — abstract, returns false by default
**`FixedCombatTacticRange`** extends `CombatTacticRange`:
- `tactic_range: int` — the fixed max range in tiles
- Returns `distance <= tactic_range`
**`AnyCombatTacticRange`** extends `CombatTacticRange`:
- Always returns `true`
**`UnitMatchingCombatTacticRange`** extends `CombatTacticRange`:
- Returns `distance <= unit.current_stats.atk_range`
### Built-in Tactics
Two built-in tactic instances, always available to all units:
- **Attack** — `AttackCombatTactic` with `range: UnitMatchingCombatTacticRange`
- **Defend** — `DefendCombatTactic` with `range: AnyCombatTacticRange`
These are instantiated in code (not `.tres` files) since they use specific subclasses. Future tactics with more complex behavior would also be subclasses; a generic parameterized subclass can be added later when patterns emerge across many similar tactics.
## Unit Changes
### Tactic Assignment
`Unit` gains:
- `@export var tactics: Array[CombatTactic]` — additional tactics beyond the built-ins (e.g., Fireball, Heavy Strike)
- At `_ready()`, Attack and Defend are appended to the unit's tactic list automatically. Custom tactics from the export list are kept as-is. This guarantees every unit always has Attack and Defend.
## CombatProposal Changes
### CombatantStats Additions
`CombatantStats` gains:
- `available_tactics: Array[CombatTactic]` — tactics filtered to those valid for the combat distance
- `selected_tactic: CombatTactic` — the chosen tactic (defaults to Attack)
The stat snapshot (`atk`, `def`, `hit`, etc.) is produced by calling the selected tactic's methods:
- `atk` and `hit` come from `selected_tactic.get_offensive_stats(unit)` (null if the tactic doesn't attack)
- `def` comes from the **opponent's** `selected_tactic.get_relevant_defense(this_unit)` — the defense stat used is determined by what attack is incoming, not what this unit is doing
- When the opponent's tactic doesn't deal damage, `def` defaults to `phys_def` for display purposes
### Stat Recalculation
When a tactic is changed on either side:
1. The combatant's offensive stats recalculate via `new_tactic.get_offensive_stats(unit)`
2. The opponent's defensive stat recalculates via `new_tactic.get_relevant_defense(opponent)`
3. Both sides' snapshots update accordingly
## CombatSystem Changes
### Proposal Creation
`create_proposal(attacker, defender)` expands to:
1. Calculate tile distance between attacker and defender
2. For each unit, filter their tactics list by `tactic.range.is_valid_range(distance, unit)`
3. Default-select Attack for both sides
4. Snapshot stats with Attack's modifiers applied (none for the base case)
5. For AI combatants, auto-select optimal tactic (see AI Selection below)
### New Method: update_tactic
`update_tactic(proposal, is_attacker: bool, tactic: CombatTactic)`:
- Sets the combatant's `selected_tactic`
- Recalculates that combatant's offensive stats via `tactic.get_offensive_stats(unit)`
- Recalculates the opponent's defensive stat via `tactic.get_relevant_defense(opponent)`
- Called by the UI when the player picks a different tactic
### Combat Resolution
`apply_proposal()` changes:
- If a combatant's `selected_tactic.deals_damage()` returns `false`, skip their attack entirely (no hit roll, no damage)
- Otherwise, resolution works as before: roll against `hit`, apply `atk - def` damage
- The stats in the snapshot are already produced by the tactic's methods, so resolution logic stays simple
## AI Tactic Selection
For non-player combatants, auto-select the tactic that maximizes predicted damage:
1. For each available tactic where `tactic.deals_damage()` is true:
- Get offensive stats via `tactic.get_offensive_stats(unit)`
- Get opponent's relevant defense via `tactic.get_relevant_defense(opponent)`
- Calculate effective damage: `offensive.atk - defense`
2. Pick the tactic with the highest damage
3. If all tactics result in 0 or negative damage, select Defend
4. This logic should be isolated in its own method for easy future modification (e.g., factoring in hit chance later)
## Combat UI Changes
### Tactic Selector
Each combatant's side in the proposal panel gains a tactic dropdown/list:
- Shows `available_tactics` by name
- Defaults to Attack
- On selection change, calls `CombatSystem.update_tactic()` and refreshes the displayed stats
- For AI combatants, the selector is shown as read-only so the player can see what the AI chose
### Existing UI
- Stat labels (ATK, DEF, HIT, SPD) continue working as-is — they read from the snapshot which now includes tactic modifiers
- Fight/cancel buttons and overall flow unchanged
- The selected tactic name should be visible for each combatant
## File Changes Summary
### New Files
- `resources/resource_definitions/combat_tactic.gd` — CombatTactic base class
- `resources/resource_definitions/attack_combat_tactic.gd` — AttackCombatTactic subclass
- `resources/resource_definitions/defend_combat_tactic.gd` — DefendCombatTactic subclass
- `resources/resource_definitions/combat_tactic_range.gd` — base range class
- `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`
### Modified Files
- `nodes/unit.gd` — add tactics export, append built-in Attack/Defend at ready
- `resources/resource_definitions/combat_proposal.gd` — add available_tactics, selected_tactic to CombatantStats
- `nodes/combat_system.gd` — distance calculation, tactic filtering, update_tactic method, AI selection, tactic-driven stat resolution
- `scripts/combat_ui.gd` — tactic selector UI elements, refresh on tactic change
- `prefabs/combat_ui.tscn` — add tactic selector nodes to proposal panel
## Out of Scope
- SP/resource costs for tactics
- Healing tactics (targeting allies) — future work, would be a new CombatTactic subclass
- Generic parameterized tactic subclass (for when many tactics share a pattern)
- Complex AI (hit chance weighting, situational awareness)

View File

@@ -1,29 +1,120 @@
class_name CombatSystem extends Node
func create_proposal(attacker: Unit, defender: Unit) -> CombatProposal:
func create_proposal(attacker: Unit, defender: Unit, distance: int) -> CombatProposal:
var proposal := CombatProposal.new()
proposal.attacker = _snapshot(attacker, defender)
proposal.defender = _snapshot(defender, attacker)
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
func _snapshot(unit: Unit, opponent: Unit) -> CombatProposal.CombatantStats:
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.hit = unit.current_stats.hit - opponent.current_stats.eva
stats.atk = unit.current_stats.phys_atk
stats.def = unit.current_stats.phys_def
stats.spd = unit.current_stats.spd
stats.available_tactics = available
stats.selected_tactic = selected
if selected and selected.deals_damage():
var offensive: Dictionary = 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 process_combat(attacker: Unit, defender: Unit) -> void:
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: Dictionary = 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: Dictionary = tactic.get_offensive_stats(unit)
var defense: int = 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)
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])
@@ -41,15 +132,20 @@ func apply_proposal(proposal: CombatProposal) -> void:
var atk_unit := atk_stats.unit
var def_unit := def_stats.unit
# Attacker strikes
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)
# 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
if def_unit.is_alive():
# 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)
func _is_player_controlled(unit: Unit) -> bool:
return unit.current_allegiance.type == UnitAllegiance.AllegianceType.PLAYER

View File

@@ -12,6 +12,7 @@ func _ready() -> void:
player_controller.camera_drag.connect(camera.apply_drag)
combat_ui.fight_confirmed.connect(_on_fight_confirmed)
combat_ui.fight_cancelled.connect(_on_fight_cancelled)
combat_ui.combat_system = combat_system
func _on_mouse_grid_changed(coords: Vector2i) -> void:
@@ -20,7 +21,10 @@ func _on_mouse_grid_changed(coords: Vector2i) -> void:
func _on_combat_requested(attacker: Unit, defender: Unit) -> void:
var proposal := combat_system.create_proposal(attacker, defender)
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)

View File

@@ -6,6 +6,7 @@ enum UnitState { ALIVE, DEAD }
@export var stat_template: UnitStats
@export var info_template: UnitInfo
@export var allegiance_template: UnitAllegiance
@export var tactics: Array[CombatTactic] = []
#endregion
var current_stats: UnitStats
@@ -21,8 +22,20 @@ 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)
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)
func set_selected(selected: bool) -> void:
unit_selected_changed.emit(self, selected)

View File

@@ -91,6 +91,10 @@ unique_name_in_owner = true
layout_mode = 2
text = "Attacker"
[node name="AttackerTacticSelect" type="OptionButton" parent="CombatProposalPanel/MarginContainer/VBoxContainer/StatsContainer/AttackerStats"]
unique_name_in_owner = true
layout_mode = 2
[node name="AttackerHPBar" type="ProgressBar" parent="CombatProposalPanel/MarginContainer/VBoxContainer/StatsContainer/AttackerStats"]
unique_name_in_owner = true
layout_mode = 2
@@ -124,6 +128,10 @@ unique_name_in_owner = true
layout_mode = 2
text = "Defender"
[node name="DefenderTacticSelect" type="OptionButton" parent="CombatProposalPanel/MarginContainer/VBoxContainer/StatsContainer/DefenderStats"]
unique_name_in_owner = true
layout_mode = 2
[node name="DefenderHPBar" type="ProgressBar" parent="CombatProposalPanel/MarginContainer/VBoxContainer/StatsContainer/DefenderStats"]
unique_name_in_owner = true
layout_mode = 2

View File

@@ -0,0 +1,5 @@
# resources/resource_definitions/any_combat_tactic_range.gd
class_name AnyCombatTacticRange extends CombatTacticRange
func is_valid_range(distance: int, unit: Unit) -> bool:
return true

View File

@@ -0,0 +1 @@
uid://danory6304bl6

View File

@@ -0,0 +1,10 @@
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

View File

@@ -0,0 +1 @@
uid://k8xmyrygnrcl

View File

@@ -9,6 +9,8 @@ class CombatantStats:
var atk: int
var def: int
var spd: int
var available_tactics: Array[CombatTactic] = []
var selected_tactic: CombatTactic
var attacker: CombatantStats
var defender: CombatantStats

View File

@@ -0,0 +1,13 @@
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

View File

@@ -0,0 +1 @@
uid://b67rtbb5gixus

View File

@@ -0,0 +1,5 @@
# resources/resource_definitions/combat_tactic_range.gd
class_name CombatTacticRange extends Resource
func is_valid_range(distance: int, unit: Unit) -> bool:
return false

View File

@@ -0,0 +1 @@
uid://5cr4kl14gvd7

View File

@@ -0,0 +1,10 @@
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

View File

@@ -0,0 +1 @@
uid://dq74qh01wi7sy

View File

@@ -0,0 +1,7 @@
# 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

View File

@@ -0,0 +1 @@
uid://6jxhvwrkiq6f

View File

@@ -0,0 +1,5 @@
# 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

View File

@@ -0,0 +1 @@
uid://7locjqufdkgj

View File

@@ -23,15 +23,20 @@ signal fight_cancelled
@onready var def_spd_label: Label = %DefenderSPDLabel
@onready var fight_button: Button = %FightButton
@onready var cancel_button: Button = %CancelButton
@onready var atk_tactic_select: OptionButton = %AttackerTacticSelect
@onready var def_tactic_select: OptionButton = %DefenderTacticSelect
var _selected_unit: Unit
var _current_proposal: CombatProposal
var combat_system: CombatSystem
func _ready() -> void:
unit_panel.visible = false
proposal_panel.visible = false
fight_button.pressed.connect(_on_fight_pressed)
cancel_button.pressed.connect(_on_cancel_pressed)
atk_tactic_select.item_selected.connect(_on_atk_tactic_selected)
def_tactic_select.item_selected.connect(_on_def_tactic_selected)
for unit: Unit in get_tree().get_nodes_in_group("units"):
unit.unit_selected_changed.connect(_on_unit_selected_changed)
unit.unit_died.connect(_on_unit_died)
@@ -76,22 +81,9 @@ func _on_unit_selected_changed(unit: Unit, selected: bool) -> void:
func show_proposal(proposal: CombatProposal) -> void:
_current_proposal = proposal
var atk := proposal.attacker
var def := 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
_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
@@ -109,3 +101,51 @@ func _on_fight_pressed() -> void:
func _on_cancel_pressed() -> void:
_hide_proposal()
fight_cancelled.emit()
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
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()