Approval GDD4

This commit is contained in:
SepComet 2026-04-27 21:41:15 +08:00
parent 01f79dccfa
commit 596e46bd94
8 changed files with 1121 additions and 54 deletions

View File

@ -1,6 +1,6 @@
# Combat Logic
> **Status**: In Review
> **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)
@ -31,13 +31,18 @@ Each form maps to one attack geometry type (via ADR-003):
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 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 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).
**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.
**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.
**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
@ -60,29 +65,56 @@ Crit is no longer RNG. Crit is earned through positioning, replacing the previou
### 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)
Vector3 Origin // Attack origin (player position in L0 world coords)
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
float BaseDamage // From Form Switch SM's AttackStyle
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
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 — for flanking crit detection
Shape HitShape // Enemy hit shape (typically Circle with per-type radius)
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)
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
@ -95,6 +127,10 @@ DamageResult {
}
```
**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`.
@ -135,19 +171,28 @@ L1's InputAdapter collects the raw data (player transform position, facing, inpu
### Hit Resolution Pipeline (called once per frame)
```
CombatLogic.ResolveAttacks(attacks, enemies, deltaTime)
**CombatCoordinator** (L0, `Combat/CombatCoordinator.cs`) is the per-frame orchestrator that aggregates inputs and distributes results:
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
```
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
```
**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.
**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.
@ -158,7 +203,8 @@ CombatLogic.ResolveAttacks(attacks, enemies, deltaTime)
### Target Filtering Rules
- Enemy state = Dead → skip
- Attack shape has no intersection with enemy hit shape → 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
@ -178,9 +224,10 @@ CombatLogic.ResolveAttacks(attacks, enemies, deltaTime)
| 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 |
| 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 |
@ -198,7 +245,7 @@ where critFactor = critMultiplier if crit (positional guarantee OR flanking+stat
| 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 |
| `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 |
@ -226,7 +273,7 @@ knockbackDistance = knockbackForce / max(enemyWeight, 1.0) × (IsCritical ? 1.5
| 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 |
| 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 |
@ -234,6 +281,10 @@ knockbackDistance = knockbackForce / max(enemyWeight, 1.0) × (IsCritical ? 1.5
| 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 |
@ -241,14 +292,16 @@ knockbackDistance = knockbackForce / max(enemyWeight, 1.0) × (IsCritical ? 1.5
| 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 |
| 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 |
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.
**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
@ -267,13 +320,16 @@ No upstream GDD dependencies — this is a Foundation layer system. Form Switch
| 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 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.
**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.
@ -320,7 +376,7 @@ Combat is a visual system — Visual/Audio is REQUIRED.
| # | 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. |
| 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. |
@ -339,17 +395,38 @@ Combat is a visual system — Visual/Audio is REQUIRED.
| 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. |
| 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) 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?
- 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: max attack range (5.0) may be too coarse for predominantly close-range combat. Profile during prototype.
- 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.

View File

@ -0,0 +1,223 @@
# Death/Respawn Rules
> **Status**: In Design
> **Author**: SepComet + Claude
> **Last Updated**: 2026-04-27
> **Implements Pillar**: 操作优于数值 (Skill Over Stats), 切换即承诺 (Switch is Commitment)
## Overview
Death/Respawn Rules 是夜裔的玩家死亡与重生管理系统——定义玩家如何承受伤害、何时死亡、在何处重生、以及死亡带来的代价。它与"切换即承诺"支柱直接对齐:死亡惩罚的设计目标是让玩家在切形态时感受到风险(切错了可能死),但不会让死亡变成挫折(快速重生,代价合理)。系统采用 checkpoint 重生模型:玩家死亡后在最近的检查点重生,失去所有当前血能,当前波次重置——但不会失去关卡进度或任何永久升级。
玩家直接感受死亡时刻的**冲击**和重生时刻的**重新出发**。死亡不是"你输了"——屏幕短暂变暗、血能归零、你在检查点重新站起、敌人重新列阵。这个循环参考 Celeste 的"失败是学习"哲学——死亡足够轻,让你敢于尝试高风险操作(弹反时机、形态切换窗口);但足够重,让你在意每一次切换的决策。没有这个系统,形态切换的风险维度崩塌——玩家可以无代价地随意切换,违背"切换即承诺"支柱。
## Player Fantasy
Death/Respawn Rules 服务于一个动作游戏中最被低估的情感:**"再来一次"的渴望**。当你因切形态时机错误被 Brute 一拳砸死时,那一瞬间不是挫败——是"我知道了,下次弹反要早 0.1 秒按"。屏幕暗下、血能归零、你在检查点重新站起的 2 秒内,你已经在大脑里重放了刚才的失误并找到了修正方案。这就是好的死亡系统应该创造的感受:死亡不是终点,是**信息**。
参考 Celeste 的死亡哲学——"每次死亡都是学习,重生快到你不觉得被打断"——和 Hades 的死亡循环——"死亡是回家,不是失败"。夜裔的死亡走在两者之间Celeste 的快速重生(检查点即重生,没有加载、没有菜单) + Hades 的资源重置(血能归零是实质性代价,迫使你重新赚取)。玩家敢于尝试高风险操作——极限弹反、狼形对撞、雾形深入包围——因为这些操作失败后的惩罚是**有限的、可量化的、可恢复的**。你不会因为一次失误而失去 30 分钟的进度,你只会失去当前的血能和当前波次——然后你立刻重新开始。
## Detailed Design
### Core Rules
**1. Player Health**
- `MaxHealth`: 100 (baseline, before any upgrades)
- `CurrentHealth`: tracked by Death/Respawn Rules, range [0, MaxHealth]
- Health displayed to player via HUD, driven by `OnPlayerHealthChanged(current, max)` event
**2. Player Damage Reception Pipeline**
```
Enemy AI produces AttackData → CombatCoordinator.ResolveFrame() → OnPlayerHit → Death/Respawn subtracts health
```
- Combat Logic's `CombatCoordinator.ResolveFrame(deltaTime)` handles all attack resolution (player + enemy) in a unified two-phase pipeline
- Enemy attacks are resolved against `PlayerState` during the damage phase (after parry nullification in the parry phase)
- Death/Respawn Rules subscribes to `CombatLogic.OnPlayerHit(PlayerDamageResult)` — subtracts FinalDamage from CurrentHealth
- No player-side resistance or armor — damage is applied as-received (mirrors Enemy AI's pure-receiver model)
**3. PlayerState Definition**
`PlayerState` is defined in Combat Logic GDD (authoritative single source). Death/Respawn reads and writes these fields:
- **Reads**: `Position`, `HitShape` (provided to CombatCoordinator for hit detection)
- **Writes**: `CurrentHealth` (updated on damage), `MaxHealth` (baseline 100, modified by upgrades), `IsDead` (set when CurrentHealth <= 0), `IsInvincible` (set during respawn i-frames)
- Other PlayerState fields (`FacingAngle`, `IsParryActive`, `ParryShape`) are managed by Form Switch SM and Combat Logic; Death/Respawn does not touch them.
**4. Death Condition**
- `CurrentHealth <= 0` → player dies
- Death is immediate on the frame health reaches 0
- Player cannot act during death (all input ignored)
- Trigger sequence: `OnPlayerDied` → systems react → respawn timer starts
**5. Checkpoint System**
- Checkpoint = start of current wave
- Updated each time `OnWaveChanged` fires (Wave Manager)
- On death: player respawns at the checkpoint wave's start state
- No manual checkpoint activation needed — automatic, invisible
### Death State
| Phase | Duration | What Happens |
|-------|----------|--------------|
| **Death Freeze** | 0.3s | Screen freeze-frame. Player input locked. `OnPlayerDied` emitted. |
| **Death Fade** | 1.0s | Screen fades to black. Audio ducks. Systems react (enemies stop, blood energy resets). |
| **Respawn Fade** | 0.5s | Player repositioned at checkpoint. HUD resets. `OnPlayerRespawned` emitted. |
| **Invincibility** | 2.0s | Player can move and act but takes no damage. Visual: player geometry pulses/flashes. |
| **Active** | — | i-frames expire. Full damage vulnerability restored. `OnPlayerVulnerable` emitted. |
Total death-to-active: ~3.8 seconds. Fast enough to feel like "instant retry."
### Respawn Flow
1. Player dies → `OnPlayerDied` emitted
2. Blood Energy Economy receives `OnPlayerDied` → resets blood energy to 0
3. Wave Manager receives `OnPlayerDied` → freezes wave state
4. Death fade completes → player position reset to checkpoint spawn point
5. Wave Manager receives `OnPlayerRespawned` → resets current wave (despawn all enemies, restart from InterWave)
6. Player enters Invincibility (2s)
7. `OnPlayerVulnerable` → player fully active, wave restarts
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|-----------|
| **Combat Logic** | Upstream | Provides `PlayerState` to Combat Logic as hit target. Receives `OnPlayerHit(PlayerDamageResult)` for health subtraction. |
| **Blood Energy Economy** | Downstream | Emits `OnPlayerDied` → Blood Energy resets to 0. |
| **Wave Manager** | Downstream | Emits `OnPlayerDied` (wave freezes), `OnPlayerRespawned` (wave resets to current checkpoint wave). |
| **Enemy AI Logic** | Downstream | Emits `OnPlayerDied` → all enemies enter Cooldown/Idle. Emits `OnPlayerVulnerable` → enemies resume normal behavior. |
| **Form Switch SM** | Downstream | Emits `OnPlayerDied` → resets form to default (Human) on respawn. |
| **UI/HUD (L2)** | Downstream | Emits `OnPlayerHealthChanged`, `OnPlayerDied`, `OnPlayerRespawned`, `OnPlayerVulnerable` for HUD display. |
| **VFX Spawner (L1)** | Downstream | Emits `OnPlayerDied` (death VFX), `OnPlayerRespawned` (respawn VFX). |
| **Audio Player (L1)** | Downstream | Emits `OnPlayerHit` (pain SFX), `OnPlayerDied` (death sting), `OnPlayerRespawned` (respawn whoosh). |
## Formulas
### Player Damage Reception
The player damage formula is defined as:
`newHealth = currentHealth - finalDamage`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Current health | `currentHealth` | float | 0100 | Pre-hit health |
| Final damage | `finalDamage` | float | 0150 | From Combat Logic PlayerDamageResult |
**Output Range**: 0100 | Clamped at 0. If newHealth <= 0 → IsDead = true, OnPlayerDied fires.
**Design Intent**: Player applies damage as-received — no armor, no resistance, no mitigation. Transparency: a Brute hit always does exactly 25 damage. The player always knows how many hits they can take. Skill expression is in avoiding/parrying hits, not in building damage resistance.
---
### Death Threshold
The death formula is defined as:
`isDead = currentHealth <= 0`
**Output**: Boolean. Once true, triggers the full death→respawn sequence. `OnPlayerDied` fires exactly once per death.
---
### Hit Survival Thresholds (Baseline 100 HP)
| Enemy Attack | Damage | Hits to Die |
|-------------|--------|-------------|
| Swarm | 6 | 17 |
| Stalker | 18 | 6 |
| Brute | 25 | 4 |
**Design Intent**: Brute is always lethal in 4 hits — enough to create tension but enough time to learn the parry timing. Swarm chip damage adds up — ignoring them is viable short-term but fatal if sustained.
## Edge Cases
- **If player takes damage exceeding current health (overkill)**: Health clamps to 0. No negative health. Death triggers once. Overkill damage is not tracked — does not affect respawn.
- **If player takes damage while IsInvincible=true (respawn i-frames)**: Damage ignored. OnPlayerHit does NOT fire. Health unchanged.
- **If multiple hits kill the player in the same frame (simultaneous hits from multiple enemies)**: All hits resolve (batched frame). Health drops to 0 or below. OnPlayerDied fires exactly once. Hit events for the same frame after death are still emitted but health stays at 0.
- **If deltaTime = 0 (game paused)**: Death/respawn timers (Death Freeze, Death Fade, Respawn Fade, Invincibility) all pause. No phase transitions during pause.
- **If player dies on Wave 1 (first wave, checkpoint = Wave 1 start)**: Normal respawn flow applies. Blood energy already 0 or low. Wave 1 restarts. No special case needed.
- **If player dies during Invincibility (2s i-frames)**: Cannot happen — player is invincible. No damage received.
- **If OnPlayerDied fires but OnPlayerRespawned never fires (e.g., game quit during death)**: State is discarded. Next session starts fresh. No partial death state persists.
- **If MaxHealth is modified (future Skill Tree upgrade) while player is dead**: Applied on respawn. CurrentHealth = new MaxHealth on respawn.
- **If checkpoint advances (OnWaveChanged) during death animation**: Ignored. Checkpoint only updates while player is in Active state. Death-locks checkpoint at the wave where death occurred.
## Dependencies
| System | Layer | Type | Hard/Soft | Interface |
|--------|-------|------|-----------|-----------|
| **Combat Logic** | L0 | Upstream | Hard | Provides PlayerState for hit detection. Receives OnPlayerHit for health subtraction. Cannot function without damage pipeline. |
| **Blood Energy Economy** | L0 | Downstream | Hard | Emits OnPlayerDied → resets blood energy to 0. Core death penalty mechanic. |
| **Wave Manager** | L0 | Downstream | Hard | Emits OnPlayerDied (freeze), OnPlayerRespawned (reset wave). Wave reset is the other core death penalty. |
| **Enemy AI Logic** | L0 | Downstream | Soft | Emits OnPlayerDied → enemies stop attacking. Works without but enemies attacking a corpse looks broken. |
| **Form Switch SM** | L0 | Downstream | Soft | Emits OnPlayerDied → form resets to Human on respawn. |
| **UI/HUD (L2)** | L2 | Downstream | Soft | Emits OnPlayerHealthChanged, OnPlayerDied, OnPlayerRespawned for display. |
| **VFX Spawner (L1)** | L1 | Downstream | Soft | Emits death/respawn events for visual feedback. |
| **Audio Player (L1)** | L1 | Downstream | Soft | Emits hit/death/respawn events for audio feedback. |
| **Level Loader (L1)** | L1 | Upstream | Soft | Receives checkpoint spawn positions from LevelData. |
**Hard dependencies**: Combat Logic, Blood Energy Economy, Wave Manager.
## Tuning Knobs
| Parameter | Default | Safe Range | Too Low | Too High |
|-----------|---------|------------|---------|----------|
| `MaxHealth` | 100 | 60150 | Every hit feels lethal — no learning window | Player immortal — no tension |
| `DeathFreezeDuration` | 0.3s | 0.10.5s | No impact feel — death feels glitchy | Too long — feels unresponsive |
| `DeathFadeDuration` | 1.0s | 0.51.5s | Not enough time for audio cue | Player waiting — frustration builds |
| `RespawnFadeDuration` | 0.5s | 0.31.0s | Disorienting — player doesn't register new position | Dead air before action resumes |
| `InvincibilityDuration` | 2.0s | 1.03.0s | Spawn-camped by enemies at perimeter | Too safe — player can clear enemies during i-frames |
| `PlayerHitShapeRadius` | 0.5 | 0.30.7 | Too hard to hit — attacks whiff unfairly | Too easy to hit — player feels bloated |
**Key tuning pair**: `MaxHealth` + enemy `AttackBaseDamage` values (in Enemy AI GDD). Changing MaxHealth changes every hits-to-die threshold. Baseline (non-crit worst case): Swarm=100/6≈17 hits, Stalker=100/18≈6 hits, Brute=100/25=4 hits. Actual hits-to-die varies with player crits, positional guarantees, and form-specific damage.
## Visual/Audio Requirements
Combat is a visual system — Visual/Audio is REQUIRED.
| Event | VFX | Audio |
|-------|-----|-------|
| `OnPlayerHit` | Red flash at screen edge from hit direction. Player geometry briefly flashes red. | Pain SFX — varies by damage amount (light grunt <15, heavy grunt 15) |
| `OnPlayerDied` | Screen freeze-frame (0.3s) → player geometry shatters outward in white/silver fragments | Bass drop + silence — distinct from enemy death sounds |
| `OnPlayerRespawned` | Player geometry reforms from fragments at checkpoint position. White flash → form color transition. | Rising whoosh + heartbeat start |
| `OnPlayerVulnerable` | Player geometry stops pulsing/flashing. Brief shimmer. | Subtle "ready" chime |
| `OnPlayerHealthChanged` | Health bar VFX (flash on damage, gentle pulse on low health <25%) | None (too frequent for SFX) |
**Invincibility visual**: Player geometry pulses white at 4Hz during i-frames. Intensity decreases over the 2s duration — last 0.5s is barely visible.
## UI Requirements
| Element | Content | Trigger |
|---------|---------|---------|
| Health Bar | CurrentHealth / MaxHealth as horizontal bar. Color: green (>50%), yellow (25-50%), red (<25%). | `OnPlayerHealthChanged` |
| Death Overlay | Full-screen dark vignette + "You Died" text (1s) | `OnPlayerDied` |
| Respawn Text | "Again" — centered, fades after 1s | `OnPlayerRespawned` |
| i-Frame Indicator | Health bar border pulses white during invincibility | `OnPlayerRespawned``OnPlayerVulnerable` |
## Acceptance Criteria
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 1 | Player CurrentHealth=100. `PlayerDamageResult { FinalDamage=25 }`. | OnPlayerHit received | CurrentHealth=75. OnPlayerHealthChanged(75, 100) fires. IsDead=false. |
| 2 | Player CurrentHealth=20. `PlayerDamageResult { FinalDamage=25 }`. | OnPlayerHit received | CurrentHealth=0 (clamped). IsDead=true. OnPlayerDied fires. Death sequence begins. |
| 3 | Player CurrentHealth=10. Two simultaneous hits: FinalDamage=8 and FinalDamage=6. | Both hits in same frame | CurrentHealth=0. OnPlayerDied fires exactly once. Both OnPlayerHit events fire. |
| 4 | Player IsInvincible=true (during respawn i-frames). `PlayerDamageResult { FinalDamage=25 }`. | OnPlayerHit received | Damage ignored. CurrentHealth unchanged. OnPlayerHealthChanged does NOT fire. |
| 5 | Player dies. Death Freeze(0.3s) → Death Fade(1.0s) → Respawn Fade(0.5s) → Invincibility(2.0s). | Death sequence completes | Total elapsed ~3.8s. Player is at checkpoint position, full health, form=Human. |
| 6 | Player dies on Wave 3. Checkpoint = Wave 3. | Respawn completes | Wave Manager.OnPlayerRespawned fires → Wave 3 resets from InterWave. Blood energy = 0. |
| 7 | Player in Active state. Wave 4 starts (OnWaveChanged fires). | Checkpoint update | CurrentCheckpointWave = 4. Future death respawns at Wave 4. |
| 8 | deltaTime=0.0 during Death Fade (0.5s elapsed, 0.5s remaining). | Update called | Timer does not advance. Death Fade stays at 0.5s remaining. |
| 9 | Player CurrentHealth=100. MaxHealth increased to 120 (future upgrade). | Upgrade applied | MaxHealth=120. CurrentHealth stays 100. (Does not auto-heal on upgrade.) |
| 10 | Player dies at MaxHealth=120. | Respawn completes | CurrentHealth = MaxHealth = 120 (full heal on respawn). |
## Open Questions
- Respawn position within arena: Should the player respawn at arena center, or at a designer-specified spawn point per wave? MVP defaults to arena center. Defer spawn point customization to Level Loader GDD.
- Should there be a "death streak" mechanic (e.g., slight health buff after 3 consecutive deaths on same wave)? Aligns with Celeste philosophy of helping struggling players, but may conflict with "Skill Over Stats" pillar.
- Player HitShape (Circle r=0.5): Is this the same regardless of current form? Or should HitShape change with form (Human smaller, Wolf larger, Mist diffuse)? MVP uses uniform HitShape for simplicity.
- Should the death counter be tracked for scoring/statistics? Score Calculator GDD may want this data.
- Game Over condition: Is there one? Infinite retries is the MVP plan, but should a level have a max-death count for challenge mode? Defer to Score Calculator / Menu System.

View File

@ -0,0 +1,466 @@
# Enemy AI Logic
> **Status**: In Review (revised 2026-04-27 — aligned with revised Combat Logic GDD)
> **Author**: SepComet + Claude
> **Last Updated**: 2026-04-27
> **Implements Pillar**: 形态即战术 (Form is Tactics), 战场即信息 (The Field is Information), 操作优于数值 (Skill Over Stats)
## Overview
Enemy AI Logic 是夜裔的敌人行为决策系统——控制每个敌人的追逐、攻击、冷却和伤害响应。系统采用 ADR-005 定义的轻量级逐类型有限状态机FSM基座为 Idle → Chase → Attack → Cooldown 四态循环,不同类型的差异化集中在 Attack 状态中。它为每种敌人类型编码了"鼓励使用哪种形态"的行为模式Swarm 集群包围迫使切雾形 AOEBrute 高伤害单次重击创造人形弹反窗口Stalker 高速冲刺留给狼躯直线对撞。
玩家不与 Enemy AI Logic 直接交互——他们**阅读**敌人行为。一个 Brute 举起重击前摇,就是战场上最清晰的信息:"现在是弹反的时机"。一个 Stalker 开始低伏冲刺,就是"切狼躯对撞或闪避"。Enemy AI Logic 是"战场即信息"支柱的引擎端:它确保每种敌人行为都是可读、可预测、可学习的——因为玩家每一个切换决策都建立在看懂敌人行为之上。没有这个系统,敌人只是静止的靶子,形态切换失去战术意义,"形态即战术"支柱崩塌。
## Player Fantasy
Enemy AI Logic 服务于一个独特的双重幻想:对玩家而言,它是**读场**的快感——你看见一群 Swarm 围过来,心里知道"切雾形";看见 Brute 抬起重拳的前摇,肌肉记忆已经按下弹反。对设计师而言,它是**编排**的权力——每个敌人的行为是一段为玩家形态切换谱写的节奏线,敌人不是"AI",是作曲家留在战场上的音符。
这个幻想与"战场即信息"支柱直接对齐。当 Enemy AI 运转良好时,玩家不会说"这个AI写得不错"——他们会说"我看懂了,我知道该切什么"。敌人的每一个状态转换Chase→Attack、Attack→Cooldown都是玩家决策链中的一个信息节点。Brute 从 Chase 进入 Attack 的那个 0.5s 抬手前摇,就是游戏在问玩家:"你准备好弹反了吗?"
参考 Hades 的敌人可读性——每个敌人的攻击前摇都清晰到你可以闭眼计时——和 Vampire Survivors 的密度压迫感——敌人数量本身构成了"你必须移动或切换"的推力。夜裔的 Enemy AI 将两者融合:**密度创造压力,可读性创造决策空间**。玩家不是被数字淹没,而是被清晰的信息包围——每一个敌人都是一张"需要你切换形态"的明牌。
## Detailed Design
### Core Rules
**1. Per-Frame Decision Pipeline**
每一帧Enemy AI Logic 对每个活跃敌人执行一次 `ComputeAction(enemy, player)`
```
1. DEAD CHECK: enemy.Health <= 0 → 强制 Dead 状态,返回 AICommand.Dead
2. DELTA CLAMP: effectiveDt = min(deltaTime, MAX_DELTA) where MAX_DELTA = 0.05s — 防止帧率峰值跳过攻击前摇或冷却计时器
3. STATE TRANSITION: 检查当前状态的转换条件距离、计时器、player.IsDead
4. ACTION OUTPUT: 当前状态产生一个 AICommand
5. RETURN: AICommand 传递给 CombatCoordinator 进行攻击解析
```
**DeltaTime 上限**`MAX_DELTA = 0.05s` 确保即使出现大幅帧率峰值(如 GC 暂停),单个 Update() 调用也不会跳过整个攻击前摇Stalker 最短0.15s或冷却Swarm 最短0.6s)。此上限与 Form Switch SM 的 deltaTime 上限策略一致。
**2. Aggro Rules**
- 每个敌人有 `DetectionRange`。玩家进入检测范围 → aggro 激活
- 一旦 aggro 激活,敌人始终知道玩家位置(不会丢失目标)
- Aggro 不可逆——敌人不会"失去兴趣"
- 生成时在 Idle 状态,不 aggro玩家进入 DetectionRange 后立即转换到 Chase
**3. Health & Damage Reception**
- Enemy AI Logic 通过 `ApplyDamage(DamageResult)` 接口接收 Combat Logic 的伤害输出
- 从 enemy.Health 减去 DamageResult.FinalDamage
- 如果 Health <= 0标记 IsDead = true发射 `OnEnemyDied(enemyId, enemyType, killerForm)`
- 应用 KnockbackDistance 到敌人位置(沿攻击方向位移)
- **不使用按形态的伤害抗性**——几何形状交互本身就是形态优势(单点 vs. AOE vs. 直线)
**4. Spacing Behavior**
- Chase 状态:敌人以 MoveSpeed 向玩家位置移动
- 到达 AttackRange 时:转换到 Attack 状态
- 如果因击退而超出 AttackRange保持在 Attack 状态(攻击动画已启动),攻击完成后进入 Cooldown
- 没有"后退/保持距离"行为——所有 MVP 敌人都是近战逼近型
- **Kiting mitigation**: 所有敌人均为近战逼近型,玩家可无限后退风筝。主要缓解措施为竞技场边界(半径 30见 Wave Manager GDD——边界作为硬性约束防止无限后退。Stalker 的高速7.0作为次级缓解——Stalker 会追上后退的玩家。如果原型验证显示风筝仍是主要问题备选方案包括Stalker 速度爆发、Swarm 区域封锁,或竞技场收缩机制。
**5. Enemy Type Configuration**
每个敌人类型由其 `EnemyTypeConfig` 定义:
| 参数 | 类型 | 描述 |
|------|------|------|
| TypeId | int | 唯一类型标识符 |
| MaxHealth | float | 最大生命值 |
| MoveSpeed | float | Chase 状态移动速度(单位/秒) |
| DetectionRange | float | 检测玩家的距离 |
| AttackRange | float | 发起攻击的距离 |
| AttackWindup | float | 攻击前摇(秒),期间有动画提示 |
| AttackShape | HitShape | 攻击判定形状 |
| AttackBaseDamage | float | 攻击基础伤害 |
| CooldownDuration | float | 攻击后冷却时间(秒) |
| Weight | float | 抗性1-10影响击退 |
| HitShape | HitShape | 受击判定形状(通常为 Circle |
| EncouragedForm | FormType | 此敌人类型设计上鼓励的玩家形态 |
### States and Transitions
**Base FSM (ADR-005),所有类型共享:**
```
Idle ──→ Chase ──→ Attack ──→ Cooldown
↑ │ │ │
│ │ │ │
└─────────┴──────────┴──────────┘
如果玩家超出 DetectionRange + margin 且不在 Chase 中,回到 Idle
Chase 状态额外退出:玩家 IsDead → 强制 Idle
```
| 状态 | 行为 | 转换条件 |
|------|------|----------|
| **Idle** | 待机/巡逻(当前帧无动作)。扫描玩家位置。 | → Chase玩家进入 DetectionRange |
| **Chase** | 以 MoveSpeed 向玩家移动。面向玩家。 | → Attack玩家进入 AttackRange |
| **Attack** | 执行 Windup → 攻击判定 → 收招动画。攻击判定帧产生 AttackData 交给 Combat Logic。 | → Cooldown攻击动画完成 |
| **Cooldown** | 等待 CooldownDuration。面向玩家但不移动。 | → Chase计时器结束且玩家在 DetectionRange + 20% margin 内;否则 → Idle |
| **Dead** | 不做任何逻辑。Health <= 0 时立即进入。 | 无(不可逆) |
**Dead 状态说明**Dead 是衍生状态——当 Health <= 0 时,无论当前在哪个 FSM 状态,立即进入 Dead。Combat Logic 通过 `OnKill(IsKillingBlow=true)` 触发此转换。L1 适配层负责销毁 GameObject 和触发死亡 VFX。
### Per-Type Attack Parameters (MVP — 3 Types)
#### Swarm小型集群敌人
```
EnemyTypeConfig {
TypeId: 1, MaxHealth: 12, MoveSpeed: 4.5,
DetectionRange: 12.0, AttackRange: 1.5,
AttackWindup: 0.2s, CooldownDuration: 0.6s,
AttackShape: Circle(r=0.8), AttackBaseDamage: 6,
Weight: 1.0, HitShape: Circle(r=0.4),
EncouragedForm: Mist
}
```
**设计意图**:大量小个体同时进攻 → Human 扇形一次只命中 1 个Wolf 矩形一次最多 3 个Mist 圆形 AOE (r=3.8) 一次清屏。Swarm 的几何特性本身——多、小、散——就是"切雾形"的信号。AttackWindup 短0.2s)使弹反窗口紧凑,进一步降低 Human 效率。
#### Brute重型单体敌人
```
EnemyTypeConfig {
TypeId: 2, MaxHealth: 60, MoveSpeed: 2.0,
DetectionRange: 10.0, AttackRange: 3.0,
AttackWindup: 0.5s, CooldownDuration: 1.2s,
AttackShape: Circle(r=1.2), AttackBaseDamage: 25,
Weight: 8.0, HitShape: Circle(r=0.9),
EncouragedForm: Human
}
```
**设计意图**:慢速、高伤害、明显前摇 → 完美的弹反目标。Human 弹反窗口 0.15s 精准匹配 Brute 的 0.5s 前摇玩家看到抬手→反应→按弹反。Mist AOE 低伤害base 8清不动 60 HP。Wolf 可以冲刺爆发但风险很高——如果在 Wolf Recovery0.15s 不可攻击)期间 Brute 攻击命中,玩家承受 25 伤害无弹反机会。Brute 的 Weight=8 使击退接近零——它不会因被击而位移,弹反是唯一的"打断"方式。
#### Stalker高速冲刺敌人
```
EnemyTypeConfig {
TypeId: 3, MaxHealth: 30, MoveSpeed: 7.0,
DetectionRange: 14.0, AttackRange: 6.0,
AttackWindup: 0.15s, CooldownDuration: 0.8s,
AttackShape: Rect(w=1.5, l=6.0), AttackBaseDamage: 18,
Weight: 3.0, HitShape: Circle(r=0.5),
EncouragedForm: Wolf
}
```
**设计意图**:高速从远距离发动冲刺 → Wolf 的 Rect 冲刺 (w=1.0, l=5.0) 形成"对撞"判定。Stalker 从 6.0 距离启动冲刺Wolf 也从约 5.0 距离启动——两者矩形重叠的可能性很高。Wolf 是**最优**反制手段(爆发伤害、对撞优势),但 Mist 在 Stalker 冲刺落点后可提供次要反制AOE 覆盖已接近的 Stalker4 次命中击杀,在 Stalker 冷却期间完成。Human 弹反理论上可行0.15s 窗口捕捉 0.15s 前摇但时机极紧——这是高阶技巧。Stalker 前摇0.15s)快于人类视觉反应时间(~200-250ms因此玩家无法纯粹靠反应弹反 Stalker——必须通过预判和站位来应对。"阅读 Stalker 行为"的方式是观察其低伏冲刺姿态和 violet streak VFX而非在起手式后才开始反应。
**Wolf vs Stalker 对撞设计**:当 Wolf Rect 与 Stalker 冲刺 Rect 相交时发生特殊"对撞"结果Stalker 攻击被 nullifiedWolf 赢Stalker 额外眩晕 0.45s。Wolf 玩家不承受伤害。这使 Wolf 对 Stalker 的优势明确且可感知——不同于仅靠数值差异22 vs 18 = +4 HP无法被感知。对撞由 Combat Logic 在招架阶段检测——两个 AttackSource.Enemy 的矩形与一个 AttackSource.Player 的矩形相交。
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|-----------|
| **Combat Logic** | Upstream | 通过 `ApplyDamage(DamageResult)` 接收伤害、击退。敌人攻击时产生的 `AttackData` 注入 Combat Logic 的 `ResolveAttacks`。 |
| **Combat Logic** | Downstream | Enemy AI 为每个 Attack 状态敌人产出 `AttackData`(填充 HitShape、BaseDamage、Direction 等),提交给 Combat Logic 判定。 |
| **Wave Manager Logic** | Upstream | Wave Manager 负责生成敌人实例并注入 `EnemyTypeConfig`。通知 Enemy AI "新敌人已生成"。 |
| **Wave Manager Logic** | Downstream | Enemy AI 发射 `OnEnemyDied` → Wave Manager 追踪存活敌人数量,判断波次是否结束。 |
| **VFX Spawner (L1)** | Downstream | 发射 `OnEnemyStateChanged`(状态转换时)、`OnEnemyAttackWindup`(前摇开始时)、`OnEnemyDied` |
| **Audio Player (L1)** | Downstream | 发射 `OnEnemyAttackWindup`(前摇音效)、`OnEnemyHit`(受击音效)、`OnEnemyDied`(死亡音效) |
| **Boss AI Logic** | Downstream | Boss AI 继承 Enemy AI 的 FSM 基座和 EnemyTypeConfig扩展 BossPhase 维度 |
**Player State 获取**Enemy AI 通过 CombatCoordinator 每帧传入的 `PlayerState` 参数获取玩家位置、当前形态、是否弹反激活、和 `IsDead` 状态。PlayerState 由 CombatCoordinator 在帧开始时计算一次,然后传递给所有 `ComputeAction()` 调用——不在每个敌人内部独立查询。这避免了 200 次独立的接口调用,并与 ADR-005 的参数式签名一致。
**AttackData 流向**Enemy AI 不直接调用 Combat Logic。每帧 Enemy AI 输出 `List<AICommand>`,其中 Attack 命令携带 `AttackData`。CombatCoordinatorL0将此 List 与玩家攻击的 `AttackData` 合并,再统一调用 `ResolveFrame()`
**Enemy AttackData 字段默认值**:敌人产生的 AttackData 必须设置以下字段。标记为 [固定] 的字段对所有敌人攻击均不变;标记为 [配置] 的字段来自 EnemyTypeConfig。
| AttackData 字段 | 敌人值 | 来源 |
|-----------------|--------|------|
| `AttackId` | 单调递增CombatCoordinator 分配 | CombatCoordinator |
| `AttackGroupId` | 0敌人攻击不在同一激活内分组 | [固定] |
| `Source` | `AttackSource.Enemy` | [固定] |
| `Origin` | 敌人当前 Position | EnemyState |
| `Direction` | 敌人当前 FacingAngle | EnemyState |
| `HitShape` | 来自 EnemyTypeConfig.AttackShape | [配置] |
| `SourceForm` | `FormType.None` | [固定] |
| `BaseDamage` | 来自 EnemyTypeConfig.AttackBaseDamage | [配置] |
| `CritChanceStat` | 0.0(敌人不会暴击) | [固定] |
| `CritMultiplier` | 1.0(敌人不会暴击) | [固定] |
| `KnockbackForce` | 来自 EnemyTypeConfig可选当前所有 MVP 类型均为 0 | [配置] |
| `InnerCritRadius` | 0.0(敌人无内圈暴击区) | [固定] |
| `IsParry` | false敌人不弹反 | [固定] |
**AICommand 结构**`AICommand` 是一个 union 结构体,包含 `CommandType` 枚举和携带数据:
```
AICommand {
CommandType Type; // MoveTo, Attack, Idle
Vector3 TargetPosition; // MoveTo 的目标位置
AttackData AttackData; // Attack 的攻击数据(仅在 Type==Attack 时有效)
}
```
CombatCoordinator 每帧收集所有 AICommand从中提取 Attack 命令的 AttackData并将其与玩家攻击合并。
**Enemy 实例工厂**Enemy AI Logic 提供 `SpawnEnemy(typeConfig, position, facingAngle)` 工厂方法,由 Wave Manager 在生成敌人时调用。此方法:
1. 创建新的 `EnemyState` 实例,从 `EnemyTypeConfig` 初始化 Position、FacingAngle、HitShape、Health、Weight
2. 将实例添加到 Enemy AI 的内部 `EnemyState[]` 数组CombatCoordinator 每帧读取)
3. 返回 `enemyId`(在 Enemy AI 内部单调递增分配)
4. 如果玩家已在 DetectionRange 内,直接进入 Chase 状态;否则进入 Idle
5. L1 适配层并行创建对应的 GameObject通过订阅 `OnEnemySpawned` 事件)
```
int SpawnEnemy(EnemyTypeConfig config, Vector3 position, float facingAngle)
```
此工厂方法是 Enemy AI 与 Wave Manager 之间的硬契约——Wave Manager 不可在没有它的情况下运作。
## Formulas
### Aggro Detection
The aggro formula is defined as:
`isAggroed = distance(enemyPos, playerPos) <= DetectionRange`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Enemy position | `enemyPos` | Vector3 | any | Enemy world position (L0 coords) |
| Player position | `playerPos` | Vector3 | any | Player world position (L0 coords) |
| Detection range | `DetectionRange` | float | 10.014.0 | Per EnemyTypeConfig |
**Output**: Boolean — once true, stays true for this enemy's lifetime.
---
### Chase Movement
**Chase Movement formula with guard**:
`direction = playerPos - enemyPos`
`if |direction| ≤ EPSILON: skip movement (enemy at player position)`
`else: newPosition = enemyPos + normalize(direction) × MoveSpeed × deltaTime`
This guards against division-by-zero when an enemy reaches the player's exact position (common at high density with no enemy-enemy collision). `EPSILON = 0.001f` (from ADR-003).
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Move speed | `MoveSpeed` | float | 2.07.0 | Per EnemyTypeConfig |
| Delta time | `deltaTime` | float | 00.033s | Frame delta (typically ~0.016s at 60fps) |
**Output Range**: 0.0320.231 units/frame | No acceleration/deceleration — instant speed.
---
### Attack Range Check
The attack range formula is defined as:
`canAttack = distance(enemyPos, playerPos) <= AttackRange AND currentState == Chase`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Attack range | `AttackRange` | float | 1.56.0 | Per EnemyTypeConfig |
**Output**: Boolean. Attack starts on the frame the condition becomes true.
---
### Damage Reception
The damage reception formula is defined as:
`newHealth = currentHealth - finalDamage`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Current health | `currentHealth` | float | 0MaxHealth | Pre-hit health |
| Final damage | `finalDamage` | float | 0150 | From Combat Logic DamageResult (already includes crit) |
**Output Range**: 0 to MaxHealth | Clamped at 0 (cannot go negative). If newHealth <= 0 → IsDead = true.
**Design Intent**: Enemy AI applies damage as-received. No resistance, no armor, no mitigation. Combat Logic owns the damage formula; Enemy AI is a pure receiver. This keeps the systems single-responsibility and makes enemy durability entirely transparent to the player (an enemy with 60 HP always takes exactly the DamageResult.FinalDamage).
---
### Knockback Application
The knockback formula is defined as:
`newPosition = enemyPos + attackDirection × knockbackDistance`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Attack direction | `attackDirection` | Vector3 | unit vector | From attack origin to hit point (Combat Logic) |
| Knockback distance | `knockbackDistance` | float | 015 | From DamageResult.KnockbackDistance |
**Output Range**: 015 units displacement | Computed by Combat Logic; Enemy AI applies it. Weight already factored in Combat Logic's knockback formula.
## Edge Cases
- **If enemy spawns with player already in DetectionRange**: Skip Idle, immediately enter Chase. No "wake-up" animation needed — enemy is already alert.
- **If Health <= 0 and ApplyDamage is called again (double-kill)**: Ignore the call. Dead enemies do not receive damage events. The first IsDead=true transition is final.
- **If deltaTime = 0 (game paused)**: Skip entire `ComputeAction` for all enemies. No state transitions, no movement, no attack timers advance.
- **If enemy has no EnemyTypeConfig assigned**: Log error, skip enemy in AI loop. Enemy does not move or attack. This is a data integrity bug, not a runtime edge case.
- **If AttackRange > DetectionRange (misconfigured EnemyTypeConfig)**: Clamp AttackRange to DetectionRange at config load time. An enemy can't attack what it can't detect.
- **If MoveSpeed = 0 (misconfigured)**: Enemy stays in Chase forever but never reaches AttackRange. Log warning at config load. Design fix: set MoveSpeed > 0.
- **If knockback pushes enemy beyond DetectionRange + 20% margin during Cooldown**: Normal Cooldown → Idle transition applies. Enemy returns to Idle (scans for player again). This creates a legitimate gameplay interaction — player can "punt" an enemy out of combat temporarily.
- **Enemy stacking at player position (magnet convergence)**: Without enemy-enemy collision (MVP deferral), all enemies in Chase converge on the player's exact position. This creates: (a) unreadable visual blob — enemies indistinguishable, (b) degenerate AOE damage — single Mist circle hits all stacked enemies, (c) stacked damage to player — up to 20 Swarms × 6 damage = 120 damage/frame. Mitigation for MVP: Swarm has per-frame player damage cap of 50 (max 8 simultaneous Swarm hits). Full enemy-enemy separation (minimum distance repulsion) deferred to vertical slice. Known risk documented.
- **If player dies while enemies are in any non-Dead state**: Enemies enter Cooldown (if attacking) or Idle. L1/DeathRespawn rules manage the overall game state. Enemy AI does NOT continue attacking a dead player.
- **If an enemy attack fires but player has already killed the enemy in the same frame**: The batched ResolveAttacks pipeline handles this — all attacks resolve against pre-frame state. If enemy A's attack and the killing blow to enemy A are in the same frame, both resolve. The enemy's attack can still hit the player even as the enemy dies.
- **If 200+ enemies are active simultaneously**: Each `ComputeAction` call is O(1) — a single distance check + state machine step. At 200 enemies this is ~0.2ms budget. No per-frame allocation (struct AICommand, pre-allocated list).
- **If enemy is knockbacked into another enemy**: No collision response between enemies in MVP. Enemies can overlap. Enemy-enemy collision is deferred to vertical slice polish.
- **If Brute's AttackWindup (0.5s) is interrupted by player parry (0.15s window overlapping attack shape)**: This is handled by Combat Logic's parry system. Parry success → enemy enters stagger (0.45s stun), attack is nullified. Enemy AI receives `OnParrySuccess` event → force transition to Cooldown after stagger ends. This is the core Brute→Human interaction and must work correctly.
## Dependencies
| System | Layer | Type | Hard/Soft | Interface |
|--------|-------|------|-----------|-----------|
| **Combat Logic** | L0 | Upstream | Hard | Receives `DamageResult` via `ApplyDamage()`. Produces `AttackData` for enemy attacks to feed into `ResolveAttacks()`. Enemy AI cannot function without hit resolution and damage feedback. |
| **Wave Manager Logic** | L0 | Upstream | Hard | Wave Manager spawns enemy instances and injects `EnemyTypeConfig`. Without it, enemies don't exist. |
| **Form Switch SM** | L0 | Upstream | Soft | Reads player position and current form via `IPlayerStateProvider`. Soft because Enemy AI could function with position-only (ignoring form), but optimal play requires form awareness for certain enemy behaviors. |
| **Boss AI Logic** | L0 | Downstream | Soft | Boss AI inherits the base FSM and `EnemyTypeConfig` structure. Enemy AI must expose virtual methods for Boss AI to override (per ADR-005). |
| **Wave Manager Logic** | L0 | Downstream | Hard | Emits `OnEnemyDied(enemyId, enemyType, killerForm)` for Wave Manager to track remaining enemies and determine wave completion. |
| **VFX Spawner (L1)** | L1 | Downstream | Soft | Emits state change, windup, and death events for visual feedback. Game works without VFX but feels broken. |
| **Audio Player (L1)** | L1 | Downstream | Soft | Emits windup, hit, and death events for audio feedback. Same as VFX — functional without but degraded. |
| **Death/Respawn Rules** | L0 | Downstream | Soft | Enemy AI emits `OnEnemyDied` which Death/Respawn may listen to for scoring/tracking. No direct dependency required for MVP. |
**Hard dependencies** (system cannot function without): Combat Logic, Wave Manager Logic (spawning).
**Soft dependencies** (enhanced by but works without): Form Switch SM, VFX Spawner, Audio Player, Death/Respawn Rules, Boss AI Logic.
## Tuning Knobs
| Parameter | Default | Safe Range | Too Low | Too High |
|-----------|---------|------------|---------|----------|
| `MoveSpeed` (Swarm) | 4.5 | 3.06.0 | Swarm never reaches player, no pressure | Swarm is unkitable, Mist form mandatory |
| `MoveSpeed` (Brute) | 2.0 | 1.53.0 | Brute never closes to attack range | Brute too fast to parry-react |
| `MoveSpeed` (Stalker) | 7.0 | 5.09.0 | Rush not threatening, easy to ignore | Rush unreactable, Wolf counter unreliable |
| `DetectionRange` (global across types) | 1014 | 818 | Enemies don't aggro until player is on top of them | Snipes from offscreen, feels unfair |
| `AttackRange` vs `DetectionRange` | Attack ≤ Detection | — | Enemy can attack but can't detect (invalid config) | — |
| `AttackWindup` (Swarm) | 0.2s | 0.150.3s | Swarm hits with no warning | Swarm too easy to react to, no threat |
| `AttackWindup` (Brute) | 0.5s | 0.350.7s | Too fast to parry-react (violates counter design) | Too slow — parry trivial, Brute harmless |
| `AttackWindup` (Stalker) | 0.15s | 0.100.25s | No visual telegraph, unreactable | Rush feels sluggish, loses identity |
| `CooldownDuration` (Swarm) | 0.6s | 0.31.0s | Swarm attacks continuously — no breathing room | Swarm feels passive, no density pressure |
| `CooldownDuration` (Brute) | 1.2s | 0.82.0s | Brute chains attacks, no parry window between | Brute stands idle too long, not a threat |
| `CooldownDuration` (Stalker) | 0.8s | 0.41.2s | Stalker endlessly rushes, can't counter | Free kill window too generous |
| `MaxHealth` (Swarm) | 12 | 820 | Dies before player can react — no density | Takes too many hits, Mist AOE feels weak |
| `MaxHealth` (Brute) | 60 | 40100 | Wolf burst one-shots, parry irrelevant | Damage sponge — tedious, not tactical |
| `MaxHealth` (Stalker) | 30 | 2050 | Wolf one-shots, no "counter" satisfaction | Stalker survives too many rushes |
| `AttackBaseDamage` (Swarm) | 6 | 310 | Tickles — player ignores Swarm entirely | Swarm more threatening than Brute (per-hit) |
| `AttackBaseDamage` (Brute) | 25 | 1840 | Parry not worth the risk (low reward) | One-shots player — parry mandatory or die |
| `AttackBaseDamage` (Stalker) | 18 | 1228 | Rush not threatening enough to switch for | Stalker rush kills from full HP |
| `Weight` (Brute) | 8.0 | 5.010.0 | Brute gets knocked around, parry positioning trivial | Knockback zero — Brute feels glued to floor |
**Key tuning groups** (changing one requires checking the other):
- **Swarm trio**: `MaxHealth` + `AttackBaseDamage` + `MoveSpeed` — too high on all three and Swarm becomes the only enemy that matters
- **Brute parry feel**: `AttackWindup` + `AttackBaseDamage` — windup must be long enough to react, damage high enough to make parry worth the risk
- **Stalker rush feel**: `MoveSpeed` + `AttackWindup` + `AttackRange` — the rush duration (AttackRange / MoveSpeed) must be long enough for the player to register "Stalker incoming" and react
## Visual/Audio Requirements
Combat is a visual system category — Visual/Audio is REQUIRED.
**Enemy Visual Identity (per art bible principles):**
| Enemy Type | Geometric Shape | Color | Size Reference |
|------------|----------------|-------|----------------|
| **Swarm** | Small tetrahedron (pyramid) | Amber/orange | ~0.5 units diameter |
| **Brute** | Large cube/rectangular prism | Red/crimson | ~1.5 units width |
| **Stalker** | Elongated wedge (arrow-like) | Violet/magenta | ~1.0 units length |
**State Visual Telegraphing:**
| State | Visual |
|-------|--------|
| **Idle** | Static geometry, subtle idle bob |
| **Chase** | Lean toward player, slight forward tilt |
| **Attack Windup** | Enemy geometry flashes in its type-specific color + expands slightly (scale pulse at windup start). **This is the single most important visual cue** — players must be able to read windup starts through dense combat. Per-type windup VFX: Swarm = amber/orange glow burst; Brute = red/crimson flash; Stalker = violet/magenta streak. Color-coded windups make density-combat readable at a glance. |
| **Attack Active** | Attack shape preview rendered semi-transparent (same system as player attack preview, Combat Logic VFX) |
| **Cooldown** | Enemy geometry briefly dims/shrinks |
| **Dead** | Geometry shatters into fragments matching enemy color. Fragments persist briefly then fade. |
**VFX Events (via L1 VFX Spawner):**
| Event | VFX |
|-------|-----|
| `OnEnemyAttackWindup` | Flash + scale pulse at enemy position |
| `OnEnemyStateChanged` (to Chase) | Subtle motion trail activation |
| `OnEnemyDied` | Geometric shatter — fragment count scales with enemy size (Swarm=4, Brute=12, Stalker=8) |
**Audio Events (via L1 Audio Player):**
| Event | Audio |
|-------|-------|
| `OnEnemyAttackWindup` (Swarm) | High-pitched collective chirp |
| `OnEnemyAttackWindup` (Brute) | Deep heavy whoosh + bass |
| `OnEnemyAttackWindup` (Stalker) | Sharp tearing sound |
| `OnEnemyHit` | Impact SFX varying by enemy material (light clink for Swarm, heavy thud for Brute, sharp crack for Stalker) |
| `OnEnemyDied` | Shatter sound — pitch varies by enemy size |
**Audio prioritization at density**: At high enemy counts, simultaneous audio events can exceed the listener's perceptual capacity. L1 Audio Player must enforce:
- **Max simultaneous SFX**: 16 instances per frame (hard cap)
- **Priority order**: Brute windup > Stalker windup > player damage received > Swarm windup > crit hit > enemy death > normal hit
- **Distance falloff**: Audio from enemies within 5 units of player always plays; distant enemies (>15 units) are deprioritized
- **Instance merging**: For 3+ identical enemy events in the same frame (e.g., 10 Swarm deaths from Mist AOE), merge into a single composite sound with volume scaled by count (logarithmic, max 3x base volume)
**Art Bible Alignment**: Principle 1 (Color is Identity — enemy color signals type), Principle 2 (Silhouette is Information — distinct geometric shapes per type for readability at density), Principle 3 (Particles are Feedback — state changes communicated through geometric visual events, not complex animations).
## UI Requirements
Enemy AI Logic itself has minimal UI requirements — most enemy-related UI is owned by UI/HUD:
| Element | Owner | Notes |
|---------|-------|-------|
| Enemy health bars | UI/HUD | Positioned above enemies. Enemy AI provides currentHealth/maxHealth via public state for HUD to read. |
| Attack windup indicator | UI/HUD (or VFX) | Center-screen directional flash when an enemy within 10 units enters attack windup. **Gated**: minimum 0.5s cooldown between flashes — prevents strobe effect at high density. Flash color matches enemy type (amber=Swarm, red=Brute, violet=Stalker) for at-a-glance identification. |
| Enemy type icon | UI/HUD | Optional — small icon above enemy indicating encouraged form. Provisional plan: show on first encounter per enemy type per session, then hide. Definitive behavior deferred to playtest. |
## Acceptance Criteria
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 1 | Swarm enemy (TypeId=1) at position (0,0,0). Player at (8,0,0) — within DetectionRange(12.0). Enemy in Idle state. | `ComputeAction(enemy, player)` called | Enemy transitions to Chase. AICommand type = MoveTo, target = player position. |
| 2 | Swarm enemy in Chase at (1.5,0,0). Player at (0,0,0) — within AttackRange(1.5). | `ComputeAction(enemy, player)` called | Enemy transitions to Attack. AICommand type = Attack, AttackData populated with Circle(r=0.8), BaseDamage=6. |
| 3 | Brute enemy in Attack state. 0.5s elapses since Attack start (Windup complete). | Attack active frame occurs | AttackData produced with Circle(r=1.2), BaseDamage=25. Ready for Combat Logic injection. |
| 4 | Brute enemy in Attack state. Cooldown timer not started. | Attack animation completes | Enemy transitions to Cooldown. Cooldown timer starts (1.2s). |
| 5 | Brute enemy in Cooldown. 1.2s elapsed. Player at (5,0,0) — within DetectionRange(10.0) + 20% margin(12.0). | `ComputeAction(enemy, player)` called | Enemy transitions to Chase. |
| 6 | Enemy in Cooldown. Cooldown expired. Player at (13,0,0) — beyond DetectionRange(10.0) + 20% margin(12.0). | `ComputeAction(enemy, player)` called | Enemy transitions to Idle. AICommand type = Idle. |
| 7 | Enemy Health=20. `DamageResult { FinalDamage=15, KnockbackDistance=2.0, attackDirection=(1,0,0) }`. | `ApplyDamage(result)` called | newHealth=5, IsDead=false. Position shifts +2.0 on X. `OnEnemyHit` fires. `OnEnemyDied` does NOT fire. |
| 8 | Enemy Health=10. `DamageResult { FinalDamage=15, IsKillingBlow=true }`. | `ApplyDamage(result)` called | newHealth=0 (clamped), IsDead=true. `OnEnemyDied` fires with enemyId, enemyType, killerForm. |
| 9 | Enemy IsDead=true. Another `DamageResult` arrives. | `ApplyDamage(result)` called | Call ignored. No state change. No events. Health stays at 0. |
| 10 | deltaTime=0.0. Multiple enemies in various states. | `ComputeAction` loop runs | All enemies skipped. No state transitions, no movement, no timer advances. |
| 11 | Enemy spawned with player at (3,0,0) — within DetectionRange. | Enemy initialized | Enemy immediately enters Chase (skips Idle). Aggro flag set to true. |
| 12 | Brute in Attack Windup (0.3s into 0.5s windup). Player executes successful parry (Combat Logic emits `OnParrySuccess` for this enemy). | `OnParrySuccess` event received | Enemy attack nullified. Enemy enters stagger (0.45s stun). After stagger, force transition to Cooldown. |
| 13a | 200 enemies active, all in various states. deltaTime=0.016. | `ComputeAction` loop runs for all 200 | All 200 enemies processed correctly — each transitions/persists per its state rules. No crashes, no skipped enemies. |
| 13b | [Benchmark only — separate test suite] 200 enemies active. | 1000-frame benchmark run | Average <0.2ms per frame. No per-frame allocation (verified via GC.GetAllocatedBytesForCurrentThread). |
| 14 | Stalker (TypeId=3) in Chase at (6,0,0). Player at (0,0,0) — within AttackRange(6.0). | `ComputeAction(enemy, player)` called | Enemy transitions to Attack with Rect(w=1.5, l=6.0), BaseDamage=18, AttackWindup=0.15s. |
| 15 | Swarm Health=12 (full). Player Mist attack Circle(r=3.8, BaseDamage=8). Swarm at (1,0,0) — within circle. | Combat Logic resolves attack → `ApplyDamage(DamageResult { FinalDamage=8 })` | newHealth=4. Not dead. Second Mist hit needed to kill. |
| 16 | Enemy in Attack state. Player killed by another enemy in same frame. | Enemy's `ComputeAction` called with player.IsDead=true | Enemy transitions to Cooldown (if attacking) or Idle (if in Chase/Cooldown). Chase-state enemies force-transition to Idle on player.IsDead. No further attacks. |
| 17 | Swarm `AttackBaseDamage=6`, non-crit. | DamageResult arrives at Enemy AI | Enemy AI applies exactly 6 damage (no resistance modification). newHealth = currentHealth - 6. |
| 18 | Brute Weight=8.0. `KnockbackDistance=5.0` (from Combat Logic, already weight-factored). | `ApplyDamage(result)` called | Enemy position shifts by exactly 5.0 along attack direction. No additional weight calculation in Enemy AI — Combat Logic's value is authoritative. |
| 19 | Enemy in Chase at position equal to player position (distance=0). deltaTime=0.016. | `ComputeAction(enemy, player)` called | Enemy skips movement (zero direction guard). No NaN position. enemyPos unchanged. |
| 20 | Stalker in Attack, AttackWindup=0.15s. deltaTime=0.10s (frame spike). | `ComputeAction(enemy, player)` with clamped dt | Effective dt = min(0.10, 0.05) = 0.05s. Windup timer advances by 0.05s (not 0.10s). Windup NOT skipped — transitions to active frame on next Update call. |
| 21 | Enemy in Cooldown. Cooldown timer has 0.1s remaining. Player at (5,0,0) — within DetectionRange + margin. deltaTime=0.016. | `ComputeAction(enemy, player)` called | Cooldown timer = 0.084s (0.1 - 0.016). Stays in Cooldown. Does NOT prematurely transition to Chase. |
| 22 | Enemy in Chase state. Player position outside DetectionRange + 20% margin for 3+ consecutive seconds. | Sustained out-of-range condition | Enemy force-transitions to Idle (Chase timeout). Prevents permanent Chase when player is unreachable. |
| 23 | Enemy in Cooldown, player outside DetectionRange + 20% margin, timer expires. | `ComputeAction(enemy, player)` called | Enemy transitions to Idle (NOT Chase). Diagram-corrected transition: out-of-range post-Cooldown = Idle. |
| 24 | Enemy AI produces AttackData for attacking enemy. | AttackData fields verified | Source=Enemy, SourceForm=None, IsParry=false, CritChanceStat=0.0, CritMultiplier=1.0, AttackGroupId=0, InnerCritRadius=0.0. All [固定] fields correctly set. |
| 25 | Enemy in Chase. FacingAngle updated toward player. | `ComputeAction(enemy, player)` called | EnemyState.FacingAngle equals angle from enemy position to player position (in degrees, 0° = +X). Verified within ±1° tolerance. |
| 26 | Enemy spawned at player position (distance=0, player already in DetectionRange). | Enemy initialized | Enemy skips Idle, immediately enters Chase. Aggro flag set to true. No wake-up animation. |
| 27 | Enemy with AttackWindup=0 (degenerate config, logged as warning). | `ComputeAction(enemy, player)` in Attack state | Attack fires immediately on first Attack frame (windup skipped). Cooldown starts after attack active frame. Minimum floor enforced: AttackWindup ≥ 0.01s (one frame at 60fps). |
## Open Questions
- **Wolf vs Stalker clash** (resolved): Wolf Rect intersecting Stalker rush Rect triggers a special "clash" outcome — Stalker attack nullified, Stalker staggered for 0.45s, Wolf takes no damage from that attack. Implemented in Combat Logic as a detected Rect-Rect intersection in the parry phase.
- **Swarm per-frame damage cap** (resolved): Provisional cap of 50 damage/frame from Swarm hits. Prevents instant death from 20+ simultaneous Swarm attacks. Prototype validation needed.
- **Parry stagger (0.45s)** (resolved): Synced with Combat Logic GDD. Prototype validation needed to confirm riposte timing feel.
- Enemy-enemy collision: Deferred to vertical slice. Reserve a config flag (`hasCollision`) in EnemyTypeConfig for future minimum-separation repulsion.
- Detection range visualization: Should players see enemy detection ranges (e.g., debug overlay, or subtle ground ring)? Related to "Battlefield is Information" pillar.
- Attack pattern variety: Each type currently has exactly one attack pattern — **known MVP scope limitation** (fought ~6 times across a 7-wave level). Post-MVP multi-pattern expansion planned for vertical slice. Should multiple patterns per type be reserved for Boss AI Logic only?
- Should enemy attacks knock back the player? Currently undefined — enemy attacks deal damage but player knockback from enemy hits is not specified.
- Stalker Rect orientation: ADR-003 Rect is center-based. Is the Stalker's Rect(l=6.0) centered on the enemy position (extends 3.0 forward + 3.0 backward), or placed entirely forward from the origin? If centered, effective forward range is ~3.0, not 6.0. This needs clarification before Stalker implementation.
- Blood Energy on player death + wave restart: Player respawns with 0 energy but wave restarts with full enemy composition. Is the initial 0-energy state safe, or should the player receive a small starting energy grant on respawn? Defer to Death/Respawn Rules GDD.

View File

@ -52,11 +52,11 @@ Idle →(RequestSwitch)→ Windup(0.25s) →(timer)→ Switching(0.10s) →(time
| Form | AttackStyle | Consumer |
|------|------------|----------|
| Human | `{Shape: Sector(100°,3.0), BaseDamage:18, Range:3.0, AttackSpeed:0.4s, MaxTargets:1}` | Combat Logic |
| Wolf | `{Shape: Rect(1.0,5.0), BaseDamage:22, Range:5.0, AttackSpeed:0.6s, MaxTargets:3}` | Combat Logic |
| Mist | `{Shape: Circle(3.8), BaseDamage:8, Range:3.8, AttackSpeed:0.8s, MaxTargets:20}` | Combat Logic |
| Human | `{Shape: Sector(100°,3.0), BaseDamage:18, CritChanceStat:0.05, CritMultiplier:1.5, KnockbackForce:5, AttackSpeed:0.4s, MaxTargets:1}` | Combat Logic |
| Wolf | `{Shape: Rect(1.0,5.0), BaseDamage:22, CritChanceStat:0.05, CritMultiplier:1.5, KnockbackForce:8, AttackSpeed:0.6s, MaxTargets:3}` | Combat Logic |
| Mist | `{Shape: Circle(3.8), BaseDamage:8, CritChanceStat:0.05, CritMultiplier:1.5, KnockbackForce:3, AttackSpeed:0.8s, MaxTargets:20}` | Combat Logic |
> **Canonical source**: BaseDamage and shape geometry values are owned by Combat Logic GDD (reviewed). Form Switch SM defines the AttackStyle struct and queries Combat Logic's per-form values at runtime. If values diverge, Combat Logic is authoritative.
> **Canonical source**: All AttackStyle values are owned by Combat Logic GDD (Approved). Form Switch SM stores and provides these values at runtime via `GetAttackStyle(CurrentForm)`. If values diverge, Combat Logic is authoritative. `CritChanceStat`, `CritMultiplier`, and `KnockbackForce` were added per Combat Logic GDD contract note (revised 2026-04-27). `Range` removed — attack range is derived from HitShape (Circle.Radius for Mist, Rect.Length for Wolf, Sector.Radius for Human).
### Interactions with Other Systems
@ -195,9 +195,9 @@ Combat system — Visual/Audio is REQUIRED.
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 14 | CurrentForm=Human | GetAttackStyle() | Returns Shape=Sector(100°,3.0), BaseDamage=18, AttackSpeed=0.4s, MaxTargets=1 |
| 15 | CurrentForm=Wolf | GetAttackStyle() | Returns Shape=Rect(1.0,5.0), BaseDamage=22, AttackSpeed=0.6s, MaxTargets=3 |
| 16 | CurrentForm=Mist | GetAttackStyle() | Returns Shape=Circle(3.8), BaseDamage=8, AttackSpeed=0.8s, MaxTargets=20 |
| 14 | CurrentForm=Human | GetAttackStyle() | Returns Shape=Sector(100°,3.0), BaseDamage=18, CritChanceStat=0.05, CritMultiplier=1.5, KnockbackForce=5, AttackSpeed=0.4s, MaxTargets=1 |
| 15 | CurrentForm=Wolf | GetAttackStyle() | Returns Shape=Rect(1.0,5.0), BaseDamage=22, CritChanceStat=0.05, CritMultiplier=1.5, KnockbackForce=8, AttackSpeed=0.6s, MaxTargets=3 |
| 16 | CurrentForm=Mist | GetAttackStyle() | Returns Shape=Circle(3.8), BaseDamage=8, CritChanceStat=0.05, CritMultiplier=1.5, KnockbackForce=3, AttackSpeed=0.8s, MaxTargets=20 |
### Events [L0]

View File

@ -6,4 +6,13 @@ Scope signal: M
Specialists: game-designer (critic), systems-designer (analyst), qa-lead (test-engineer), unity-specialist (architect), performance-analyst (general-purpose)
Blocking items: 13 | Recommended: 9
Summary: The hit-detection infrastructure (shapes, pipeline, edge cases, events) was solid, but three fundamental gaps blocked implementation: (1) the Player Fantasy described a parry mechanic that didn't exist in the design, (2) pure RNG crit contradicted the "Skill Over Stats" pillar, and (3) cross-system contracts (EnemyState, Vector3 boundary, AttackData construction, Blood Energy bridge) were undefined. All 13 blockers were resolved in the same session via targeted additions: full parry subsystem, positional crit system, defined structs, L0/L1 boundary contracts, spatial grid, and NonAlloc memory pattern. Form dimensions rebalanced (Mist 3.8, Wolf width 1.0, Human angle 100°). GDD now ready for implementation pending a lightweight re-review pass.
Summary: The hit-detection infrastructure (shapes, pipeline, edge cases, events) was solid, but three fundamental gaps blocked implementation: (1) the Player Fantasy described a parry mechanic that didn't exist in the design, (2) pure RNG crit contradicted the "Skill Over Stats" pillar, and (3) cross-system contracts (EnemyState, Vector3 boundary, AttackData construction, Blood Energy bridge) were undefined. All 13 blockers were resolved in the same session via targeted additions: full parry subsystem, positional crit system, defined structs, L0/L1 boundary contracts, spatial grid, and NonAlloc memory pattern. Form dimensions rebalanced (Mist 3.8, Wolf width 1.0, Human angle 100°).
## Review — 2026-04-27 — Verdict: APPROVED (revised same session after re-review)
Scope signal: L (multi-system integration, 3+ formulas, requires CombatCoordinator ADR)
Specialists: critic (game design), analyst (systems/math), qa-tester (AC review), general-purpose (feel/UX/audio), general-purpose (performance/data contracts)
Blocking items: 6 (all resolved) | Recommended: 14 (all resolved)
Summary: Six blocking data contract issues and fourteen major design gaps were identified across five specialist reviews. Critical architectural findings: (1) AttackData didn't distinguish player from enemy attacks — resolved by adding AttackSource enum, AttackGroupId, PlayerState struct, and InnerCritRadius; (2) parry nullification was impossible in the single-pass batch pipeline — resolved by restructuring to a two-phase pipeline (parry phase → damage phase) with a new CombatCoordinator L0 component; (3) parry timing management violated ADR-001 L0/L1 boundary — resolved by moving parry state into Combat Logic with ActivateParry(); (4) EnemyState ownership and FacingAngle pipeline were undefined — resolved as shared data contract with Enemy AI as data source; (5) parry input model was ambiguous (hold vs press) — resolved to press-for-window model; (6) multihit parry behavior was unspecified — resolved to one-parry-counters-all with single riposte. Additional fixes: parry stagger extended from 0.3s to 0.45s (exceeds Human 0.4s attack windup), Mist inner-radius crit zone gets a visible inner ring, combined clamping clarified as clamp(flanking+stat, 0, 1.0), 13 new ACs added (total: 33), spatial grid cell size added as tuning knob, AttackStyle contract extended with CritChanceStat/CritMultiplier/KnockbackForce defaults, CombatCoordinator dependency documented. GDD is now implementation-ready with all data contracts, pipeline phases, and edge cases fully specified.
Prior verdict resolved: Yes (6 blockers from re-review resolved in same session)

View File

@ -0,0 +1,18 @@
# Enemy AI Logic — Review Log
## Review — 2026-04-27 — Verdict: NEEDS REVISION (revised same session)
Scope signal: M
Specialists: critic (game design), analyst (systems/math), qa-tester (AC review), general-purpose (AI behavior + performance), general-purpose (feel/UX/audio)
Blocking items: 6 (all resolved) | Recommended: 12 (all resolved)
Summary: Six blocking issues identified across five specialist reviews: (1) parry stagger duration mismatch with Combat Logic (0.3s vs 0.45s) — synced to 0.45s; (2) no Chase→Idle FSM transition for player death — added force-to-Idle and Chase timeout; (3) Vector3.Normalize(0) NaN risk — added zero-distance guard; (4) no deltaTime spike protection — added MAX_DELTA=0.05s clamp; (5) windup flash becomes strobe at density — added 0.5s cooldown, 10-unit range gate, per-type color coding; (6) no audio prioritization system — added 16-SFX cap, priority order, distance falloff, instance merging. Additional major fixes: AICommand AttackData field defined, EnemyState population pipeline clarified, Swarm per-frame damage cap (50), kiting mitigation documented, IPlayerStateProvider unified to parameter-passing, enemy AttackData defaults table added, FSM diagram corrected, windup VFX differentiated per-type (amber/red/violet), Wolf-Stalker clash defined as special outcome (Stalker nullified + staggered), Stalker Mist counter claim adjusted to "optimal vs viable", AC 13 split into functional + benchmark, 9 new ACs added (19-27). GDD now aligned with revised Combat Logic GDD.
Prior verdict resolved: N/A (first review)
## Review — 2026-04-27 — Verdict: APPROVED (re-review after same-session revision)
Scope signal: M
Re-review: Yes — all 6 prior blockers verified resolved
Blocking items: 0 | Recommended: 2 (minor — stale Interactions/Dependencies table references)
Summary: Re-review confirmed all six blocking issues and twelve major items from the first review were correctly resolved. Cross-system consistency verified via /consistency-check with upstream Combat Logic, Form Switch SM, and Death/Respawn GDDs — all contracts aligned (PlayerState merged, SpawnEnemy defined, AttackStyle extended, ResolveFrame referenced). Two minor documentation cleanup items remain (Interactions table still says "ResolveAttacks" vs "ResolveFrame"; Dependencies table still says "IPlayerStateProvider" vs parameter-passing). GDD is implementation-ready.

View File

@ -19,12 +19,12 @@
| # | System Name | Layer | Category | Priority | Status | Design Doc | Depends On |
|---|-------------|-------|----------|----------|--------|------------|------------|
| 1 | Blood Energy Economy | L0 | Combat Logic | MVP | Approved | [GDD](blood-energy-economy.md) | — |
| 2 | Combat Logic | L0 | Combat Logic | MVP | Reviewed | [GDD](combat-logic.md) | — |
| 2 | Combat Logic | L0 | Combat Logic | MVP | Approved | [GDD](combat-logic.md) | — |
| 3 | Form Switch State Machine | L0 | Combat Logic | MVP | Approved | [GDD](form-switch-state-machine.md) | Blood Energy Economy |
| 4 | Enemy AI Logic | L0 | Combat Logic | MVP | Not Started | — | Combat Logic |
| 4 | Enemy AI Logic | L0 | Combat Logic | MVP | Approved | [GDD](enemy-ai-logic.md) | Combat Logic |
| 5 | Boss AI Logic | L0 | Combat Logic | MVP | Not Started | — | Enemy AI Logic, Combat Logic |
| 6 | Wave Manager Logic | L0 | Combat Logic | MVP | Not Started | — | Enemy AI Logic |
| 7 | Death/Respawn Rules | L0 | Player | MVP | Not Started | — | Combat Logic |
| 6 | Wave Manager Logic | L0 | Combat Logic | MVP | Designed | [GDD](wave-manager-logic.md) | Enemy AI Logic |
| 7 | Death/Respawn Rules | L0 | Player | MVP | Designed | [GDD](death-respawn-rules.md) | Combat Logic |
| 8 | Skill Tree Data & Rules | L0 | Progression | VS | Not Started | — | Form Switch State Machine |
| 9 | Save/Load Logic | L0 | Persistence | VS | Not Started | — | Skill Tree Data & Rules |
| 10 | Score Calculator | L0 | Meta | Full | Not Started | — | Combat Logic, Wave Manager Logic |
@ -144,10 +144,10 @@
| Metric | Count |
|--------|-------|
| Total systems identified | 17 |
| Design docs started | 3 |
| Design docs started | 6 |
| Design docs reviewed | 2 |
| Design docs approved | 2 |
| MVP systems designed | 3/14 |
| MVP systems designed | 6/14 |
| Vertical Slice systems designed | 0/3 |
---
@ -158,6 +158,7 @@
- [x] Run `/design-system Blood Energy Economy` — Complete (Approved)
- [x] Run `/design-system Combat Logic` — Complete (Reviewed, revised)
- [x] Run `/design-system Form Switch State Machine` — Complete (Approved, revised after review)
- [x] Run `/design-system Enemy AI Logic` — Complete (Designed, pending review)
- [ ] Run `/prototype form-switch-state-machine` on the highest-risk system early
- [ ] Run `/design-review` on remaining GDDs
- [ ] Run `/gate-check pre-production` when all MVP GDDs are authored and reviewed

View File

@ -0,0 +1,273 @@
# Wave Manager Logic
> **Status**: In Design
> **Author**: SepComet + Claude
> **Last Updated**: 2026-04-27
> **Implements Pillar**: 战场即信息 (The Field is Information), 形态即战术 (Form is Tactics)
## Overview
Wave Manager Logic 是夜裔的遭遇战编排系统——控制每一波敌人的生成时机、类型构成、数量和波次推进节奏。它不是一个简单的"定时刷怪"计时器,而是战斗节奏的作曲家:它决定什么时候让玩家感到压力(混合编制波次)、什么时候给玩家喘息(波次间 10-20s 间隔)、以及什么时候考验玩家的形态切换决策(同一波内投放多种需要不同形态克制的敌人)。
玩家直接感受 Wave Manager 的输出——但不是通过 UI 数字,而是通过**战场节奏**。一波 Swarm 海从四面八方涌来就是"切雾形"的信号。一波 Brute+Stalker 混合编制就是在问玩家:"你先处理哪个Brute 的弹反窗口还是 Stalker 的冲刺?"波次清空后的短暂沉默不是空白——那是玩家重新评估血能、调整位置、预判下一波构成的战略时刻。Wave Manager Logic 是"战场即信息"和"形态即战术"的编排引擎——它通过敌人构成设计来创造有意义的形态切换压力,而不是靠随机数。
## Player Fantasy
Wave Manager Logic 服务于一个任何动作游戏玩家都熟悉但很少被命名的幻想:**掌控节奏**。当你清空一波敌人、战场突然安静的那 10 秒钟,你的心跳从"战斗模式"的高频切回战术思考——"我还有多少血能?下一波会是什么?我该站在哪里?"——这就是 Wave Manager 在发挥作用。它不是制造敌人,而是制造**期待**。
好的波次编排像一场 DJ 演出它知道什么时候给你密集的低音Swarm 海、什么时候切入一个重拍让你展示技巧Brute 弹反)、什么时候让所有声音同时响起制造混乱(混合编制)、以及什么时候全部静默给你喘息(波次间隔)。玩家不会说"这波次设计得真好"——他们会说"再来一波,我能打"。
参考 Vampire Survivors 的波次节奏——敌人密度波浪式增长,偶尔给一个"安全窗口"让你有空间操作——和 Hades 的遭遇战房间——每个房间是一场独立的微型战斗拼图。夜裔的 Wave Manager 将两者融合:**波浪式密度 + 拼图式构成**。每一波不只是"更多敌人",而是"不同的问题"。第一波是热身(纯 Swarm教你切雾形第三波是期中考试Brute + Swarm 混合,考验你什么时候放弃 AOE 去做弹反),最后一波是终极考验(三种类型同时在场,要求完整的形态切换循环)。
## Detailed Design
### Core Rules
**1. Wave Lifecycle**
```
InterWave ──(delay elapsed)──→ WaveActive ──(all enemies dead)──→ InterWave
↑ │
└──────────────────── (next wave exists) ────────────────────────────┘
└── (no more waves) → LevelComplete
```
**2. Spawning Rules**
- 敌人从竞技场边界perimeter生成以竞技场中心为圆心、固定半径 R 的圆周上随机取点
- 生成时敌人面向竞技场中心(朝向玩家方向)
- 同一 `SpawnGroup` 内的敌人同时生成(同一帧),不同 SpawnGroup 之间有 `SpawnDelay` 间隔
- 生成后立即进入 Enemy AI 的 Idle 状态(玩家进入 DetectionRange 时进入 Chase
**3. Wave Tracking**
- Wave Manager 订阅 `EnemyAI.OnEnemyDied(enemyId, enemyType, killerForm)` 事件
- 维护 `aliveEnemyCount` — 当前波次中存活敌人数量
- 当 `aliveEnemyCount == 0` 且所有 SpawnGroup 已生成完毕 → 波次清空
- 仅追踪由当前波次生成的敌人。不在波次中的敌人不计入
**4. Wave Advancement**
- 波次清空后立即进入 InterWave 状态,启动 `InterWaveDelay` 计时器10-20s由 LevelData 配置)
- 计时器结束 → 自动推进到下一波
- 无需玩家交互 — 节奏完全由设计者控制
- 如果是最后一波 → 发射 `OnLevelComplete`,不再进入 InterWave
**5. Level Completion**
- 最后一波的所有敌人死亡 → `OnLevelComplete` 发射
- 此时所有敌人已清除,关卡结束
- 后续流程(结算、菜单、下一关)由 L1/L2 处理
### Wave Structure
**WaveData 定义:**
```
WaveData {
int WaveIndex; // 0-based wave number
string WaveName; // Designer label (e.g. "Swarm Intro")
List<WaveSpawnGroup> SpawnGroups; // Groups spawned this wave
float PreWaveDelay; // Additional delay before first spawn (default 0)
bool IsFinalWave; // Marks level completion
}
```
**WaveSpawnGroup 定义:**
```
WaveSpawnGroup {
EnemyType Type; // Swarm(1) / Brute(2) / Stalker(3)
int Count; // How many enemies of this type
float SpawnDelay; // Seconds after wave start to spawn this group
SpawnPattern Pattern; // Perimeter / Clustered / Opposite / Random
}
```
**SpawnPattern 枚举:**
| Pattern | Behavior | When to Use |
|---------|----------|-------------|
| `Perimeter` | 随机分布在竞技场边界圆周上 | 标准包围感,适合 Swarm |
| `Clustered` | 集中在圆周的一个 60° 弧段内 | 紧凑编队,适合 Brute 群 |
| `Opposite` | 分布在两个相对的 60° 弧段 | 夹击压力,适合 Stalker 编队 |
| `Random` | 完全随机分布在整个边界上 | 混合编制默认 |
### MVP Level: "First Night" 波次设计
7 波,教学曲线从单一类型引入到全类型混合:
| Wave | Name | Composition | Design Intent |
|------|------|-------------|---------------|
| 1 | Awakening | 8× Swarm (Perimeter) | 雾形教学 — 圆形 AOE 清屏。玩家学会"看到很多小敌人→切雾形"。 |
| 2 | The Pack | 12× Swarm (Perimeter, 2 groups staggered 1s) | 雾形巩固 — 更多数量,分两批生成创造持续压力。 |
| 3 | Heavy | 2× Brute (Clustered) | 弹反教学 — 两个 Brute 先后攻击,玩家学会"看到大块头→切人形弹反"。SpawnDelay=2s 确保不会同时攻击。 |
| 4 | Swarm + Brute | 8× Swarm (Perimeter) + 2× Brute (Opposite) | 首次混合 — 玩家被迫在 AOE 清小怪和弹反大怪之间切换。Swarm 先刷0sBrute 延迟 3s 刷。 |
| 5 | Hunters | 3× Stalker (Opposite, 2 groups staggered 0.5s) | 冲刺教学 — 首次引入 Stalker玩家学会"看到高速冲刺→切狼形对撞"。 |
| 6 | Full House | 6× Swarm (Perimeter) + 1× Brute (Clustered) + 2× Stalker (Opposite) | 全类型混合 — 三种形态循环的完整考验。生成顺序Swarm(0s) → Stalker(2s) → Brute(4s)。 |
| 7 | Final Stand | 10× Swarm (Perimeter) + 3× Brute (Random) + 3× Stalker (Random) | 最终波 — 高密度全类型。所有 SpawnGroup 同时生成SpawnDelay=0制造最大混乱和最大切换压力。 |
**设计原则**
- 每种敌人类型的前两次出现都是"纯编制"(只有该类型),给予玩家学习空间
- 混合编制在玩家掌握基础后引入Wave 4+
- 生成顺序创造"先处理A再处理B"的引导:先刷的敌人先到达玩家
- SpawnDelay 控制敌人到达玩家的时间差,防止所有敌人同时压上
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|-----------|
| **Enemy AI Logic** | Downstream | 生成敌人实例并注入 `EnemyTypeConfig`。调用 Enemy AI 的 `SpawnEnemy(typeConfig, position, facingAngle)` 工厂方法。 |
| **Enemy AI Logic** | Upstream | 订阅 `OnEnemyDied(enemyId, enemyType, killerForm)` — 追踪存活敌人数量,判断波次是否结束。 |
| **Combat Logic** | — | 无直接依赖。Wave Manager 不参与伤害判定。通过 Enemy AI 间接关联。 |
| **UI/HUD (L2)** | Downstream | 发射 `OnWaveChanged(waveIndex, waveName)` — HUD 显示当前波次信息。发射 `OnInterWaveStarted(duration)` — HUD 显示倒计时或"准备"提示。 |
| **VFX Spawner (L1)** | Downstream | 发射 `OnWaveStarted`(波次开始 VFX、`OnWaveCleared`(波次清空 VFX、`OnEnemySpawned`(敌人出现 VFX |
| **Audio Player (L1)** | Downstream | 发射 `OnWaveStarted`(波次音效)、`OnInterWaveStarted`(喘气/心跳音效) |
| **Level Loader (L1)** | Upstream | Level Loader 加载关卡时提供 `LevelData`(包含所有 WaveData。可选提供竞技场边界参数中心点、半径用于生成位置计算。 |
| **Score Calculator** | Downstream | 发射 `OnWaveCleared` + 波次数据 — Score Calculator 可根据波次清空速度计算评分。 |
## Formulas
### Spawn Position Calculation
The spawn position formula is defined as:
`spawnPos = arenaCenter + (cos(angle), 0, sin(angle)) × arenaRadius`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Arena center | `arenaCenter` | Vector3 | any | Arena center point (from Level Loader) |
| Arena radius | `arenaRadius` | float | 2040 | Spawn perimeter radius (LevelData config) |
| Angle | `angle` | float | 0360° | Random angle for perimeter spawns. Clustered: 60° range from base angle. Opposite: two 60° ranges 180° apart. |
**Output Range**: Any point on the perimeter circle at `arenaRadius` from center.
For `Clustered` pattern: `angle = baseAngle + random(-30°, +30°)` where `baseAngle` is randomized per wave.
For `Opposite` pattern: half at `baseAngle ± 30°`, half at `baseAngle + 180° ± 30°`.
For `Random` pattern: `angle = random(0, 360°)`.
---
### Alive Enemy Count
The wave tracking formula is defined as:
`aliveCount = spawnedThisWave - killedThisWave`
**Variables:**
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Spawned this wave | `spawnedThisWave` | int | 0totalWaveEnemies | Cumulative count of enemies spawned in current wave |
| Killed this wave | `killedThisWave` | int | 0spawnedThisWave | Incremented on each `OnEnemyDied` for enemies belonging to this wave |
**Output**: 0 to totalWaveEnemies. Wave clears when `aliveCount == 0 && allSpawnGroupsCompleted`.
---
### Wave Completion
The wave completion formula is defined as:
`isWaveCleared = (aliveEnemyCount == 0) AND (allSpawnGroups have been spawned)`
**Output**: Boolean. Once true, triggers transition to InterWave or LevelComplete.
## Edge Cases
- **If all enemies in a wave are killed before all SpawnGroups have spawned**: Wave remains active. `isWaveCleared` requires both conditions — all enemies dead AND all groups spawned. Remaining SpawnGroups continue spawning on schedule.
- **If player dies during WaveActive**: Wave state freezes. Death/Respawn Rules manages respawn. On respawn, Wave Manager resets the current wave to its initial state (all enemies despawned, wave restarts from InterWave → WaveActive).
- **If deltaTime = 0 (game paused)**: All Wave Manager timers pause (InterWaveDelay, SpawnDelay). No spawning occurs. No state transitions.
- **If a wave has 0 enemies (all SpawnGroups have Count=0)**: Wave immediately completes (aliveCount=0, all groups "spawned"). Transition directly to InterWave. Logged as a warning — likely a data error.
- **If a wave has only one SpawnGroup**: SpawnDelay is irrelevant — all enemies spawn simultaneously at wave start. No timer needed between groups.
- **If `OnEnemyDied` fires for an enemy not spawned by this wave**: Ignore the event. Wave Manager maintains a set of tracked enemy IDs. Unknown IDs are silently skipped.
- **If arenaRadius <= 0 (invalid LevelData)**: Log error. Use fallback radius = 20.0. Spawning continues with fallback.
- **If LevelData has an empty Waves list**: Log error, emit `OnLevelComplete` immediately. No enemies, no gameplay — treat as degenerate level.
- **If `InterWaveDelay = 0`**: Wave advancement is instant — no breather between waves. Valid but not recommended for MVP. Minimum enforced by design: 5s (clamped at runtime).
- **If `PreWaveDelay + SpawnDelay` exceeds practical limit (>30s)**: Log warning. Enemies not spawning for too long creates dead air. Designer should review timing.
- **If spawned position would place enemy inside arena geometry (e.g., pillar)**: MVP does not handle this — arenas are assumed to be clear circles. Enemy collision with geometry deferred to vertical slice.
- **If `OnLevelComplete` fires but game state is already transitioning**: Guard with `_levelCompleteFired` flag. Emit event exactly once. Duplicate calls are ignored.
## Dependencies
| System | Layer | Type | Hard/Soft | Interface |
|--------|-------|------|-----------|-----------|
| **Enemy AI Logic** | L0 | Upstream/Downstream | Hard | Downstream: calls `SpawnEnemy(typeConfig, position, facingAngle)` to create enemies. Upstream: subscribes to `OnEnemyDied(enemyId, enemyType, killerForm)` to track alive count. Cannot function without Enemy AI. |
| **Level Loader (L1)** | L1 | Upstream | Hard | Provides `LevelData` (wave list, arena params, InterWaveDelay) on level start. Without it, Wave Manager has nothing to manage. |
| **UI/HUD (L2)** | L2 | Downstream | Soft | Emits `OnWaveChanged`, `OnInterWaveStarted` for HUD display. Gameplay works without HUD. |
| **VFX Spawner (L1)** | L1 | Downstream | Soft | Emits `OnWaveStarted`, `OnWaveCleared`, `OnEnemySpawned` for visual feedback. |
| **Audio Player (L1)** | L1 | Downstream | Soft | Emits `OnWaveStarted`, `OnInterWaveStarted` for audio cues. |
| **Score Calculator** | L0 | Downstream | Soft | Emits `OnWaveCleared` with wave index and clear time for scoring. |
| **Death/Respawn Rules** | L0 | Upstream | Soft | Receives `OnPlayerDied` to freeze/reset wave state. Soft because wave system could function without death handling (just loses state on death). |
**Hard dependencies** (system cannot function without): Enemy AI Logic, Level Loader.
**Soft dependencies** (enhanced by but works without): UI/HUD, VFX Spawner, Audio Player, Score Calculator, Death/Respawn Rules.
## Tuning Knobs
| Parameter | Default | Safe Range | Too Low | Too High |
|-----------|---------|------------|---------|----------|
| `InterWaveDelay` | 15s | 825s | No time to reassess — waves blur together | Dead air — player gets bored waiting |
| `ArenaRadius` | 30.0 | 2040 | Enemies spawn on top of player — no reaction time | Enemies take too long to reach player — no pressure |
| `PreWaveDelay` (per wave) | 0s | 03s | — | Delays wave start, creates dead air before fights |
| `SpawnDelay` (between groups) | 2s | 0.55s | All enemies arrive simultaneously — no staggered pressure | Groups arrive so far apart wave loses cohesion |
| **Swarm count per wave** | 812 | 420 | Not enough to justify AOE form switch | Frame rate and readability suffer |
| **Brute count per wave** | 13 | 15 | Brute not threatening — can be ignored | Multiple simultaneous heavy attacks unreadable |
| **Stalker count per wave** | 23 | 15 | Stalker not creating urgency | Stalker rush overlaps unreactable |
| **Total enemies per wave** | 816 | 430 | Wave feels empty — no density | 200+ total active enemies — performance risk |
**Key tuning groups**:
- **Pacing rhythm**: `InterWaveDelay` + `PreWaveDelay` + `SpawnDelay` — the sum of delays determines the feel of "combat density vs breathing room"
- **Form pressure balance**: Swarm count vs Brute count vs Stalker count per wave — if any type dominates, the wave only tests one form
- **Performance ceiling**: Total enemies alive simultaneously — Combat Logic budget is 200 enemies at <1ms. Total active across all waves must stay under this limit. At default counts (Wave 7: 16 total), performance is not a concern. Future tuning should profile before increasing.
## Visual/Audio Requirements
Combat is a visual system category — Visual/Audio is REQUIRED.
| Event | VFX | Audio |
|-------|-----|-------|
| `OnWaveStarted` | Arena perimeter pulse — brief geometric ring flash at spawn boundary. Color: white → fade. | Rising tone / alarm — pitch increases with wave number |
| `OnWaveCleared` | Brief screen flash + all remaining enemy fragments dissolve simultaneously | Bass drop + short silence before InterWave heartbeat |
| `OnEnemySpawned` | Small geometric "materialize" effect at spawn point — shape matches enemy type | Subtle whoosh — pitch varies by enemy size |
| `OnInterWaveStarted` | Screen edges dim slightly (vignette increase) — signals "safe moment" | Heartbeat / breathing SFX — tempo slows as InterWaveDelay nears end |
| `OnLevelComplete` | Full screen geometric pattern (like shattered glass reforming) — gold/white | Victory fanfare — short, geometric, not orchestral |
## UI Requirements
| Element | Content | Trigger |
|---------|---------|---------|
| Wave Banner | "Wave [N]: [WaveName]" — appears at wave start, fades after 2s | `OnWaveChanged` |
| InterWave Countdown | "[N]s until next wave" — centered, subtle | `OnInterWaveStarted(duration)` |
| Enemy Remaining Counter | "[N] enemies remaining" — small HUD element | Alive count update each time `OnEnemyDied` fires |
| Wave Clear Text | "Cleared!" — brief centered text | `OnWaveCleared` |
## Acceptance Criteria
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 1 | LevelData with 7 waves loaded. Wave 1 has 8× Swarm (Perimeter). InterWaveDelay=15s. | Level starts | Wave 1 spawns 8 Swarm at arena perimeter. OnWaveChanged fires with waveIndex=0. |
| 2 | Wave 1 active. 8 enemies alive. | Enemy AI emits OnEnemyDied for one Swarm | aliveCount=7. Wave remains active. |
| 3 | Wave 1 active. 8 enemies alive. All 8 OnEnemyDied events fire. | Last enemy dies | Wave clears. OnWaveCleared fires. Transition to InterWave. InterWaveDelay timer starts. |
| 4 | InterWave state. 15s elapsed. Next wave exists (Wave 2). | Timer expires | Transition to WaveActive. Wave 2 begins spawning. OnWaveChanged fires with waveIndex=1. |
| 5 | Wave 7 (IsFinalWave=true) active. All enemies dead. | Last enemy dies | OnWaveCleared fires. OnLevelComplete fires. No InterWave transition. |
| 6 | Wave with 2 SpawnGroups: Group1(Delay=0s), Group2(Delay=3s). Wave starts. | Wave becomes active | Group1 spawns immediately. 3s later, Group2 spawns. Both before any died events. |
| 7 | SpawnPattern=Clustered, baseAngle=90°. Count=4. ArenaRadius=30. ArenaCenter=(0,0,0). | Spawn executed | 4 enemies spawned within 60° arc centered on 90° (±30°). All at distance 30 from center. |
| 8 | deltaTime=0.0. InterWave state with 10s remaining on timer. | Update called | Timer does not advance. Still 10s remaining. |
| 9 | Player dies during WaveActive (Wave 3). Death/Respawn emits OnPlayerRespawned. | OnPlayerRespawned received | Wave 3 resets: all enemies despawned, wave restarts from InterWave → re-spawn all Wave 3 enemies. |
| 10 | OnEnemyDied fires for enemy ID not in tracked set (not spawned by this wave). | Event received | Ignored. aliveCount unchanged. |
| 11 | LevelData.Waves is empty list. | Level starts | Log error. OnLevelComplete fires immediately. |
| 12 | Wave 3 "Heavy": 2× Brute (Clustered, SpawnDelay=2s between them). | Wave becomes active | First Brute spawns at 0s. Second Brute spawns at 2s. Two separate spawn events. |
## Open Questions
- Player death wave reset: Should the player restart the entire level, the current wave, or from a checkpoint (e.g., every 3 waves)? Defer to Death/Respawn Rules GDD.
- Wave difficulty scaling: Should enemy stats increase per wave (health/damage scaling) or is composition change alone sufficient for difficulty? MVP assumes composition-only scaling.
- Boss wave integration: How does Boss AI Logic GDD's boss encounter integrate into the wave system? Is the boss a special "Wave 8" or a separate encounter after Wave 7?
- Wave order randomization: Should the 7-wave structure be fixed (same every playthrough) or allow designer to tag waves as "random pool" for replayability? MVP uses fixed order.
- Spawn visual transition: Should enemies "phase in" with a brief materialize animation, or pop into existence? Enemy AI GDD says Idle state has no wake-up animation — spawning FX is a Wave Manager concern.