247 lines
18 KiB
Markdown
247 lines
18 KiB
Markdown
# 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, 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`, and `KnockbackForce` were added per Combat Logic GDD contract note (revised 2026-04-27). `Range` removed — 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 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, 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, `FormType` enum is extensible. Skill Tree GDD should reserve an expansion node.
|