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,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)