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

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. 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:

  • AttackAttackCombatTactic with range: UnitMatchingCombatTacticRange
  • DefendDefendCombatTactic 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)