8.5 KiB
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. Returnsnullif 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 returnsunit.current_stats.phys_def, a magical one returnsunit.current_stats.magic_def.func deals_damage() -> bool— returns whether this tactic produces an attack. Base implementation returnsget_offensive_stats() != null. Subclasses like Defend override to returnfalse.
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)returnsunit.current_stats.phys_def
DefendCombatTactic extends CombatTactic:
get_offensive_stats(unit)returnsnulldeals_damage()returnsfalseget_relevant_defense(unit)returnsunit.current_stats.phys_def(default for display; unused in resolution)
Future subclasses (not built now, examples for illustration):
MagicAttackCombatTactic— pullsmagic_atk/magic_def, could apply modifiersHeavyAttackCombatTactic— pullsphys_atk + 5andhit - 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 —
AttackCombatTacticwithrange: UnitMatchingCombatTacticRange - Defend —
DefendCombatTacticwithrange: 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 distanceselected_tactic: CombatTactic— the chosen tactic (defaults to Attack)
The stat snapshot (atk, def, hit, etc.) is produced by calling the selected tactic's methods:
atkandhitcome fromselected_tactic.get_offensive_stats(unit)(null if the tactic doesn't attack)defcomes from the opponent'sselected_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,
defdefaults tophys_deffor display purposes
Stat Recalculation
When a tactic is changed on either side:
- The combatant's offensive stats recalculate via
new_tactic.get_offensive_stats(unit) - The opponent's defensive stat recalculates via
new_tactic.get_relevant_defense(opponent) - Both sides' snapshots update accordingly
CombatSystem Changes
Proposal Creation
create_proposal(attacker, defender) expands to:
- Calculate tile distance between attacker and defender
- For each unit, filter their tactics list by
tactic.range.is_valid_range(distance, unit) - Default-select Attack for both sides
- Snapshot stats with Attack's modifiers applied (none for the base case)
- 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()returnsfalse, skip their attack entirely (no hit roll, no damage) - Otherwise, resolution works as before: roll against
hit, applyatk - defdamage - 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:
- 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
- Get offensive stats via
- Pick the tactic with the highest damage
- If all tactics result in 0 or negative damage, select Defend
- 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_tacticsby 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 classresources/resource_definitions/attack_combat_tactic.gd— AttackCombatTactic subclassresources/resource_definitions/defend_combat_tactic.gd— DefendCombatTactic subclassresources/resource_definitions/combat_tactic_range.gd— base range classresources/resource_definitions/fixed_combat_tactic_range.gdresources/resource_definitions/any_combat_tactic_range.gdresources/resource_definitions/unit_matching_combat_tactic_range.gd
Modified Files
nodes/unit.gd— add tactics export, append built-in Attack/Defend at readyresources/resource_definitions/combat_proposal.gd— add available_tactics, selected_tactic to CombatantStatsnodes/combat_system.gd— distance calculation, tactic filtering, update_tactic method, AI selection, tactic-driven stat resolutionscripts/combat_ui.gd— tactic selector UI elements, refresh on tactic changeprefabs/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)