Combat tactics
This commit is contained in:
169
docs/superpowers/specs/2026-04-04-combat-tactics-design.md
Normal file
169
docs/superpowers/specs/2026-04-04-combat-tactics-design.md
Normal 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)
|
||||
Reference in New Issue
Block a user