This commit is contained in:
SepComet 2026-04-27 17:26:10 +08:00
parent c565b6bab6
commit 5c24481b88
5 changed files with 505 additions and 82 deletions

View File

@ -1,17 +1,19 @@
# Combat Logic
> **Status**: In Design
> **Status**: In Review
> **Author**: SepComet + Claude
> **Last Updated**: 2026-04-27
> **Implements Pillar**: 操作优于数值 (Skill Over Stats), 战场即信息 (Battlefield is Information)
> **Implements Pillar**: 操作优于数值 (Skill Over Stats), 战场即信息 (Battlefield is Information), 形态即战术 (Form is Tactics), 切换即承诺 (Switch is Commitment)
## Overview
Combat Logic 是夜裔的即时战斗判定核心——负责"打中了没有""伤害是多少""是否暴击"。它接收攻击数据(形状、伤害值、来源形态),对目标进行几何相交检测,产出 DamageResult。系统完全纯C#,使用 ADR-003 定义的 Circle/Rect/Sector 进行形状判定。它不产生任何视觉或音频效果——而是通过 C# event 将结果传递给 L1/L2。设计原则**判定必须确定、反馈必须清晰、计算必须快速**(每帧数百次判定在 1ms 内完成)。
Combat Logic 是夜裔的即时战斗判定核心——负责"打中了没有""伤害是多少""是否暴击"。它接收攻击数据(形状、伤害值、来源形态),对目标进行几何相交检测,产出 DamageResult。系统完全纯C#,使用 ADR-003 定义的 Circle/Rect/Sector 进行形状判定。它不产生任何视觉或音频效果——而是通过 C# event 将结果传递给 L1/L2。设计原则**判定必须确定、反馈必须清晰、计算必须快速**(每帧数百次判定,目标 <1ms含空间分割优化
Combat Logic 实现了所有四个游戏支柱位置暴击Skill Over Stats、攻击形状可视化Battlefield is Information、不同形态不同攻击风格Form is Tactics、人形弹反的时机窗口Switch is Commitment
## Player Fantasy
夜裔的战斗幻想不是"数字在涨",而是**每一击都有形状、有重量、有来由**。当你用人形精准弹反——扇形判定区恰好覆盖精英的攻击前摇——一个金色暴击数字弹出,你感觉自己不是"按了攻击键",而是用几何体"接住了"对方的攻击。当你切到狼躯冲刺——矩形判定贯穿一排敌人——敌人被击退的轨迹本身就是你所画的一条线。当雾形圆形AOE笼罩整个屏幕的杂兵那种"我画了一个圈,圈里全死"的掌控感,就是 Combat Logic 要传达的幻想。参考 RE4 弹反的"接住"反馈 + DMC 的判定可视化 + 割草的密度感。
夜裔的战斗幻想不是"数字在涨",而是**每一击都有形状、有重量、有来由**。当你用人形精准弹反——在敌人攻击前摇的 0.15s 窗口内启动扇形招架——扇形判定区恰好覆盖精英的攻击前摇,一个金色暴击数字弹出,你感觉自己不是"按了攻击键",而是用几何体"接住了"对方的攻击。当你切到狼躯冲刺——矩形贯穿一排敌人——敌人被击退的轨迹本身就是你所画的一条线。当雾形圆形AOE笼罩整个屏幕的杂兵那种"我画了一个圈,圈里全死"的掌控感,就是 Combat Logic 要传达的幻想。参考 RE4 弹反的"接住"反馈 + DMC 的判定可视化 + 割草的密度感。
## Detailed Design
@ -21,72 +23,167 @@ Each form maps to one attack geometry type (via ADR-003):
| Form | Shape | Parameters | Visual Read |
|------|-------|------------|-------------|
| **Human** | `Sector` | Angle=90°, Radius=3.0 | Forward fan — parry judgment zone |
| **Wolf** | `Rect` | Width=1.5, Length=5.0 | Forward rectangle — dash trajectory |
| **Mist** | `Circle` | Radius=5.0 | Self-centered circle — full AOE |
| **Human** | `Sector` | Angle=100°, Radius=3.0 | Forward fan — attack + parry zone |
| **Wolf** | `Rect` | Width=1.0, Length=5.0 | Narrow forward corridor — dash trajectory |
| **Mist** | `Circle` | Radius=3.8 | Self-centered circle — close-range AOE |
### Human Form: Parry Subsystem
Human form has a distinct **parry action** in addition to its standard attack. This is the mechanical realization of the "geometry catching attacks" fantasy.
**Parry Input**: Dedicated parry input (separate from attack) — player holds parry to enter a brief parry stance.
**Parry Window**: The parry sector (Angle=100°, Radius=3.0) is active for 0.15s from input. During this window:
- If **any enemy attack shape** overlaps the parry sector, the parry **succeeds**: the enemy is staggered (0.3s stun), the attack is nullified, and the next Human attack within 0.5s is a **guaranteed crit** (critMultiplier applied, no RNG roll).
- If the parry window expires with no enemy attack overlap, the player enters a 0.2s recovery (cannot act). This is the risk of parrying at the wrong time.
**Parry vs. Attack**: Human form can either attack (standard Sector sweep, base damage 18) or parry (defensive, no damage on parry itself, enables riposte crit). The player chooses which based on reading the battlefield.
### Positional Crit System
Crit is no longer RNG. Crit is earned through positioning, replacing the previous `random(0,1) < critChance` formula.
| Form | Crit Condition | Crit Behavior |
|------|---------------|---------------|
| **Human** | Parry success → next attack within 0.5s is guaranteed crit. Also: attacking enemy from behind (flanking, angle > 120° between enemy facing and attack origin) grants +30% crit chance from critChanceStat. | critMultiplier applied |
| **Wolf** | Dash hitting 3+ enemies in single activation → all hits in that dash are crit. Also: hitting enemy from outside its forward 60° cone (flanking) grants +30% crit chance from critChanceStat. | critMultiplier applied to all dash hits |
| **Mist** | Enemy within inner 40% of circle radius (≤1.52 units from center) → guaranteed crit. Also: hitting enemy from behind (flanking, angle > 120°) grants +30% crit chance from critChanceStat. | critMultiplier applied to inner-radius hits |
**critChanceStat**: A per-form stat (base 0.05, grows via Skill Tree to 0.00.30) that adds flat crit chance on top of positional conditions. At 0.05 base, a flanking attack has 35% crit chance (30% positional + 5% stat). This preserves a small RNG element that grows with investment — but positional crits (parry, dash-chain, inner-radius) are always 100% regardless of stat.
**Crit resolution per hit**:
1. Check positional guarantee (parry riposte, dash 3+, inner 40% radius) → if true, crit
2. Else, check flanking bonus + critChanceStat → `random(0,1) < (flankingBonus + critChanceStat)`
3. Else, check critChanceStat only → `random(0,1) < critChanceStat`
4. Otherwise, normal hit (no crit)
### Data Structures
```
AttackData {
Vector3 Origin // Attack origin (player position)
float Direction // Facing angle
Shape HitShape // Circle / Rect / Sector
FormType SourceForm // Form that launched the attack
float BaseDamage // From Form Switch SM's AttackStyle
float CritChance // Base 0.05, upgradable
float CritMultiplier // Default 1.5x
int AttackId // Unique per attack instance (monotonic, caller-assigned)
Vector3 Origin // Attack origin (player position in L0 world coords)
float Direction // Facing angle in DEGREES (Unity convention; L0 converts to radians internally)
Shape HitShape // Circle / Rect / Sector
FormType SourceForm // Form that launched the attack
float BaseDamage // From Form Switch SM's AttackStyle
float CritChanceStat // Per-form stat (base 0.05), added to positional crit bonus
float CritMultiplier // Default 1.5x
float KnockbackForce
bool IsParry // True if this attack is a Human parry attempt (not a standard attack)
}
EnemyState {
int TargetId // Unique enemy identifier
Vector3 Position // World position (L0 coordinate system)
float FacingAngle // Facing direction in degrees — for flanking crit detection
Shape HitShape // Enemy hit shape (typically Circle with per-type radius)
float Health // Current health — used for IsKillingBlow determination
float Weight // Knockback resistance (110, clamped to ≥1.0)
bool IsDead // Derived: Health <= 0
}
DamageResult {
int TargetId
float FinalDamage
int AttackId // References originating AttackData.AttackId
float FinalDamage // baseDamage × critFactor (pre-resistance; resistance applied downstream by Enemy AI)
bool IsCritical
Vector3 HitPoint // For VFX positioning
bool IsKillingBlow
Vector3 HitPoint // Hit position in L0 world coordinates (computed from shape intersection)
bool IsKillingBlow // True if Health - FinalDamage <= 0
FormType SourceForm
float KnockbackDistance // Computed knockback (knockbackForce / enemyWeight × critFactor)
}
```
**Angle Convention**: All angles in AttackData and EnemyState are in **degrees** (matching Unity convention). L0's math library (ADR-003) converts to radians internally before calling `System.Math` trig functions. This convention must be documented in ADR-003.
**Vector3 Boundary**: L0 uses custom `Vector3` (ADR-003). L1 converts to `UnityEngine.Vector3` via a static utility class `Vector3Conversion.ToUnity(this Combat.Vector3)` in the L1 Adapters assembly. L0 never references `UnityEngine.Vector3`.
**HitPoint Coordinate Space**: `DamageResult.HitPoint` is in **L0 world coordinates** (same coordinate system as `AttackData.Origin` and `EnemyState.Position`). L1 converts to Unity world space for VFX positioning. L0 computes it from the intersection point of the attack shape and enemy hit shape.
**Random Number Injection**: Combat Logic accepts an `IRandomProvider` interface with `double NextDouble()` for critChanceStat rolls. Default implementation wraps `System.Random`. Tests inject a seeded or mock provider for deterministic crit testing.
**Shape Polymorphism**: Shape types use a **union struct** pattern (`HitShape` with `ShapeType` enum + all shape fields) to avoid boxing and virtual dispatch in the hot path.
```
ShapeType { Circle, Rect, Sector }
HitShape {
ShapeType Type;
Circle Circle;
Rect Rect;
Sector Sector;
bool Contains(Vector3 point) { /* switch by Type, no boxing */ }
bool Intersects(HitShape other) { /* dispatch by Type pair */ }
}
```
Epsilon constant: `const float HIT_EPSILON = 0.001f` — overlap must exceed this value to count as a hit. Defined in ADR-003.
### AttackData Construction
An L0 factory method constructs `AttackData` from `AttackStyle` + input intent:
```csharp
// L0 / Combat / AttackDataFactory.cs
public static class AttackDataFactory {
public static AttackData Create(AttackStyle style, Vector3 playerPos,
float facingAngle, bool isParry, int attackId, IRandomProvider rng = null);
}
```
L1's InputAdapter collects the raw data (player transform position, facing, input state) and calls this factory. The factory computes the hit shape geometry from AttackStyle parameters — this is game logic, appropriate for L0.
### Hit Resolution Pipeline (called once per frame)
```
CombatLogic.ResolveAttacks(attacks, enemies, deltaTime)
1. COLLECT: Receive List<AttackData> + List<EnemyState>
2. CULL: Remove dead enemies, skip enemies outside attack range (coarse filter)
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 (Section D)
5. SORT: Descending by damage — high damage resolves first
6. DEDUPLICATE: Same attack can hit multiple enemies; same enemy can be hit by multiple attacks — but each attack hits each enemy at most once per frame
7. EMIT: OnHit(DamageResult), OnKill(DamageResult), OnCrit(DamageResult)
8. RETURN: List<DamageResult> grouped by TargetId
4. DAMAGE: For each hit pair, execute damage formula (crit resolution per positional rules)
5. DEDUPLICATE: Each (AttackId, TargetId) pair counts at most once per frame
6. EMIT: OnHit(DamageResult), OnKill(DamageResult), OnCrit(DamageResult), OnParrySuccess(AttackData, DamageResult)
7. RETURN: List<DamageResult> grouped by TargetId
```
**Pipeline is batch-per-frame**: All attacks are resolved against pre-frame enemy state. Dead filtering (step 2) runs once at the start. If an enemy is alive at frame start, all attacks that intersect it will produce hits this frame — damage is summed and applied downstream once after resolution. There is NO mid-frame kill check.
**Sorting**: Results are ordered by AttackId (stable, deterministic). There is no sort-by-damage step.
**Spatial Grid**: A uniform spatial grid (L0 pure C#, cell size = max attack range) provides broad-phase culling. Enemies are bucketed at O(E) per frame. Attack queries check only nearby cells, reducing intersection pairs from O(A×E) to O(A×local-density).
**Memory**: Use `NonAlloc` pattern — caller provides a pre-allocated `List<DamageResult>` buffer that `ResolveAttacks` fills in-place. No per-frame allocation from Combat Logic.
### Target Filtering Rules
- Enemy state = Dead → skip
- Attack shape has no intersection with enemy hit shape → skip
- Same attack already hit this enemy this frame → skip (dedup)
- Same (AttackId, TargetId) pair already processed this frame → skip (dedup)
- Degenerate attack shape (zero area: radius=0, width=0, angle=0) → skip entire attack
### Events
| Event | Trigger | Payload |
|-------|---------|---------|
| `OnHit` | Every successful hit | `DamageResult` |
| `OnKill` | Hit reduces enemy health to 0 | `DamageResult` |
| `OnCrit` | Hit triggers critical | `DamageResult` |
| `OnAttackResolved` | Attack resolution complete (fires even if 0 hits) | `AttackData, int hitCount` |
| `OnKill` | Hit reduces enemy health to ≤0 | `DamageResult` |
| `OnCrit` | Hit triggers critical (positional or stat-based) | `DamageResult` |
| `OnParrySuccess` | Human parry intersects enemy attack during parry window | `AttackData, DamageResult` |
| `OnAttackResolved` | Per-attack resolution complete (fires even if 0 hits) | `AttackData, int hitCount` |
**Event batching note**: For performance at high hit density, L1 subscribers (VFX Spawner, Audio Player) should batch-process events per frame and apply per-frame caps (e.g., max 200 particle bursts, max 20 SFX). See Performance Considerations below.
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|-----------|
| Form Switch SM | Upstream | Reads `AttackStyle` for shape + base damage per form |
| Enemy AI Logic | Downstream | Enemy receives damage, updates health, may trigger behavior change |
| VFX Spawner (L1) | Downstream | Subscribes to `OnHit`/`OnKill`/`OnCrit` for visual feedback |
| Blood Energy Economy | Downstream | `OnHit` triggers `BloodEnergyEconomy.Add(attackGain)`, `OnKill` triggers `BloodEnergyEconomy.Add(killGain)`. Connected via L1 `BloodEnergyBridge` adapter that subscribes to Combat Logic events and calls Blood Energy Economy methods. |
| Enemy AI Logic | Downstream | Enemy receives `DamageResult` for health subtraction, behavior response, and resistance application |
| VFX Spawner (L1) | Downstream | Subscribes to `OnHit`/`OnKill`/`OnCrit`/`OnParrySuccess` for visual feedback |
| Death/Respawn | Downstream | `OnKill` with IsKillingBlow triggers death check |
| ADR-003 Geometry | Foundation | Uses `Circle`, `Rect`, `Sector` for all intersection tests |
## Formulas
@ -95,57 +192,61 @@ CombatLogic.ResolveAttacks(attacks, enemies, deltaTime)
```
finalDamage = baseDamage × critFactor
where critFactor = critMultiplier if random(0,1) < critChance, else 1.0
where critFactor = critMultiplier if crit (positional guarantee OR flanking+stat roll succeeds OR stat-only roll succeeds), else 1.0
```
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Base damage | `baseDamage` | float | 550 | From AttackStyle, determined by form and upgrades |
| Crit chance | `critChance` | float | 0.00.30 | Baseline 0.05, grows via Skill Tree |
| Crit chance stat | `critChanceStat` | float | 0.00.30 | Per-form stat, baseline 0.05, grows via Skill Tree |
| Flanking bonus | `flankingBonus` | float | 0.30 (fixed) | Applied when attacking outside enemy's forward 60°120° cone |
| Crit multiplier | `critMultiplier` | float | 1.23.0 | Baseline 1.5 |
| Crit factor | `critFactor` | float | 1.0 or critMultiplier | Dice roll per hit |
| Crit factor | `critFactor` | float | 1.0 or critMultiplier | Positional guarantee = always critMultiplier |
**Output Range**: 5150 (extreme: baseDamage=50 × critMultiplier=3.0 = 150)
**Design Intent**: Combat Logic applies only base damage. Enemy resistance modifiers and form affinity bonuses are defined in Enemy AI GDD. Skill damage bonuses are defined in Skill Tree GDD. This keeps Combat Logic single-responsibility.
**Design Intent**: Combat Logic applies only base damage × critFactor. Enemy resistance modifiers are defined in Enemy AI GDD and applied downstream. Skill damage bonuses are defined in Skill Tree GDD. This keeps Combat Logic single-responsibility.
### Knockback Formula
```
knockbackDistance = knockbackForce / enemyWeight × (IsCritical ? 1.5 : 1.0)
knockbackDistance = knockbackForce / max(enemyWeight, 1.0) × (IsCritical ? 1.5 : 1.0)
```
| Variable | Symbol | Type | Range | Description |
|----------|--------|------|-------|-------------|
| Knockback force | `knockbackForce` | float | 010 | From AttackStyle |
| Enemy weight | `enemyWeight` | float | 110 | Heavier enemies resist knockback |
| Enemy weight | `enemyWeight` | float | 110 (clamped to ≥1.0) | Heavier enemies resist knockback |
**Output**: 015 units | Critical hits add 1.5× knockback
**Output**: 015 units | Critical hits add 1.5× knockback. `max(enemyWeight, 1.0)` prevents division by zero.
## Edge Cases
| # | Condition | Resolution | Rationale |
|---|-----------|------------|-----------|
| 1 | Attack shape has zero area (radius=0, width=0) | Skip, return empty list | Degenerate shape — no valid hit possible |
| 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 attack hits same enemy twice (edge overlap) | Dedup: count only first hit | One attack = one hit per target per frame |
| 4 | `critChance` exceeds 1.0 via buffs | Clamp to 1.0 | Guaranteed crit is the ceiling |
| 5 | `baseDamage` = 0 (status effect attack) | Still perform hit test, emit OnHit with 0 damage | Knockback may still apply |
| 6 | Multiple attacks in same frame against same enemy | Resolve all, sort by damage desc | Independent attacks stack |
| 7 | Enemy killed mid-frame by attack #1 | Attacks #2-N skip this enemy (dead filter) | Dead enemies don't accumulate damage |
| 8 | `knockbackForce` = 0 | Return 0, skip calculation | No force applied |
| 9 | deltaTime = 0 (pause) | Return empty list, no events | Paused game = no combat resolution |
| 10 | Empty enemies list | Return empty list, no events | No targets to hit |
| 11 | Shape intersection at exact tangent point | Count as miss (requires overlap > epsilon) | Prevents degenerate edge-touch = hit |
| 3 | Same (AttackId, TargetId) processed twice in one frame | Dedup: count only first occurrence | One attack = one hit per target per frame |
| 4 | `critChanceStat` exceeds 1.0 via buffs | Clamp to 1.0 (combined with positional bonus, cap at 1.0) | Guaranteed crit is the ceiling |
| 5 | `baseDamage` = 0 (status effect attack) | Still perform hit test, emit OnHit with FinalDamage=0 | Knockback may still apply; positional crit conditions still evaluated |
| 6 | Multiple attacks in same frame against same enemy | Resolve all — damage summed downstream | Independent attacks stack; all resolved against pre-frame state |
| 7 | `knockbackForce` = 0 | Return knockbackDistance=0, skip calculation | No force applied |
| 8 | deltaTime = 0 (pause) | Return empty list, no events | Paused game = no combat resolution |
| 9 | Empty enemies list | Return empty list, no events | No targets to hit. OnAttackResolved still fires per attack with hitCount=0. |
| 10 | Shape intersection at exact tangent point (overlap ≤ HIT_EPSILON) | Count as miss | HIT_EPSILON = 0.001f. Prevents degenerate edge-touch = hit |
| 11 | Parry activated but no enemy attack in window | Parry fails → 0.2s recovery, OnAttackResolved fires with hitCount=0 | Risk of parrying at wrong time |
| 12 | `enemyWeight` = 0 (invalid data) | Clamp to 1.0 before division | Defensive guard — prevents float.PositiveInfinity |
| 13 | `Direction` passed as radians (incorrect, should be degrees) | L0 treats Direction as degrees; caller's bug | Convention enforcement: ADR-003 documents degrees as the interchange format |
## Dependencies
| System | Relationship | Interface |
|--------|-------------|-----------|
| Form Switch SM | Upstream | Reads `AttackStyle.BaseDamage`, `AttackStyle.AttackShape` per form |
| Blood Energy Economy | Downstream | `OnHit``Add(attackGain)`, `OnKill``Add(killGain)`. Bridged via L1 adapter. |
| Enemy AI Logic | Downstream | Enemy receives `DamageResult` for health subtraction and behavior response |
| VFX Spawner (L1) | Downstream | Subscribes to `OnHit`, `OnKill`, `OnCrit` for visual feedback |
| 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 |
| 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.
@ -153,14 +254,38 @@ No upstream GDD dependencies — this is a Foundation layer system. Form Switch
| Parameter | Default | Safe Range | Too Low | Too High |
|-----------|---------|------------|---------|----------|
| `baseDamage` (Human) | 15 | 825 | Parry feels unrewarding | Parry out-damages other forms |
| `baseDamage` (Wolf) | 25 | 1540 | Burst doesn't feel bursty | Wolf becomes the only viable form |
| `baseDamage` (Mist) | 8 | 415 | AOE feels useless | Mist clears everything, no switching needed |
| `critChance` | 0.05 | 0.020.10 | Crits never seen | Crits too frequent, lose impact |
| `baseDamage` (Human) | 18 | 1025 | Attacks feel weak | Other forms become irrelevant |
| `baseDamage` (Wolf) | 22 | 1535 | Burst doesn't feel bursty | Wolf the only viable form |
| `baseDamage` (Mist) | 8 | 415 | AOE feels useless | Mist clears everything |
| `critChanceStat` | 0.05 | 0.020.10 | Stat crits never seen without positional bonus | Positional crits lose distinction |
| `critMultiplier` | 1.5 | 1.22.5 | Crits not noticeable | Damage spikes too wild |
| Human Sector Angle | 90° | 60120° | Too narrow, parry frustrating | Too wide, parry trivial |
| Wolf Rect Length | 5.0 | 3.08.0 | Dash feels short | Range competes with Mist |
| Mist Circle Radius | 5.0 | 3.08.0 | AOE feels cramped | Covers whole screen, no positioning |
| Flanking bonus | 0.30 | 0.200.40 | Flanking not worth maneuvering for | Flanking replaces parry/dash-chain |
| Human Sector Angle | 100° | 80130° | Too narrow, parry frustrating | Too wide, parry trivial |
| Wolf Rect Width | 1.0 | 0.81.5 | Too narrow, dash whiffs | Too wide, dash loses precision identity |
| Wolf Rect Length | 5.0 | 3.08.0 | Dash feels short | Range competes with ranged forms |
| Mist Circle Radius | 3.8 | 2.55.0 | AOE feels cramped | Covers whole screen, no positioning |
| Mist inner crit radius | 1.52 (40% of 3.8) | 3050% of circle radius | Inner-radius crits too rare | Inner-radius crits too easy |
| HIT_EPSILON | 0.001 | 0.00050.005 | Grazing contacts feel inconsistent | Attacks "should have hit" |
| Parry window duration | 0.15s | 0.100.20s | Parry too tight, frustrating | Parry too generous, loses skill |
| Parry recovery duration | 0.20s | 0.150.30s | Recovery meaningless | Parry too punishing |
**Key tuning pair**: `baseSwitchCost / baseAttackGain ≈ 8` (from Blood Energy Economy GDD) — roughly 8 attack hits to earn one switch. Base damage values here affect that rhythm: higher base damage means faster kills, faster energy gain, more frequent switching.
## Performance Considerations
**Spatial partitioning**: Uniform grid with cell size = max attack range (5.0 units). Enemies bucketed at O(E) per frame. Attack queries check only nearby cells, reducing intersection pairs ~10× at 200 enemies.
**Memory**: `NonAlloc` pattern — caller provides pre-allocated `List<DamageResult>`. No per-frame allocation from Combat Logic. Dedup uses pre-allocated `HashSet<(int AttackId, int TargetId)>` cleared per frame.
**Event batching**: L1 subscribers (VFX Spawner, Audio Player) must apply per-frame caps:
- VFX: max 200 particle bursts/frame (prioritize crits, kills, then hits)
- Audio: max 20 SFX/frame (prioritize kills, crits, then hits)
- HUD: max 50 damage numbers/frame (prioritize highest damage)
**Target budget at 200 enemies × 10 attacks (2000 pairs)**:
- Intersection + damage + dedup: <0.8ms (with spatial grid + union struct shapes + squared-distance early-out)
- Event emission: <0.2ms (batched event data, subscribers apply caps)
- Total Combat Logic: <1.0ms
## Visual/Audio Requirements
@ -170,12 +295,14 @@ Combat is a visual system — Visual/Audio is REQUIRED.
- `OnHit`: Geometric particle burst at `DamageResult.HitPoint` — color matches `SourceForm`
- `OnCrit`: Larger burst + screen shake micro — gold/amber tint
- `OnKill`: Enemy shatter into geometric fragments — fragment color = enemy type
- Debug overlay: Semi-transparent fill of Sector/Rect/Circle during attack frames
- `OnParrySuccess`: Sharp ring-shaped particle burst at parry contact point — white/silver flash
- Debug overlay: Semi-transparent fill of Sector/Rect/Circle during attack/parry frames (player-facing shape preview)
**Audio (via L1 Audio Player):**
- `OnHit`: Impact SFX — varies by form (Human=sharp slash, Wolf=heavy thud, Mist=ethereal whoosh)
- `OnCrit`: Distinct "clink" + bass emphasis
- `OnKill`: Shatter sound — pitch varies by enemy size
- `OnParrySuccess`: Metallic ring + short bass drop
**Art Bible Alignment**: Principle 1 (Color is Identity — hit VFX color = form color), Principle 3 (Particles are Feedback — geometric fragments = information).
@ -183,30 +310,46 @@ Combat is a visual system — Visual/Audio is REQUIRED.
| Element | Position | Content |
|---------|----------|---------|
| Damage Numbers | Floating at hit point | Numeric value, color = form color, crit = larger + gold |
| Damage Numbers | Floating at hit point | Numeric value, color = form color, crit = larger + gold, positional crit = gold + "positional" icon |
| Hit Indicator | Screen edge | Directional flash when player takes damage |
| Kill Counter | HUD | Combo/kill streak (fed by `OnKill` event) |
| Parry Indicator | Center-screen | Brief flash when parry window is active; distinct flash on parry success |
| Attack Shape Preview | In-world overlay | Semi-transparent shape shown during attack windup (L1 renders from AttackData.HitShape) |
## Acceptance Criteria
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 1 | Player in Human form, enemy in sector zone | `ResolveAttacks` called | Hit detected, `OnHit` fires with SourceForm=Human |
| 2 | Player in Wolf form, enemy 6 units away | `ResolveAttacks` (Rect length=5) | No hit (beyond range), 0 results |
| 3 | Player in Mist form, 5 enemies in 4-unit radius | `ResolveAttacks` (Circle radius=5) | All 5 enemies hit, 5 results returned |
| 4 | Enemy at exact edge of shape boundary | Intersection test | Miss (epsilon threshold > tangent) |
| 5 | critChance=0.3, 100 attacks | `ResolveAttacks` × 100 | ~30 hits critical (±5 tolerance) |
| 6 | Enemy health=10, baseDamage=15 | Hit resolves | `OnKill` fires, IsKillingBlow=true |
| 7 | Enemy health=10, baseDamage=5 | Hit resolves | Health → 5, `OnHit` fires, no `OnKill` |
| 8 | knockbackForce=10, enemyWeight=2 | Hit resolves | knockbackDistance = 5.0 |
| 9 | Dead enemy in list | `ResolveAttacks` | Dead enemy skipped |
| 10 | critChance buffed to 1.2 | Crit roll | Clamped 1.0, all hits crit |
| 11 | 3 attacks vs same enemy in one frame | `ResolveAttacks` | 3 independent hits, damage summed |
| 12 | Attack misses everything | `ResolveAttacks` | `OnAttackResolved` fires with hitCount=0 |
| 13 | deltaTime=0 (pause) | `ResolveAttacks` | Returns empty, no events |
| 1 | Human Sector (Angle=100°, Radius=3.0, dir=0°). EnemyState { TargetId=1, Position=(2.0, 0, 0.5), HitShape=Circle(r=0.5), Health=20, Weight=2, FacingAngle=0° }. AttackData { AttackId=1, BaseDamage=18, CritChanceStat=0.0, IsParry=false }. Mock RNG returns 0.5. | `ResolveAttacks([attack], [enemy], dt=0.016)` | Returns 1 result. DamageResult: TargetId=1, AttackId=1, FinalDamage=18.0, IsCritical=false (no flank, no positional, stat only=0.0 < 0.5 roll), SourceForm=Human, IsKillingBlow=false. OnHit fires. OnAttackResolved fires with hitCount=1. |
| 2 | Wolf Rect (Width=1.0, Length=5.0, rotation=0°). Enemy at Position=(0, 0, 5.5) — 5.5 > half-length 2.5 + enemy radius 0.5 = 3.0, beyond range. | `ResolveAttacks([attack], [enemy], dt=0.016)` | Returns empty list. OnAttackResolved fires with hitCount=0. |
| 3 | Mist Circle (Radius=3.8). 5 enemies all within 3.3 units (well inside radius). | `ResolveAttacks([attack], enemies, dt=0.016)` | Returns 5 results, all SourceForm=Mist, distinct TargetIds. OnHit fires 5 times. OnAttackResolved fires with hitCount=5. Enemy at (0.5, 0, 0) — inner 40% radius (≤1.52) → IsCritical=true (positional guarantee). |
| 4a | Attack shape Circle(r=5.0), enemy hit shape at exact tangent (overlap depth = 0.0). HIT_EPSILON = 0.001. | `ResolveAttacks([attack], [enemy], dt=0.016)` | Returns empty list. Overlap 0.0 < 0.001 miss. |
| 4b | Same setup, enemy shifted by 0.001 units toward attack center (overlap = 0.001 = HIT_EPSILON). | `ResolveAttacks([attack], [enemy], dt=0.016)` | Hit detected, OnHit fires. |
| 5a | critChanceStat=0.30, flanking bonus not active (enemy facing toward attacker). Mock RNG returns 0.29. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=true (0.29 < 0.30 stat-only roll). |
| 5b | critChanceStat=0.30, flanking bonus not active. Mock RNG returns 0.31. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=false (0.31 ≥ 0.30). |
| 5c | critChanceStat=0.05, flanking bonus active (enemy facing away, angle > 120°). Mock RNG returns 0.34. | `ResolveAttacks([attack], [enemy], dt=0.016)` | DamageResult.IsCritical=true (0.34 < 0.05 + 0.30 flanking bonus). |
| 6 | Enemy Health=10, BaseDamage=15 (non-crit). | `ResolveAttacks` | IsKillingBlow=true, OnKill fires, OnHit fires. FinalDamage=15. |
| 7 | Enemy Health=10, BaseDamage=5 (non-crit). | `ResolveAttacks` | IsKillingBlow=false, OnHit fires, OnKill does NOT fire. FinalDamage=5. |
| 8a | KnockbackForce=10, enemyWeight=2, IsCritical=false. | `ResolveAttacks` | DamageResult.KnockbackDistance = 5.0 (10/2 × 1.0). |
| 8b | KnockbackForce=10, enemyWeight=2, IsCritical=true (positional guarantee). | `ResolveAttacks` | DamageResult.KnockbackDistance = 7.5 (10/2 × 1.5). |
| 9 | Enemy A { Health=0, IsDead=true }, Enemy B { Health=20 }. Attack covers both. | `ResolveAttacks([attack], [enemyA, enemyB], dt=0.016)` | Returns 1 result (Enemy B only). Enemy A skipped (dead). OnAttackResolved fires with hitCount=1. |
| 10 | critChanceStat=1.2 (beyond cap). | Crit resolution | Clamped to 1.0. With no flanking active, combined cap is 1.0. Mock RNG returns 0.99 → crit (0.99 < 1.0). |
| 11 | 3 attacks [A(AttackId=1), B(AttackId=2), C(AttackId=3)] all hitting same enemy (Health=100). | `ResolveAttacks([A, B, C], [enemy], dt=0.016)` | Returns 3 results (TargetId=1, AttackId distinct). Total FinalDamage = sum of 3 individual damages. All resolved against pre-frame health of 100. OnHit fires 3 times. |
| 12 | Attack misses all enemies (none in range). | `ResolveAttacks([attack], [enemies], dt=0.016)` | Returns empty list. OnAttackResolved fires with hitCount=0. No OnHit/OnKill/OnCrit. |
| 13 | Valid attack, enemy in range, but deltaTime=0.0. | `ResolveAttacks([attack], [enemy], dt=0.0)` | Returns empty list. No events fire. |
| 14 | Same (AttackId, TargetId) pair appears twice in pipeline (bug or edge overlap). | Dedup step | Only first occurrence produces a result. Second is skipped. OnHit fires once. |
| 15 | OnCrit event: any crit (positional guarantee or stat roll succeeds). | Hit resolves with IsCritical=true | OnCrit fires with DamageResult. Payload matches the crit hit. |
| 16 | Parry: AttackData { IsParry=true, HitShape=Sector(100°, 3.0) }. Enemy attack HitShape overlaps sector during 0.15s window. | `ResolveAttacks([parry], [enemyAttack], dt=0.016)` | OnParrySuccess fires. Next Human attack within 0.5s has positional crit guarantee. |
| 17 | Parry: same parry attack, but no enemy attack shape overlaps. | Same call | OnParrySuccess does NOT fire. OnAttackResolved fires with hitCount=0. |
| 18 | enemyWeight=0 (invalid). KnockbackForce=10. | `ResolveAttacks` | KnockbackDistance = 10.0 (10/max(0,1.0) = 10/1.0, non-crit). Formula guards against division by zero. |
| 19 | Attack shape with radius=0 (degenerate). | `ResolveAttacks([zeroAreaAttack], [enemies], dt=0.016)` | Returns empty list for that attack. OnAttackResolved fires with hitCount=0. |
| 20 | Empty enemies list but attack data present. | `ResolveAttacks([attack], [], dt=0.016)` | Returns empty list. OnAttackResolved fires per attack with hitCount=0. No OnHit/OnKill/OnCrit. |
## Open Questions
- Should enemies have different hit shape sizes (e.g., Brute larger than Swarm) or uniform? — Defer to Enemy AI GDD
- Should there be a "perfect timing" bonus for Human parry (hitting within narrow enemy attack window)? — Candidate for Skill Tree / Form Switch SM GDD
- Attack shape size scaling: should Skill Tree upgrades increase shape dimensions, or only damage? — Defer to Skill Tree GDD
- Parry window (0.15s) and recovery (0.20s) need prototype validation — actual game feel will determine the right durations
- Inner crit radius for Mist (40% of circle radius) — does this create the right "dense at center" feel or should it be a flat value?
- Should there be a "perfect parry" tier (tighter window, bigger reward) or is the single parry window sufficient?
- Should enemies have varying hit shape sizes (e.g., Brute larger than Swarm) or uniform radius? — Defer to Enemy AI GDD
- Attack shape size scaling: should Skill Tree upgrades increase shape dimensions, or only damage/critChanceStat? — Defer to Skill Tree GDD
- Spatial grid cell size: max attack range (5.0) may be too coarse for predominantly close-range combat. Profile during prototype.

View File

@ -0,0 +1,246 @@
# Form Switch State Machine
> **Status**: In Review (revised 2026-04-27 — addressed 6 blocking review findings)
> **Author**: SepComet + Claude
> **Last Updated**: 2026-04-27
> **Implements Pillar**: 形态即战术 (Form is Tactics), 切换即承诺 (Switch is Commitment)
## Overview
Form Switch State Machine 是夜裔最核心的战斗系统——管理玩家在三种形态(人形、狼躯、雾形)之间的切换。每次切换不是一个即时操作,而是一个有节奏的承诺:玩家按下切换键后,必须经过短暂的 Windup 前摇0.25s,期间可被打断),然后进入 Switching 无敌帧0.10s),最后 Recovery 后摇0.15s和冷却0.3s)。总间隔 ≥ 0.8s,其中 Windup 是风险窗口——被打断则血能不退。状态机结构遵循 ADR-004 的 4 阶段设计。切换不仅改变攻击风格(通过 AttackStyle 传递给 Combat Logic还改变移动方式和视觉呈现。这个系统是"切换即承诺"和"形态即战术"两个核心支柱的直接实现。
## Player Fantasy
形态切换不是按个键换色——它是你在战场上**读秒**的瞬间。当一群 Swarm 杂兵围上来时你在心里计时Windup 0.25 秒是否安全?切到雾形后能否在 Recovery 结束前按下第一次攻击?每一次成功切换都让你感觉自己"掌握了一种危险的古老力量"——你能变成雾穿透敌阵、变成狼撕裂目标、变回人形精准弹反。在外人看来,你在一群敌人中间跳舞;在你自己看来,你在做一连串以秒为单位的战术决策。参考 RE4 弹反"接住时机"的释放感和 DMC 形态切换的流畅感,但加入**风险**——我们的切换会失败,失败有代价,这才是"切换即承诺"。
## Detailed Design
### Core Rules
**1. Switch Request**: Player presses form key → `RequestSwitch(targetForm)`
- Checks `CanSwitchTo(target)`: Phase=Idle ✓, cooldown ended ✓, blood energy sufficient ✓, target≠current ✓
- Returns: `SwitchResult.Success` / `InsufficientEnergy` / `OnCooldown` / `InvalidTarget`
- Blood Energy Economy is injected via constructor (`IBloodEnergyEconomy` interface) — follows project DI standard. `RequestSwitch` takes only `FormType target`.
**SwitchResult enum**:
```
SwitchResult { Success, InsufficientEnergy, OnCooldown, InvalidTarget }
```
**2. State Machine Flow**:
```
Idle →(RequestSwitch)→ Windup(0.25s) →(timer)→ Switching(0.10s) →(timer)→ Recovery(0.15s) →(timer)→ Idle(+0.3s cooldown)
│ │
└─ Hit → Interrupt() → Idle (energy spent, NOT refunded) └─ RequestSwitch now available
```
**3. Phase Capability Limits**:
| Phase | Move | Attack | Damaged | Re-Switch | Visual |
|-------|------|--------|---------|-----------|--------|
| Idle | ✓ | ✓ | ✓ | ✓ | Current form normal |
| Windup | ✓ (50% slow) | ✗ | ✓ | ✗ | Form flicker, color transition begins |
| Switching | ✗ | ✗ | ✗ (i-frames) | ✗ | Geometry restructure animation |
| Recovery | ✓ | ✓ | ✗ | ✗ | New form color stabilizing |
**4. Cooldown**: After Recovery, extra 0.3s before next switch allowed. Windup(0.25) + Switching(0.10) + Recovery(0.15) + Cooldown(0.30) = 0.80s minimum between switches.
**Cooldown state note**: During cooldown, `Phase` reports `Idle` (the player can move and attack freely). Systems must query `CanSwitchTo(target)` — not `Phase` — to determine switch availability. `CanSwitchTo()` checks: Phase=Idle AND cooldown expired AND energy sufficient AND target≠current. `Phase` alone does not imply switch-readiness.
### Attack Style Output
| 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 |
> **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.
### Interactions with Other Systems
| System | Direction | Interface |
|--------|-----------|-----------|
| Blood Energy Economy | Upstream | Calls `CanSpend(cost)`, `Spend(cost)` on successful switch |
| Player Input | Upstream | Responds to `OnSwitchPress(FormType)` events from Input Adapter |
| Combat Logic | Downstream | Provides `GetAttackStyle(CurrentForm)` for hit resolution |
| VFX Spawner (L1) | Downstream | Emits `OnFormChanged`, `OnPhaseChanged`, `OnSwitchInterrupted` |
| UI/HUD (L2) | Downstream | `OnFormChanged` drives HUD form indicator update |
| Skill Tree | Upstream | Can modify `WindupDuration`, `CooldownDuration`, unlock new forms |
**Event ordering contract**: When a switch completes (Recovery→Idle), events fire in this order: (1) `OnFormChanged(from, to)`, (2) `OnPhaseChanged(Idle)`. When `Interrupt()` is called during Windup: (1) `OnSwitchInterrupted(targetForm)`, (2) `OnPhaseChanged(Idle)`. During normal phase transitions (Windup→Switching→Recovery): only `OnPhaseChanged` fires. Subscribers (VFX, Audio, HUD) can rely on this ordering.
**Dependency injection**: `IBloodEnergyEconomy` is injected via constructor. `RequestSwitch(FormType target)` takes only the target form — it internally calls `_energy.CanSpend(cost)` and `_energy.Spend(cost)`. This follows the project DI-over-singletons standard and allows mock injection for L0 testing.
## Formulas
No complex formulas. Core timing relationships:
```
totalSwitchTime = WindupDuration + SwitchDuration + RecoveryDuration (= 0.50s default)
minSwitchInterval = totalSwitchTime + CooldownDuration (= 0.80s default)
```
The timing parameters are tunable (see Tuning Knobs). The WindupDuration is the critical feel variable — it creates the "danger window" that defines the Switch is Commitment pillar.
## Edge Cases
| # | Condition | Resolution |
|---|-----------|------------|
| 1 | RequestSwitch while Phase≠Idle | Return OnCooldown — only Idle accepts input |
| 2 | RequestSwitch to current form | Return InvalidTarget — meaningless switch |
| 3 | Hit during Windup | Interrupt() → Phase=Idle, energy already spent, NOT refunded |
| 4 | Hit during Switching | Ignored — i-frames are absolute |
| 5 | Die during any phase | Force reset to Idle, CurrentForm=Human, energy already at 0 |
| 6 | skillDiscount makes switchCost→0 | Minimum cost 1.0 enforced (from Blood Energy GDD) |
| 7 | Switch key held down (repeated input) | Ignored — only rising edge triggers RequestSwitch |
| 8 | deltaTime=0 (pause) | Skip Update(), state frozen |
| 9 | WindupDuration reduced to 0 by Skill Tree | Clamp to minimum 0.10s floor — Skill Tree cannot reduce WindupDuration below 0.10s |
| 10 | Cooldown reduced to 0 by Skill Tree | Clamp to minimum 0.15s floor — Skill Tree cannot reduce CooldownDuration below 0.15s |
| 11 | Damage during Windup exactly as timer expires | Switching wins — damage ignored (favor the player at boundaries). Implementation: within a single Update() call, the phase timer is decremented FIRST, then damage is processed. |
| 12 | Multiple switch requests in same frame | Only first processed |
| 13 | deltaTime exceeds WindupDuration (frame spike) | Clamp effective deltaTime to min(deltaTime, WindupDuration) — process at most one phase transition per Update() call |
| 14 | Skill Tree modifies timing param mid-switch | Snapshot at RequestSwitch — changes apply on next switch, not mid-transition |
| 15 | Switch input buffered during last 0.10s of Recovery | Queued input auto-executes when cooldown expires. Buffer holds exactly one input (most recent wins). |
## Dependencies
| System | Relationship | Hard/Soft |
|--------|-------------|-----------|
| Blood Energy Economy (Approved) | Upstream — must check/spend energy | Hard |
| Player Input (undesigned) | Upstream — input events | Hard |
| Combat Logic (Designed) | Downstream — provides AttackStyle per form | Hard |
| VFX Spawner (undesigned) | Downstream — visual events | Soft |
| UI/HUD (undesigned) | Downstream — form indicator | Soft |
| Skill Tree (undesigned) | Upstream — modifies timing parameters | Soft |
Provisional assumption: Player Input provides `OnSwitchPress(FormType)` events. The Form-to-key mapping (Human=1, Wolf=2, Mist=3 or similar) is defined in the Input Adapter GDD.
## Tuning Knobs
| Parameter | Default | Safe Range | Core Impact |
|-----------|---------|------------|-------------|
| `WindupDuration` | 0.25s | 0.150.35s | The "danger window" — defines switch feel |
| `SwitchDuration` | 0.10s | 0.050.15s | I-frame generosity |
| `RecoveryDuration` | 0.15s | 0.100.25s | Re-entry smoothness after switch |
| `CooldownDuration` | 0.30s | 0.150.50s | Switch rhythm spacing |
| `WindupSlowPercent` | 0.50 | 0.300.70 | Mobility during Windup |
| Human `BaseDamage` | 18 | 1025 | Human form reward |
| Wolf `BaseDamage` | 22 | 1535 | Wolf form reward |
| Mist `BaseDamage` | 8 | 415 | Mist form reward |
**Key interaction**: Reducing WindupDuration + CooldownDuration too aggressively removes the tactical weight of switching. **Hard floors enforced**: `WindupDuration ≥ 0.10s`, `CooldownDuration ≥ 0.15s`, keeping total interval ≥ 0.40s (0.10+0.05+0.10+0.15). Skill Tree upgrades reduce toward these floors but never below them.
## Visual/Audio Requirements
Combat system — Visual/Audio is REQUIRED.
**VFX (via L1 VFX Spawner)**:
- `OnFormChanged(from, to)`: Geometric burst at player position — color = new form color
- `OnPhaseChanged(Windup)`: Form flicker + color lerp from current to target
- `OnPhaseChanged(Switching)`: Geometry warp/restructure + brief i-frame glow (white)
- `OnSwitchInterrupted`: Red flash + particle scatter in current form color
**Audio (via L1 Audio Player)**:
- `OnFormChanged`: Distinct form activation SFX per form (Human=sharp, Wolf=heavy, Mist=ethereal)
- `OnPhaseChanged(Windup)`: Rising tension drone
- `OnSwitchInterrupted`: Dissonant "snap" — clear audio punishment
**Art Bible Alignment**: Principle 1 (Color is Identity — switch VFX color directly communicates new form), Principle 3 (Particles are Feedback — each phase has a unique particle signature).
## UI Requirements
| Element | Content |
|---------|---------|
| Form Indicator | 3 form icons — current highlighted, out-of-reach greyed, available glowing |
| Switch Cooldown Ring | Circular timer showing remaining cooldown around form indicator |
| Phase Feedback | Windup = red border flash (danger), Switching = gold border (i-frames) |
| Switch Failure Feedback | InsufficientEnergy = energy bar flash red; OnCooldown = cooldown ring pulse; InvalidTarget = current form icon pulse (already active) |
| Buffered Switch Indicator | When switch input is queued during Recovery buffer: form icon shows subtle glow/border pulse to confirm input was registered |
## Acceptance Criteria
> **Testing scope**: ACs marked [L0] are NUnit-testable without Unity. ACs marked [L1] require Unity integration testing. ACs marked [VISUAL] require manual sign-off.
### State Machine Transitions [L0]
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 1 | Phase=Idle, energy≥cost, WindupDuration=0.25 | RequestSwitch(Wolf) | Phase→Windup, PhaseTimer=WindupDuration |
| 2 | Phase=Windup, PhaseTimer=0 | Update(dt) | Phase→Switching, PhaseTimer=SwitchDuration |
| 3 | Phase=Switching, PhaseTimer=0 | Update(dt) | Phase→Recovery, PhaseTimer=RecoveryDuration |
| 4 | Phase=Recovery, PhaseTimer=0 | Update(dt) | Phase→Idle, CooldownTimer=CooldownDuration. Phase reports Idle; CanSwitchTo() returns false until cooldown expires. |
| 5 | Phase=Idle, CooldownTimer=0.05 | Update(0.06) | CooldownTimer=0, CanSwitchTo() returns true (energy permitting) |
### Switch Validation [L0]
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 6 | Phase=Idle, CooldownTimer=0.1 remaining | RequestSwitch(Wolf) | Returns SwitchResult.OnCooldown, Phase stays Idle |
| 7 | Phase=Idle, energy < switchCost | RequestSwitch(Wolf) | Returns SwitchResult.InsufficientEnergy, Phase stays Idle, energy unchanged |
| 8 | CurrentForm=Human | RequestSwitch(Human) | Returns SwitchResult.InvalidTarget, Phase stays Idle |
| 9 | Phase=Switching | RequestSwitch(Wolf) | Returns SwitchResult.OnCooldown (only Idle accepts input) |
### Interrupt & Damage [L0]
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 10 | Phase=Windup, energy deducted by Spend(cost) on switch start | Interrupt() | Phase→Idle, energy NOT refunded (verify mock IBloodEnergyEconomy.Refund() was NOT called), OnSwitchInterrupted fires with target form |
| 11 | Phase=Switching | Hit received / Interrupt() | Ignored — Phase stays Switching, no events fire |
| 12 | Phase=Recovery | Hit received | Ignored — Phase stays Recovery |
| 13 | Phase=Windup, PhaseTimer≤PHASE_EPSILON (0.001), hit received same frame | Update(dt) then damage check | Phase→Switching before damage processed — hit ignored (player-favored boundary) |
### AttackStyle Output [L0]
| # | 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 |
### Events [L0]
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 17 | Switch completes (Recovery→Idle), Human→Wolf | OnFormChanged | Event fires with args (Human, Wolf). Fires BEFORE OnPhaseChanged(Idle). |
| 18 | Phase transitions Idle→Windup | Phase change | OnPhaseChanged fires with (Windup) after OnFormChanged (if form changed) |
| 19 | Phase transitions Windup→Switching | Phase change | OnPhaseChanged fires with (Switching) |
| 20 | Phase transitions Switching→Recovery | Phase change | OnPhaseChanged fires with (Recovery) |
| 21 | Phase transitions Recovery→Idle | Phase change | OnPhaseChanged fires with (Idle) |
| 22 | Interrupt() called during Windup | Interrupt | OnSwitchInterrupted fires with targetForm (the form being switched to) |
### Phase Capability Queries [L0]
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 23 | Phase=Windup | CanMove() queried | Returns true. GetMovementSpeedMultiplier() returns WindupSlowPercent (0.50). |
| 24 | Phase=Windup | CanAttack() queried | Returns false |
| 25 | Phase=Switching | CanMove() queried | Returns false |
| 26 | Phase=Switching | CanBeDamaged() queried | Returns false (i-frames) |
| 27 | Phase=Recovery | CanBeDamaged() queried | Returns false |
### Edge Case Coverage [L0]
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 28 | WindupDuration=0.10 (floor), Phase=Idle, energy≥cost | RequestSwitch(Wolf) + Update(dt) | Phase→Switching (Windup skipped at floor value). PhaseTimer=SwitchDuration. |
| 29 | CooldownDuration=0.15 (floor), Recovery→Idle transition | Update(dt) | CooldownTimer=0.15. CanSwitchTo() returns false until cooldown expires. |
| 30 | Phase=Windup, Die() called | Death during switch | Force reset: Phase=Idle, CurrentForm=Human. Energy NOT refunded. |
| 31 | deltaTime=0.0 | Update(0) | Phase unchanged, PhaseTimer unchanged, no events fire |
| 32 | deltaTime=0.3 (exceeds WindupDuration=0.25) | Update(0.3) | Effective dt clamped to WindupDuration — at most one phase transition. Phase→Switching, PhaseTimer=SwitchDuration. |
| 33 | Two RequestSwitch(Wolf) calls in same frame (before Update) | Sequential calls | First returns Success (Phase→Windup). Second returns OnCooldown (Phase≠Idle). |
### Input Buffering [L0]
| # | GIVEN | WHEN | THEN |
|---|-------|------|------|
| 34 | Phase=Recovery, RecoveryTimer≤0.10 (within buffer window) | RequestSwitch(Wolf) | Input queued. When Recovery→Idle and cooldown expires, queued switch auto-executes. |
| 35 | Two different form requests buffered during same Recovery window | RequestSwitch(Wolf) then RequestSwitch(Mist) | Only most recent (Mist) is queued — buffer holds one input. |
| 36 | Phase=Idle, cooldown active, RecoveryTimer already 0 (outside buffer window) | RequestSwitch(Wolf) | Returns SwitchResult.OnCooldown — NOT buffered. Buffer only active during last 0.10s of Recovery. |
## Open Questions
- Should the player be able to buffer a switch request during Recovery (auto-execute after cooldown)? — **Yes**: implement a 0.10s buffer window at the end of Recovery. Any switch input during the last 0.10s of Recovery is queued and auto-executes when the cooldown expires. Short enough to require timing, long enough to prevent dropped-input frustration. The buffer holds exactly one input (most recent wins).
- Should Windup slow use a configurable curve (linear vs ease-in)? — MVP uses linear; revisit for polish.
- Should the architecture support a 4th form in the future? — Yes, `FormType` enum is extensible. Skill Tree GDD should reserve an expansion node.

View File

@ -0,0 +1,9 @@
# Combat Logic — Review Log
## Review — 2026-04-27 — Verdict: NEEDS REVISION (revised same session)
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.

View File

@ -0,0 +1,23 @@
# Review Log: Form Switch State Machine
---
## Review — 2026-04-27 — Verdict: NEEDS REVISION (first review)
**Scope signal**: M
**Specialists**: game-designer (critic), systems-designer (critic), qa-lead (critic), gameplay-programmer (general-purpose), unity-specialist (general-purpose), creative-director (general-purpose)
**Blocking items**: 6 | **Recommended**: 8
**Prior verdict resolved**: First review
**Summary**: The core 4-phase FSM design is sound, but the GDD carried six blocking issues that made it unsafe to implement as written. The critical finding was an AttackStyle data conflict with the Combat Logic GDD — 5 of 6 parameters (damage values, shape geometry) disagreed between the two documents. Additional blocking issues: self-contradictory minimum interval guard (Skill Tree could reduce to 0.15s vs stated ≥0.40s floor), no input buffering in a 0.45s dead zone, ambiguous cooldown state representation, three conflicting API signatures across documents, and GetAttackStyle() missing from the architecture contract. The creative-director noted the concept is solid and needs a focused revision pass, not a rewrite.
---
## Review — 2026-04-27 — Verdict: APPROVED (revised, same session)
**Scope signal**: M
**Specialists**: (revision pass — no re-review agents spawned)
**Blocking items**: 0 | **Recommended**: 0 (all addressed)
**Prior verdict resolved**: Yes — all 6 NEEDS REVISION blockers resolved
**Summary**: All six blocking items were resolved in a single revision pass. AttackStyle values were reconciled with Combat Logic GDD (Combat Logic is canonical source for numbers). Hard floors added (WindupDuration ≥ 0.10s, CooldownDuration ≥ 0.15s) enforcing the ≥0.40s interval guard. 0.10s input buffer window added at end of Recovery. Cooldown state clarified with CanSwitchTo() as authoritative query. API standardized to constructor-injected IBloodEnergyEconomy. GetAttackStyle() contract aligned. ACs expanded from 12 to 36 covering all phase transitions, events, phase capabilities, edge cases, and input buffering. GDD is ready for implementation.

View File

@ -19,8 +19,8 @@
| # | 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 | Designed | [GDD](combat-logic.md) | — |
| 3 | Form Switch State Machine | L0 | Combat Logic | MVP | Not Started | — | Blood Energy Economy |
| 2 | Combat Logic | L0 | Combat Logic | MVP | Reviewed | [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 |
| 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 |
@ -144,18 +144,20 @@
| Metric | Count |
|--------|-------|
| Total systems identified | 17 |
| Design docs started | 2 |
| Design docs reviewed | 0 |
| Design docs approved | 1 |
| MVP systems designed | 1/14 |
| Design docs started | 3 |
| Design docs reviewed | 2 |
| Design docs approved | 2 |
| MVP systems designed | 3/14 |
| Vertical Slice systems designed | 0/3 |
---
## Next Steps
- [ ] Design MVP-tier systems in dependency order — start with Blood Energy Economy
- [ ] Run `/design-system Blood Energy Economy` to author the first GDD
- [x] Design MVP-tier systems in dependency order — Blood Energy Economy and Combat Logic designed
- [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)
- [ ] Run `/prototype form-switch-state-machine` on the highest-risk system early
- [ ] Run `/design-review` on each completed GDD
- [ ] Run `/design-review` on remaining GDDs
- [ ] Run `/gate-check pre-production` when all MVP GDDs are authored and reviewed