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

40 KiB
Raw Permalink Blame History

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:

// 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 OnHitAdd(attackGain), OnKillAdd(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=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 (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.