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

27 KiB
Raw Blame History

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

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 (110, 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:

// 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<AttackData> + List<EnemyState>
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<DamageResult> 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<DamageResult> 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 550 From AttackStyle, determined by form and upgrades
Crit chance stat 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 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 OnHitAdd(attackGain), OnKillAdd(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 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 recovery duration 0.20s 0.150.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<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=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.