Vampire-Act-Base/design/gdd/form-switch-state-machine.md

17 KiB
Raw Blame History

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.