# Combat Logic > **Status**: In Review > **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 holds parry to enter a brief parry stance. **Parry Window**: The parry sector (Angle=100°, Radius=3.0) is active for 0.15s from input. During this window: - If **any enemy attack shape** overlaps the parry sector, the parry **succeeds**: the enemy is staggered (0.3s stun), the attack is nullified, and the next Human attack within 0.5s is a **guaranteed crit** (critMultiplier applied, no RNG roll). - 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. **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. ### 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.0–0.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 ``` AttackData { int AttackId // Unique per attack instance (monotonic, caller-assigned) Vector3 Origin // Attack origin (player position 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 float BaseDamage // From Form Switch SM's AttackStyle float CritChanceStat // Per-form stat (base 0.05), added to positional crit bonus float CritMultiplier // Default 1.5x float KnockbackForce bool IsParry // True if this attack is a Human parry attempt (not a standard attack) } EnemyState { int TargetId // Unique enemy identifier Vector3 Position // World position (L0 coordinate system) float FacingAngle // Facing direction in degrees — for flanking crit detection Shape HitShape // Enemy hit shape (typically Circle with per-type radius) float Health // Current health — used for IsKillingBlow determination float Weight // Knockback resistance (1–10, clamped to ≥1.0) bool IsDead // Derived: Health <= 0 } 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) } ``` **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) ``` CombatLogic.ResolveAttacks(attacks, enemies, deltaTime) 1. COLLECT: Receive List + List 2. CULL: Remove dead enemies, skip enemies outside attack range (coarse filter via spatial grid) 3. INTERSECT: For each attack × potential target, call ADR-003 Shape.Intersects() 4. DAMAGE: For each hit pair, execute damage formula (crit resolution per positional rules) 5. DEDUPLICATE: Each (AttackId, TargetId) pair counts at most once per frame 6. EMIT: OnHit(DamageResult), OnKill(DamageResult), OnCrit(DamageResult), OnParrySuccess(AttackData, DamageResult) 7. RETURN: List grouped by TargetId ``` **Pipeline is batch-per-frame**: All attacks are resolved against pre-frame enemy state. Dead filtering (step 2) runs once at the start. If an enemy is alive at frame start, all attacks that intersect it will produce hits this frame — damage is summed and applied downstream once after resolution. There is NO mid-frame kill check. **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` buffer that `ResolveAttacks` fills in-place. No per-frame allocation from Combat Logic. ### Target Filtering Rules - Enemy state = Dead → skip - Attack shape has no intersection with enemy 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` for shape + base damage per form | | Blood Energy Economy | Downstream | `OnHit` triggers `BloodEnergyEconomy.Add(attackGain)`, `OnKill` triggers `BloodEnergyEconomy.Add(killGain)`. Connected 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, and resistance application | | 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 | 5–50 | From AttackStyle, determined by form and upgrades | | Crit chance stat | `critChanceStat` | float | 0.0–0.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.2–3.0 | Baseline 1.5 | | Crit factor | `critFactor` | float | 1.0 or critMultiplier | Positional guarantee = always critMultiplier | **Output Range**: 5–150 (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 | 0–10 | From AttackStyle | | Enemy weight | `enemyWeight` | float | 1–10 (clamped to ≥1.0) | Heavier enemies resist knockback | **Output**: 0–15 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 to 1.0 (combined with positional bonus, cap at 1.0) | Guaranteed crit is the ceiling | | 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 | | 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.BaseDamage`, `AttackStyle.AttackShape` per form | | Blood Energy Economy | Downstream | `OnHit` → `Add(attackGain)`, `OnKill` → `Add(killGain)`. Bridged via L1 adapter. | | Enemy AI Logic | Downstream | Enemy receives `DamageResult` for health subtraction and behavior response | | 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 | No upstream GDD dependencies — this is a Foundation layer system. Form Switch SM GDD is undesigned; the expected `AttackStyle` contract is defined here as provisional until that GDD is written. ## Tuning Knobs | Parameter | Default | Safe Range | Too Low | Too High | |-----------|---------|------------|---------|----------| | `baseDamage` (Human) | 18 | 10–25 | Attacks feel weak | Other forms become irrelevant | | `baseDamage` (Wolf) | 22 | 15–35 | Burst doesn't feel bursty | Wolf the only viable form | | `baseDamage` (Mist) | 8 | 4–15 | AOE feels useless | Mist clears everything | | `critChanceStat` | 0.05 | 0.02–0.10 | Stat crits never seen without positional bonus | Positional crits lose distinction | | `critMultiplier` | 1.5 | 1.2–2.5 | Crits not noticeable | Damage spikes too wild | | Flanking bonus | 0.30 | 0.20–0.40 | Flanking not worth maneuvering for | Flanking replaces parry/dash-chain | | Human Sector Angle | 100° | 80–130° | Too narrow, parry frustrating | Too wide, parry trivial | | Wolf Rect Width | 1.0 | 0.8–1.5 | Too narrow, dash whiffs | Too wide, dash loses precision identity | | Wolf Rect Length | 5.0 | 3.0–8.0 | Dash feels short | Range competes with ranged forms | | Mist Circle Radius | 3.8 | 2.5–5.0 | AOE feels cramped | Covers whole screen, no positioning | | Mist inner crit radius | 1.52 (40% of 3.8) | 30–50% of circle radius | Inner-radius crits too rare | Inner-radius crits too easy | | HIT_EPSILON | 0.001 | 0.0005–0.005 | Grazing contacts feel inconsistent | Attacks "should have hit" | | Parry window duration | 0.15s | 0.10–0.20s | Parry too tight, frustrating | Parry too generous, loses skill | | Parry recovery duration | 0.20s | 0.15–0.30s | Recovery meaningless | Parry too punishing | **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 cell size = max attack range (5.0 units). Enemies bucketed at O(E) per frame. Attack queries check only nearby cells, reducing intersection pairs ~10× at 200 enemies. **Memory**: `NonAlloc` pattern — caller provides pre-allocated `List`. 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=0° }. 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 (no flank, no positional, stat only=0.0 < 0.5 roll), 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. | `ResolveAttacks([parry], [enemyAttack], dt=0.016)` | OnParrySuccess fires. Next Human attack within 0.5s has positional crit guarantee. | | 17 | Parry: same parry attack, but no enemy attack shape overlaps. | Same call | OnParrySuccess does NOT fire. OnAttackResolved fires with hitCount=0. | | 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. | ## Open Questions - Parry window (0.15s) and recovery (0.20s) need prototype validation — actual game feel will determine the right durations - Inner crit radius for Mist (40% of circle radius) — does this create the right "dense at center" feel or should it be a flat value? - Should there be a "perfect parry" tier (tighter window, bigger reward) or is the single parry window sufficient? - 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: max attack range (5.0) may be too coarse for predominantly close-range combat. Profile during prototype.