17 KiB
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 (
IBloodEnergyEconomyinterface) — follows project DI standard.RequestSwitchtakes onlyFormType 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 colorOnPhaseChanged(Windup): Form flicker + color lerp from current to targetOnPhaseChanged(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 droneOnSwitchInterrupted: 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,
FormTypeenum is extensible. Skill Tree GDD should reserve an expansion node.