18 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, 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: 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, andKnockbackForcewere added per Combat Logic GDD contract note (revised 2026-04-27).Rangeremoved — attack range is derived from HitShape (Circle.Radius for Mist, Rect.Length for Wolf, Sector.Radius for Human).
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, 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]
| # | 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.