Vampire-Act-Base/design/gdd/combat-logic.md

433 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Combat Logic
> **Status**: In Review (revised 2026-04-27 — aligned implementation with design per review findings)
> **Author**: SepComet + Claude
> **Last Updated**: 2026-04-27
> **Implements Pillar**: 操作优于数值 (Skill Over Stats), 战场即信息 (Battlefield is Information), 形态即战术 (Form is Tactics), 切换即承诺 (Switch is Commitment)
## Overview
Combat Logic 是夜裔的即时战斗判定核心——负责"打中了没有""伤害是多少""是否暴击"。它接收攻击数据(形状、伤害值、来源形态),对目标进行几何相交检测,产出 DamageResult。系统完全纯C#,使用 ADR-003 定义的 Circle/Rect/Sector 进行形状判定。它不产生任何视觉或音频效果——而是通过 C# event 将结果传递给 L1/L2。设计原则**判定必须确定、反馈必须清晰、计算必须快速**(每帧数百次判定,目标 <1ms含空间分割优化)。
Combat Logic 实现了所有四个游戏支柱位置暴击Skill Over Stats)、攻击形状可视化Battlefield is Information)、不同形态不同攻击风格Form is Tactics)、人形弹反的时机窗口Switch is Commitment)。
## Player Fantasy
夜裔的战斗幻想不是"数字在涨"而是**每一击都有形状有重量有来由**。当你用人形精准弹反——在敌人攻击前摇的 0.15s 窗口内启动扇形招架——扇形判定区恰好覆盖精英的攻击前摇一个金色暴击数字弹出你感觉自己不是"按了攻击键"而是用几何体"接住了"对方的攻击当你切到狼躯冲刺——矩形贯穿一排敌人——敌人被击退的轨迹本身就是你所画的一条线当雾形圆形AOE笼罩整个屏幕的杂兵那种"我画了一个圈圈里全死"的掌控感就是 Combat Logic 要传达的幻想参考 RE4 弹反的"接住"反馈 + DMC 的判定可视化 + 割草的密度感
## Detailed Design
### Attack Shape Definitions
Each form maps to one attack geometry type (via ADR-003):
| Form | Shape | Parameters | Visual Read |
|------|-------|------------|-------------|
| **Human** | `Sector` | Angle=100°, Radius=3.0 | Forward fan attack + parry zone |
| **Wolf** | `Rect` | Width=1.0, Length=5.0 | Narrow forward corridor dash trajectory |
| **Mist** | `Circle` | Radius=3.8 | Self-centered circle close-range AOE |
### Human Form: Parry Subsystem
Human form has a distinct **parry action** in addition to its standard attack. This is the mechanical realization of the "geometry catching attacks" fantasy.
**Parry Input**: Dedicated parry input (separate from attack) player **presses** parry to trigger a fixed 0.15s parry window. The window is a single timed activation regardless of button hold duration. This ensures timing precision is the skill, not pre-holding.
**Parry Window**: The parry sector (Angle=100°, Radius=3.0) is active for 0.15s from the press. During this window:
- If **any enemy attack shape** overlaps the parry sector, the parry **succeeds**: the enemy is staggered (0.45s stun), the attack is nullified, and the next Human attack within 0.5s is a **guaranteed crit** (critMultiplier applied, no RNG roll).
- If **multiple enemy attack shapes** overlap the parry sector during the same window, **all are parried** one parry counters all overlapping attacks. The riposte guaranteed crit still applies to the next single Human attack only. This rewards good timing in dense combat without making the riposte overpowered.
- If the parry window expires with no enemy attack overlap, the player enters a 0.2s recovery (cannot act). This is the risk of parrying at the wrong time.
**Post-Parry State**: After a successful parry, the player can act immediately (no recovery). The 0.5s riposte window starts from the parry success frame. The 0.2s recovery applies only on parry miss.
**Parry vs. Attack**: Human form can either attack (standard Sector sweep, base damage 18) or parry (defensive, no damage on parry itself, enables riposte crit). The player chooses which based on reading the battlefield. If both attack and parry inputs arrive in the same frame, parry takes priority (it is the more time-sensitive action).
**Form Defensive Posture**: Only Human form has a dedicated parry. Wolf and Mist rely on mobility and pre-emptive positioning their defense is switching to Human. If caught in the wrong form against an incoming attack, the correct response is to accept the hit or attempt to switch forms during Windup. This asymmetry reinforces "Form is Tactics": each form has a distinct defensive profile.
### Positional Crit System
Crit is no longer RNG. Crit is earned through positioning, replacing the previous `random(0,1) < critChance` formula.
| Form | Crit Condition | Crit Behavior |
|------|---------------|---------------|
| **Human** | Parry success next attack within 0.5s is guaranteed crit. Also: attacking enemy from behind (flanking, angle > 120° between enemy facing and attack origin) grants +30% crit chance from critChanceStat. | critMultiplier applied |
| **Wolf** | Dash hitting 3+ enemies in single activation → all hits in that dash are crit. Also: hitting enemy from outside its forward 60° cone (flanking) grants +30% crit chance from critChanceStat. | critMultiplier applied to all dash hits |
| **Mist** | Enemy within inner 40% of circle radius (≤1.52 units from center) → guaranteed crit. Also: hitting enemy from behind (flanking, angle > 120°) grants +30% crit chance from critChanceStat. | critMultiplier applied to inner-radius hits |
**critChanceStat**: A per-form stat (base 0.05, grows via Skill Tree to 0.00.30) that adds flat crit chance on top of positional conditions. At 0.05 base, a flanking attack has 35% crit chance (30% positional + 5% stat). This preserves a small RNG element that grows with investment — but positional crits (parry, dash-chain, inner-radius) are always 100% regardless of stat.
**Crit resolution per hit**:
1. Check positional guarantee (parry riposte, dash 3+, inner 40% radius) → if true, crit
2. Else, check flanking bonus + critChanceStat → `random(0,1) < (flankingBonus + critChanceStat)`
3. Else, check critChanceStat only → `random(0,1) < critChanceStat`
4. Otherwise, normal hit (no crit)
### Data Structures
```
AttackSource { Player, Enemy }
// Determines resolution target:
// Player → resolve against EnemyState list (hit detection + damage)
// Enemy → resolve against PlayerState (hit detection + damage to player)
// Parry (IsParry=true) always has AttackSource.Player and resolves against enemy AttackData shapes
AttackData {
int AttackId // Unique per attack instance (monotonic, caller-assigned)
int AttackGroupId // Groups related attacks in a single activation (e.g., Wolf dash frames). 0 = ungrouped.
AttackSource Source // Player or Enemy — determines resolution target
Vector3 Origin // Attack origin in L0 world coords
float Direction // Facing angle in DEGREES (Unity convention; L0 converts to radians internally)
Shape HitShape // Circle / Rect / Sector
FormType SourceForm // Form that launched the attack (Human/Wolf/Mist for player; None for enemy)
float BaseDamage // From AttackStyle (player) or EnemyTypeConfig (enemy)
float CritChanceStat // Per-form stat (base 0.05), added to positional crit bonus
float CritMultiplier // Default 1.5x
float KnockbackForce // From AttackStyle or EnemyTypeConfig
float InnerCritRadius // For Circle shapes: enemies within this radius get positional crit. 0 = no inner crit zone.
bool IsParry // True if this attack is a Human parry attempt (not a standard attack)
}
// EnemyState is a shared data contract (L0 Core / Common namespace).
// Owned by Enemy AI Logic (populates fields each frame). Read by Combat Logic.
// Schema change-controlled — field additions require both system owners' approval.
EnemyState {
int TargetId // Unique enemy identifier
Vector3 Position // World position (L0 coordinate system)
float FacingAngle // Facing direction in degrees — updated by Enemy AI during Chase (enemy faces player)
Shape HitShape // Enemy hit shape (typically Circle with per-type radius, from EnemyTypeConfig)
float Health // Current health — used for IsKillingBlow determination
float Weight // Knockback resistance (110, clamped to ≥1.0, from EnemyTypeConfig)
bool IsDead // Derived: Health <= 0
}
// PlayerState is the player's combat snapshot for enemy-attack resolution.
// Populated by CombatCoordinator each frame from Form Switch SM + Death/Respawn state.
// This is the single authoritative PlayerState definition — all systems reference this struct.
PlayerState {
Vector3 Position // Player world position (L0 coordinates)
float FacingAngle // Player facing direction in degrees
Shape HitShape // Player hit shape (typically Circle with radius ~0.5)
bool IsParryActive // True while a parry window is open (for parry-vs-enemy-attack checks)
Shape ParryShape // Active parry sector shape (valid when IsParryActive=true)
float CurrentHealth // From Death/Respawn Rules — read by CombatCoordinator
float MaxHealth // Baseline 100 (before upgrades)
bool IsDead // Derived: CurrentHealth <= 0 — set by Death/Respawn, read by Enemy AI + CombatCoordinator
bool IsInvincible // true during respawn i-frames — set by Death/Respawn
}
DamageResult {
int TargetId
int AttackId // References originating AttackData.AttackId
float FinalDamage // baseDamage × critFactor (pre-resistance; resistance applied downstream by Enemy AI)
bool IsCritical
Vector3 HitPoint // Hit position in L0 world coordinates (computed from shape intersection)
bool IsKillingBlow // True if Health - FinalDamage <= 0
FormType SourceForm
float KnockbackDistance // Computed knockback (knockbackForce / enemyWeight × critFactor)
}
```
**EnemyState Ownership**: `EnemyState` is a shared data contract in the L0 Core/Common namespace — neither Combat Logic nor Enemy AI exclusively owns it. Enemy AI is the data source (populates Position, FacingAngle, Health, Weight, IsDead each frame). Combat Logic reads it. Schema changes require approval from both system owners.
**FacingAngle Pipeline**: Enemy AI updates enemy facing during `Chase` state (enemy rotates toward player). The facing angle is carried in `EnemyState.FacingAngle` every frame. 0° = +X axis (L0 convention, consistent with ADR-003). Combat Logic reads this for flanking crit detection.
**Angle Convention**: All angles in AttackData and EnemyState are in **degrees** (matching Unity convention). L0's math library (ADR-003) converts to radians internally before calling `System.Math` trig functions. This convention must be documented in ADR-003.
**Vector3 Boundary**: L0 uses custom `Vector3` (ADR-003). L1 converts to `UnityEngine.Vector3` via a static utility class `Vector3Conversion.ToUnity(this Combat.Vector3)` in the L1 Adapters assembly. L0 never references `UnityEngine.Vector3`.
**HitPoint Coordinate Space**: `DamageResult.HitPoint` is in **L0 world coordinates** (same coordinate system as `AttackData.Origin` and `EnemyState.Position`). L1 converts to Unity world space for VFX positioning. L0 computes it from the intersection point of the attack shape and enemy hit shape.
**Random Number Injection**: Combat Logic accepts an `IRandomProvider` interface with `double NextDouble()` for critChanceStat rolls. Default implementation wraps `System.Random`. Tests inject a seeded or mock provider for deterministic crit testing.
**Shape Polymorphism**: Shape types use a **union struct** pattern (`HitShape` with `ShapeType` enum + all shape fields) to avoid boxing and virtual dispatch in the hot path.
```
ShapeType { Circle, Rect, Sector }
HitShape {
ShapeType Type;
Circle Circle;
Rect Rect;
Sector Sector;
bool Contains(Vector3 point) { /* switch by Type, no boxing */ }
bool Intersects(HitShape other) { /* dispatch by Type pair */ }
}
```
Epsilon constant: `const float HIT_EPSILON = 0.001f` — overlap must exceed this value to count as a hit. Defined in ADR-003.
### AttackData Construction
An L0 factory method constructs `AttackData` from `AttackStyle` + input intent:
```csharp
// L0 / Combat / AttackDataFactory.cs
public static class AttackDataFactory {
public static AttackData Create(AttackStyle style, Vector3 playerPos,
float facingAngle, bool isParry, int attackId, IRandomProvider rng = null);
}
```
L1's InputAdapter collects the raw data (player transform position, facing, input state) and calls this factory. The factory computes the hit shape geometry from AttackStyle parameters — this is game logic, appropriate for L0.
### Hit Resolution Pipeline (called once per frame)
**CombatCoordinator** (L0, `Combat/CombatCoordinator.cs`) is the per-frame orchestrator that aggregates inputs and distributes results:
```
CombatCoordinator.ResolveFrame(deltaTime)
1. GATHER: Poll Form Switch SM for player AttackData. Poll Enemy AI for List<EnemyState> + List<AttackData> (enemy attacks).
2. MERGE: Combine player + enemy AttackData into a single list. Tag each with correct AttackSource.
3. PARRY PHASE (two-pass for nullification):
a. Find all AttackData with IsParry=true (player parries).
b. For each parry: intersect parry HitShape against all enemy AttackData shapes.
c. If parry overlaps an enemy attack → mark that enemy AttackData as Nullified (excluded from damage resolution).
d. Emit OnParrySuccess for each parried enemy attack.
4. DAMAGE PHASE: Resolve non-nullified attacks against their targets:
- Player attacks → EnemyState list (CULL → INTERSECT → DAMAGE → DEDUP)
- Enemy attacks → PlayerState (CULL → INTERSECT → DAMAGE)
5. EMIT: OnHit(DamageResult), OnKill(DamageResult), OnCrit(DamageResult), OnAttackResolved
6. RETURN: List<DamageResult> grouped by TargetId
```
**Parry State Management**: Combat Logic owns the active parry state internally. When L1 calls `CombatLogic.ActivateParry()`, Combat Logic records the parry start time and sector shape. On each `ResolveFrame` call, if the parry window (0.15s) has not expired, Combat Logic automatically includes the parry AttackData in the resolution. After 0.15s or on successful parry, the parry state clears. This keeps timing logic in L0 (ADR-001 compliant) while L1's InputAdapter simply forwards the button press.
**Pipeline is batch-per-frame**: All attacks are resolved against pre-frame state. The two-phase structure (parry first, then damage) ensures parried enemy attacks cannot damage the player in the same frame — nullification happens before damage resolution. Dead filtering runs once at the start of the damage phase. If an enemy is alive at frame start, all non-nullified attacks that intersect it will produce hits this frame.
**Sorting**: Results are ordered by AttackId (stable, deterministic). There is no sort-by-damage step.
**Spatial Grid**: A uniform spatial grid (L0 pure C#, cell size = max attack range) provides broad-phase culling. Enemies are bucketed at O(E) per frame. Attack queries check only nearby cells, reducing intersection pairs from O(A×E) to O(A×local-density).
**Memory**: Use `NonAlloc` pattern — caller provides a pre-allocated `List<DamageResult>` buffer that `ResolveAttacks` fills in-place. No per-frame allocation from Combat Logic.
### Target Filtering Rules
- Enemy state = Dead → skip
- Enemy attack marked Nullified (parried in same frame) → skip
- Attack shape has no intersection with target hit shape → skip
- Same (AttackId, TargetId) pair already processed this frame → skip (dedup)
- Degenerate attack shape (zero area: radius=0, width=0, angle=0) → skip entire attack
### Events
| Event | Trigger | Payload |
|-------|---------|---------|
| `OnHit` | Every successful hit | `DamageResult` |
| `OnKill` | Hit reduces enemy health to ≤0 | `DamageResult` |
| `OnCrit` | Hit triggers critical (positional or stat-based) | `DamageResult` |
| `OnParrySuccess` | Human parry intersects enemy attack during parry window | `AttackData, DamageResult` |
| `OnAttackResolved` | Per-attack resolution complete (fires even if 0 hits) | `AttackData, int hitCount` |
**Event batching note**: For performance at high hit density, L1 subscribers (VFX Spawner, Audio Player) should batch-process events per frame and apply per-frame caps (e.g., max 200 particle bursts, max 20 SFX). See Performance Considerations below.
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|-----------|
| Form Switch SM | Upstream | Reads `AttackStyle` (Shape, BaseDamage, CritChanceStat, CritMultiplier, KnockbackForce, AttackSpeed, MaxTargets) per form |
| Blood Energy Economy | Downstream | `OnHit` triggers `BloodEnergyEconomy.Add(attackGain)`, `OnKill` triggers `BloodEnergyEconomy.Add(killGain)`. Bridged via L1 `BloodEnergyBridge` adapter that subscribes to Combat Logic events and calls Blood Energy Economy methods. |
| Enemy AI Logic | Downstream | Enemy receives `DamageResult` for health subtraction, behavior response. Enemy AI also provides `List<EnemyState>` and enemy `AttackData` (upstream data source) each frame. |
| CombatCoordinator | Orchestrator | New L0 component that aggregates attack/enemy streams, calls `ResolveFrame()`, and distributes results. |
| VFX Spawner (L1) | Downstream | Subscribes to `OnHit`/`OnKill`/`OnCrit`/`OnParrySuccess` for visual feedback |
| Death/Respawn | Downstream | `OnKill` with IsKillingBlow triggers death check |
| ADR-003 Geometry | Foundation | Uses `Circle`, `Rect`, `Sector` for all intersection tests |
## Formulas
### Damage Formula
```
finalDamage = baseDamage × critFactor
where critFactor = critMultiplier if crit (positional guarantee OR flanking+stat roll succeeds OR stat-only roll succeeds), else 1.0
```
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Base damage | `baseDamage` | float | 550 | From AttackStyle, determined by form and upgrades |
| `CritChanceStat` | float | 0.00.30 | Per-form stat, baseline 0.05, grows via Skill Tree |
| Flanking bonus | `flankingBonus` | float | 0.30 (fixed) | Applied when attacking outside enemy's forward 60°120° cone |
| Crit multiplier | `critMultiplier` | float | 1.23.0 | Baseline 1.5 |
| Crit factor | `critFactor` | float | 1.0 or critMultiplier | Positional guarantee = always critMultiplier |
**Output Range**: 5150 (extreme: baseDamage=50 × critMultiplier=3.0 = 150)
**Design Intent**: Combat Logic applies only base damage × critFactor. Enemy resistance modifiers are defined in Enemy AI GDD and applied downstream. Skill damage bonuses are defined in Skill Tree GDD. This keeps Combat Logic single-responsibility.
### Knockback Formula
```
knockbackDistance = knockbackForce / max(enemyWeight, 1.0) × (IsCritical ? 1.5 : 1.0)
```
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Knockback force | `knockbackForce` | float | 010 | From AttackStyle |
| Enemy weight | `enemyWeight` | float | 110 (clamped to ≥1.0) | Heavier enemies resist knockback |
**Output**: 015 units | Critical hits add 1.5× knockback. `max(enemyWeight, 1.0)` prevents division by zero.
## Edge Cases
| # | Condition | Resolution | Rationale |
|---|-----------|------------|-----------|
| 1 | Attack shape has zero area (radius=0, width=0, angle=0) | Skip entire attack, return empty results for that attack | Degenerate shape — no valid hit possible |
| 2 | Enemy has no hit shape defined | Skip that enemy | Incomplete data — should not happen at runtime |
| 3 | Same (AttackId, TargetId) processed twice in one frame | Dedup: count only first occurrence | One attack = one hit per target per frame |
| 4 | `critChanceStat` exceeds 1.0 via buffs | Clamp the combined sum `(flankingBonus + critChanceStat)` to 1.0. Stat alone capped at per-form max (0.30). | Guaranteed crit at combined 1.0 is the ceiling; flanking still meaningful as it reduces the stat needed to hit cap |
| 5 | `baseDamage` = 0 (status effect attack) | Still perform hit test, emit OnHit with FinalDamage=0 | Knockback may still apply; positional crit conditions still evaluated |
| 6 | Multiple attacks in same frame against same enemy | Resolve all — damage summed downstream | Independent attacks stack; all resolved against pre-frame state |
| 7 | `knockbackForce` = 0 | Return knockbackDistance=0, skip calculation | No force applied |
| 8 | deltaTime = 0 (pause) | Return empty list, no events | Paused game = no combat resolution |
| 9 | Empty enemies list | Return empty list, no events | No targets to hit. OnAttackResolved still fires per attack with hitCount=0. |
| 10 | Shape intersection at exact tangent point (overlap ≤ HIT_EPSILON) | Count as miss | HIT_EPSILON = 0.001f. Prevents degenerate edge-touch = hit |
| 11 | Parry activated but no enemy attack in window | Parry fails → 0.2s recovery, OnAttackResolved fires with hitCount=0 | Risk of parrying at wrong time |
| 11a | Parry sector overlaps multiple enemy attacks simultaneously (e.g., 3 Brutes attacking at once) | All overlapping enemy attacks are parried (nullified). OnParrySuccess fires once per parried attack. Riposte guaranteed crit applies to next single Human attack only. | One parry counters all — rewards timing in dense combat without making riposte overpowered |
| 11b | Parry activated but enemy is in Chase state (not attacking — no enemy AttackData to overlap) | Parry fails → 0.2s recovery (same as #11) | Parry requires an enemy attack to intersect; idle/Chase enemies cannot be parried |
| 11c | Form switch requested during active parry window | Parry window cancels on form switch. Parry is Human-form specific. | Reinforces "Switch is Commitment" |
| 11d | Player presses both attack and parry in the same frame | Parry takes priority — attack input suppressed for that frame | Parry window (0.15s) is more time-sensitive than attack |
| 12 | `enemyWeight` = 0 (invalid data) | Clamp to 1.0 before division | Defensive guard — prevents float.PositiveInfinity |
| 13 | `Direction` passed as radians (incorrect, should be degrees) | L0 treats Direction as degrees; caller's bug | Convention enforcement: ADR-003 documents degrees as the interchange format |
## Dependencies
| System | Relationship | Interface |
|--------|-------------|-----------|
| Form Switch SM | Upstream | Reads `AttackStyle` (Shape, BaseDamage, CritChanceStat, CritMultiplier, KnockbackForce, AttackSpeed, MaxTargets) per form |
| Blood Energy Economy | Downstream | `OnHit``Add(attackGain)`, `OnKill``Add(killGain)`. Bridged via L1 `BloodEnergyBridge` adapter. |
| Enemy AI Logic | Downstream (results) | Enemy receives `DamageResult` via `ApplyDamage()` for health subtraction and behavior response |
| Enemy AI Logic | Upstream (data) | Provides `List<EnemyState>` (Position, FacingAngle, Health, Weight, HitShape, IsDead) and `List<AttackData>` (enemy attacks) each frame |
| CombatCoordinator | Orchestrator | New L0 component (`Combat/CombatCoordinator.cs`) — aggregates player + enemy AttackData and EnemyState lists, calls `ResolveFrame()`, routes `DamageResult` back to Enemy AI |
| VFX Spawner (L1) | Downstream | Subscribes to `OnHit`, `OnKill`, `OnCrit`, `OnParrySuccess` for visual feedback |
| Death/Respawn | Downstream | `OnKill(IsKillingBlow=true)` triggers death check |
| ADR-003 Geometry | Foundation | Uses `Circle`, `Rect`, `Sector` for all intersection tests; defines HIT_EPSILON and degrees convention |
**AttackStyle contract note**: `AttackStyle` (owned by Form Switch SM) must be extended to include `CritChanceStat` (per-form default 0.05), `CritMultiplier` (default 1.5), and `KnockbackForce` (per-form defaults TBD — recommended Human=5, Wolf=8, Mist=3). Until the Skill Tree GDD is written, these are flat per-form config values. When Skill Tree is designed, it will provide an `ICritStatProvider` that overrides these defaults.
## Tuning Knobs
| Parameter | Default | Safe Range | Too Low | Too High |
|-----------|---------|------------|---------|----------|
| `baseDamage` (Human) | 18 | 1025 | Attacks feel weak | Other forms become irrelevant |
| `baseDamage` (Wolf) | 22 | 1535 | Burst doesn't feel bursty | Wolf the only viable form |
| `baseDamage` (Mist) | 8 | 415 | AOE feels useless | Mist clears everything |
| `critChanceStat` | 0.05 | 0.020.10 | Stat crits never seen without positional bonus | Positional crits lose distinction |
| `critMultiplier` | 1.5 | 1.22.5 | Crits not noticeable | Damage spikes too wild |
| Flanking bonus | 0.30 | 0.200.40 | Flanking not worth maneuvering for | Flanking replaces parry/dash-chain |
| Human Sector Angle | 100° | 80130° | Too narrow, parry frustrating | Too wide, parry trivial |
| Wolf Rect Width | 1.0 | 0.81.5 | Too narrow, dash whiffs | Too wide, dash loses precision identity |
| Wolf Rect Length | 5.0 | 3.08.0 | Dash feels short | Range competes with ranged forms |
| Mist Circle Radius | 3.8 | 2.55.0 | AOE feels cramped | Covers whole screen, no positioning |
| Mist inner crit radius | 1.52 (40% of 3.8) | 3050% of circle radius | Inner-radius crits too rare | Inner-radius crits too easy |
| HIT_EPSILON | 0.001 | 0.00050.005 | Grazing contacts feel inconsistent | Attacks "should have hit" |
| Parry window duration | 0.15s | 0.100.20s | Parry too tight, frustrating | Parry too generous, loses skill |
| Parry stagger duration | 0.45s | 0.300.60s | Stagger shorter than attack windup — riposte lands after stagger | Stagger too long — enemy disabled, no threat |
| Parry recovery duration | 0.20s | 0.150.30s | Recovery meaningless | Parry too punishing |
| Spatial grid cell size | 5.0 | 3.010.0 | Many empty cells — memory waste, query overhead | No spatial culling — all pairs tested |
| Wolf dash crit threshold | 3 enemies | 25 | Crits too easy — dash = always crit | Crits too rare — mechanic invisible |
**Key tuning pair**: `baseSwitchCost / baseAttackGain ≈ 8` (from Blood Energy Economy GDD) — roughly 8 attack hits to earn one switch. Base damage values here affect that rhythm: higher base damage means faster kills, faster energy gain, more frequent switching.
## Performance Considerations
**Spatial partitioning**: Uniform grid with configurable cell size (default 5.0, tuning knob). Enemies bucketed at O(E) per frame. Attack queries check only nearby cells. ~10× reduction at 200 enemies at normal distribution. **Worst case**: all 200 enemies cluster within one cell — grid provides no culling, producing 2000 intersection tests. At this density, intersection + damage + dedup may reach ~1.0-1.2ms (exceeding the 0.8ms sub-budget). This is acceptable for peak density spikes but should be profiled.
**Memory**: `NonAlloc` pattern — caller provides pre-allocated `List<DamageResult>`. No per-frame allocation from Combat Logic. Dedup uses pre-allocated `HashSet<(int AttackId, int TargetId)>` cleared per frame.
**Event batching**: L1 subscribers (VFX Spawner, Audio Player) must apply per-frame caps:
- VFX: max 200 particle bursts/frame (prioritize crits, kills, then hits)
- Audio: max 20 SFX/frame (prioritize kills, crits, then hits)
- HUD: max 50 damage numbers/frame (prioritize highest damage)
**Target budget at 200 enemies × 10 attacks (2000 pairs)**:
- Intersection + damage + dedup: <0.8ms (with spatial grid + union struct shapes + squared-distance early-out)
- Event emission: <0.2ms (batched event data, subscribers apply caps)
- Total Combat Logic: <1.0ms
## Visual/Audio Requirements
Combat is a visual system Visual/Audio is REQUIRED.
**VFX (via L1 VFX Spawner):**
- `OnHit`: Geometric particle burst at `DamageResult.HitPoint` color matches `SourceForm`
- `OnCrit`: Larger burst + screen shake micro gold/amber tint
- `OnKill`: Enemy shatter into geometric fragments fragment color = enemy type
- `OnParrySuccess`: Sharp ring-shaped particle burst at parry contact point white/silver flash
- Debug overlay: Semi-transparent fill of Sector/Rect/Circle during attack/parry frames (player-facing shape preview)
**Audio (via L1 Audio Player):**
- `OnHit`: Impact SFX varies by form (Human=sharp slash, Wolf=heavy thud, Mist=ethereal whoosh)
- `OnCrit`: Distinct "clink" + bass emphasis
- `OnKill`: Shatter sound pitch varies by enemy size
- `OnParrySuccess`: Metallic ring + short bass drop
**Art Bible Alignment**: Principle 1 (Color is Identity hit VFX color = form color), Principle 3 (Particles are Feedback geometric fragments = information).
## UI Requirements
| Element | Position | Content |
|---------|----------|---------|
| Damage Numbers | Floating at hit point | Numeric value, color = form color, crit = larger + gold, positional crit = gold + "positional" icon |
| Hit Indicator | Screen edge | Directional flash when player takes damage |
| Kill Counter | HUD | Combo/kill streak (fed by `OnKill` event) |
| Parry Indicator | Center-screen | Brief flash when parry window is active; distinct flash on parry success |
| Attack Shape Preview | In-world overlay | Semi-transparent shape shown during attack windup (L1 renders from AttackData.HitShape) |
## Acceptance Criteria
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 1 | Human Sector (Angle=100°, Radius=3.0, dir=0°). EnemyState { TargetId=1, Position=(2.0, 0, 0.5), HitShape=Circle(r=0.5), Health=20, Weight=2, FacingAngle= }. AttackData { AttackId=1, BaseDamage=18, CritChanceStat=0.0, IsParry=false }. Mock RNG returns 0.5. | `ResolveAttacks([attack], [enemy], dt=0.016)` | Returns 1 result. DamageResult: TargetId=1, AttackId=1, FinalDamage=18.0, IsCritical=false (geometric flanking angle 166° > 120° triggers step 2 but 0.0 + 0.30 = 0.30 < 0.5 RNG roll fail; no positional guarantee; stat-only step 3: 0.0 < 0.5 fail), SourceForm=Human, IsKillingBlow=false. OnHit fires. OnAttackResolved fires with hitCount=1. |
| 2 | Wolf Rect (Width=1.0, Length=5.0, rotation=0°). Enemy at Position=(0, 0, 5.5) 5.5 > half-length 2.5 + enemy radius 0.5 = 3.0, beyond range. | `ResolveAttacks([attack], [enemy], dt=0.016)` | Returns empty list. OnAttackResolved fires with hitCount=0. |
| 3 | Mist Circle (Radius=3.8). 5 enemies all within 3.3 units (well inside radius). | `ResolveAttacks([attack], enemies, dt=0.016)` | Returns 5 results, all SourceForm=Mist, distinct TargetIds. OnHit fires 5 times. OnAttackResolved fires with hitCount=5. Enemy at (0.5, 0, 0) — inner 40% radius (≤1.52) → IsCritical=true (positional guarantee). |
| 4a | Attack shape Circle(r=5.0), enemy hit shape at exact tangent (overlap depth = 0.0). HIT_EPSILON = 0.001. | `ResolveAttacks([attack], [enemy], dt=0.016)` | Returns empty list. Overlap 0.0 < 0.001 miss. |
| 4b | Same setup, enemy shifted by 0.001 units toward attack center (overlap = 0.001 = HIT_EPSILON). | `ResolveAttacks([attack], [enemy], dt=0.016)` | Hit detected, OnHit fires. |
| 5a | critChanceStat=0.30, flanking bonus not active (enemy facing toward attacker). Mock RNG returns 0.29. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=true (0.29 < 0.30 stat-only roll). |
| 5b | critChanceStat=0.30, flanking bonus not active. Mock RNG returns 0.31. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=false (0.31 0.30). |
| 5c | critChanceStat=0.05, flanking bonus active (enemy facing away, angle > 120°). Mock RNG returns 0.34. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=true (0.34 < 0.05 + 0.30 flanking bonus). |
| 6 | Enemy Health=10, BaseDamage=15 (non-crit). | `ResolveAttacks` | IsKillingBlow=true, OnKill fires, OnHit fires. FinalDamage=15. |
| 7 | Enemy Health=10, BaseDamage=5 (non-crit). | `ResolveAttacks` | IsKillingBlow=false, OnHit fires, OnKill does NOT fire. FinalDamage=5. |
| 8a | KnockbackForce=10, enemyWeight=2, IsCritical=false. | `ResolveAttacks` | DamageResult.KnockbackDistance = 5.0 (10/2 × 1.0). |
| 8b | KnockbackForce=10, enemyWeight=2, IsCritical=true (positional guarantee). | `ResolveAttacks` | DamageResult.KnockbackDistance = 7.5 (10/2 × 1.5). |
| 9 | Enemy A { Health=0, IsDead=true }, Enemy B { Health=20 }. Attack covers both. | `ResolveAttacks([attack], [enemyA, enemyB], dt=0.016)` | Returns 1 result (Enemy B only). Enemy A skipped (dead). OnAttackResolved fires with hitCount=1. |
| 10 | critChanceStat=1.2 (beyond cap). | Crit resolution | Clamped to 1.0. With no flanking active, combined cap is 1.0. Mock RNG returns 0.99 crit (0.99 < 1.0). |
| 11 | 3 attacks [A(AttackId=1), B(AttackId=2), C(AttackId=3)] all hitting same enemy (Health=100). | `ResolveAttacks([A, B, C], [enemy], dt=0.016)` | Returns 3 results (TargetId=1, AttackId distinct). Total FinalDamage = sum of 3 individual damages. All resolved against pre-frame health of 100. OnHit fires 3 times. |
| 12 | Attack misses all enemies (none in range). | `ResolveAttacks([attack], [enemies], dt=0.016)` | Returns empty list. OnAttackResolved fires with hitCount=0. No OnHit/OnKill/OnCrit. |
| 13 | Valid attack, enemy in range, but deltaTime=0.0. | `ResolveAttacks([attack], [enemy], dt=0.0)` | Returns empty list. No events fire. |
| 14 | Same (AttackId, TargetId) pair appears twice in pipeline (bug or edge overlap). | Dedup step | Only first occurrence produces a result. Second is skipped. OnHit fires once. |
| 15 | OnCrit event: any crit (positional guarantee or stat roll succeeds). | Hit resolves with IsCritical=true | OnCrit fires with DamageResult. Payload matches the crit hit. |
| 16 | Parry: AttackData { IsParry=true, HitShape=Sector(100°, 3.0) }. Enemy attack HitShape overlaps sector during 0.15s window. | `ResolveFrame(dt=0.016)` parry phase runs before damage phase | OnParrySuccess fires. Enemy attack marked Nullified excluded from damage phase (no player damage). Next Human attack within 0.5s has positional crit guarantee. |
| 17 | Parry: same parry attack, but no enemy attack shape overlaps during window. | Same call | OnParrySuccess does NOT fire. OnAttackResolved fires with hitCount=0. Player enters 0.2s recovery. |
| 17a | Parry: same parry attack, but enemy is in Chase state (not attacking no enemy AttackData to overlap). | Same call | OnParrySuccess does NOT fire. Same behavior as #17 (no overlap = miss). |
| 18 | enemyWeight=0 (invalid). KnockbackForce=10. | `ResolveAttacks` | KnockbackDistance = 10.0 (10/max(0,1.0) = 10/1.0, non-crit). Formula guards against division by zero. |
| 19 | Attack shape with radius=0 (degenerate). | `ResolveAttacks([zeroAreaAttack], [enemies], dt=0.016)` | Returns empty list for that attack. OnAttackResolved fires with hitCount=0. |
| 20 | Empty enemies list but attack data present. | `ResolveAttacks([attack], [], dt=0.016)` | Returns empty list. OnAttackResolved fires per attack with hitCount=0. No OnHit/OnKill/OnCrit. |
| 21 | Parry succeeds (OnParrySuccess fired). Human attack within 0.45s (before riposte window expires at 0.5s). critChanceStat=0.0, no flanking, mock RNG returns 0.0. | `ResolveAttacks([humanAttack], [enemy], dt=0.016)` | DamageResult.IsCritical=true (parry riposte positional guarantee, bypasses RNG). FinalDamage = BaseDamage × critMultiplier (e.g., 18 × 1.5 = 27.0). |
| 22 | Parry succeeds. Human attack at 0.55s (after 0.5s riposte window expires). | `ResolveAttacks([humanAttack], [enemy], dt=0.016)` | DamageResult.IsCritical=false (riposte window expired; resolves via normal crit steps). |
| 23a | Wolf Rect (Width=1.0, Length=5.0). 3 enemies alive within Rect bounds. | `ResolveAttacks([wolfDash], [e1, e2, e3], dt=0.016)` | Returns 3 results. All 3 have IsCritical=true (dash-chain positional guarantee: 3+ enemies). |
| 23b | Wolf Rect. 2 enemies alive within Rect bounds. | `ResolveAttacks([wolfDash], [e1, e2], dt=0.016)` | Returns 2 results. Both have IsCritical=false (only 2 enemies dash-chain threshold not met). Resolved via normal crit steps. |
| 23c | Wolf Rect. 4 enemies alive within Rect bounds. | `ResolveAttacks([wolfDash], [e1, e2, e3, e4], dt=0.016)` | Returns 4 results. All 4 have IsCritical=true (3+ threshold met, applies to all). |
| 24 | Parry with 3 simultaneous Brute attacks overlapping the parry sector. | `ResolveFrame(dt=0.016)` parry phase | OnParrySuccess fires 3 times (once per parried attack). All 3 enemy attacks marked Nullified no player damage from any of them. |
| 25 | Human attack, critMultiplier=2.0, positional crit guaranteed (parry riposte). BaseDamage=18. | Crit resolution | DamageResult.FinalDamage=36.0 (18 × 2.0). IsCritical=true. Verifies critMultiplier is applied, not just the default 1.5. |
| 26 | critChanceStat=0.0, no flanking, no positional guarantee. Mock RNG returns 0.0. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=false. `0.0 < 0.0` is false (strict less-than). Verifies RNG=0 produces no stat crit even at boundary. |
| 27 | Flanking angle exactly 120° (enemy facing 0°, attack origin at angle 120° relative to enemy). | Flanking check | Flanking NOT active. `> 120°` is strict exactly 120° is NOT flanking. Crit resolution skips step 2. |
| 28 | critChanceStat=0.30, flanking angle >120° (flanking bonus +0.30 active). Mock RNG returns 0.30 exactly. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=false. Combined threshold = 0.30 + 0.30 = 0.60. `0.30 < 0.60` → NOT crit (strict less-than; 0.30 is not less than 0.30). |
| 29a | Mist Circle (Radius=3.8, InnerCritRadius=1.52). Enemy at (0, 0, 1.0) — distance 1.0 ≤ 1.52 (inner zone). | `ResolveAttacks([mistAttack], [enemy], dt=0.016)` | DamageResult.IsCritical=true (inner-radius positional guarantee). |
| 29b | Mist Circle (Radius=3.8, InnerCritRadius=1.52). Enemy at (0, 0, 2.0) — distance 2.0 > 1.52 (outer zone). No flanking. critChanceStat=0.05, mock RNG=0.5. | `ResolveAttacks([mistAttack], [enemy], dt=0.016)` | DamageResult.IsCritical=false (no positional; no flanking; stat 0.05 < 0.5 RNG fail). |
| 30 | critChanceStat=0.80 (beyond per-form max), flankingBonus=0.30. Edge case #4: combined clamping. | Crit resolution step 2 | Combined = clamp(0.80 + 0.30, 0.0, 1.0) = 1.0. Roll uses threshold 1.0. Mock RNG=0.99 crit (0.99 < 1.0). Mock RNG=1.0 no crit. |
| 31 | AttackDataFactory receives AttackStyle { Shape=Sector(100°,3.0), BaseDamage=18, AttackSpeed=0.4s, MaxTargets=1, CritChanceStat=0.05, CritMultiplier=1.5, KnockbackForce=5 }. Player at origin, facing +X. | `AttackDataFactory.Create(style, pos, facing, isParry=false, attackId=1)` | AttackData: HitShape=Sector(100°,3.0), BaseDamage=18, CritChanceStat=0.05, CritMultiplier=1.5, KnockbackForce=5, IsParry=false, Source=Player, AttackGroupId=0. |
| 32 | Parry: IsParry=true but HitShape.Type is Circle (not Sector invalid parry shape). | `ResolveFrame(dt=0.016)` parry phase | Parry skipped (invalid shape). Logged as warning. OnParrySuccess does NOT fire. |
| 33 | Same (AttackId, TargetId) pair appears in frame 1 and frame 2 (different ResolveAttacks calls). | Frame 1 then Frame 2 | Both frames produce hits independently. Dedup HashSet cleared between frames each frame's hit counts. |
## Open Questions
- Parry window (0.15s), stagger (0.45s), and recovery (0.20s) need prototype validation actual game feel will determine the right durations
- Should there be a "perfect parry" tier (tighter window, bigger reward) or is the single parry window sufficient? Recommended: add a "perfect parry" at 0.08s window for double crit multiplier, prototyped after MVP parry is validated
- Should enemies have varying hit shape sizes (e.g., Brute larger than Swarm) or uniform radius? Defer to Enemy AI GDD
- Attack shape size scaling: should Skill Tree upgrades increase shape dimensions, or only damage/critChanceStat? Defer to Skill Tree GDD
- Spatial grid cell size (default 5.0): may be too coarse for close-range combat, too fine for Stalker attacks (Rect length 6.0 exceeds cell size). Profile during prototype; cell size is now a tuning knob.
- Wolf dash-chain crit threshold (3 enemies): is 3 the right number, or should it scale with difficulty? Prototype validation needed.
- Mist inner crit ring visibility: the visible inner ring at 40% radius needs visual design should it be a hard ring, a gradient glow, or a ground decal?
- 150 max theoretical damage one-shots Brute (60 HP) is this intentional at max upgrades, or should damage output be capped below the highest non-Boss enemy HP?
- Should there be a "clash" outcome when Wolf dash Rect intersects Stalker rush Rect (both Rect shapes)? Defer to Enemy AI GDD if yes, Combat Logic needs a new event type.
- Should parry also work against future Shooter-type enemy projectiles? If yes, projectile AttackShape definition and parry intersection need specification.