# 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.15–0.35s | The "danger window" — defines switch feel | | `SwitchDuration` | 0.10s | 0.05–0.15s | I-frame generosity | | `RecoveryDuration` | 0.15s | 0.10–0.25s | Re-entry smoothness after switch | | `CooldownDuration` | 0.30s | 0.15–0.50s | Switch rhythm spacing | | `WindupSlowPercent` | 0.50 | 0.30–0.70 | Mobility during Windup | | Human `BaseDamage` | 18 | 10–25 | Human form reward | | Wolf `BaseDamage` | 22 | 15–35 | Wolf form reward | | Mist `BaseDamage` | 8 | 4–15 | 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.