对战斗系统进行一个基础的拆解

一个从空白 Unity 工程开始搭建的 3D 动作战斗原型

一、项目概述

一个从空白 Unity 工程开始搭建的 3D 动作战斗原型。约 6,800 行 C#,88 个脚本文件,覆盖 13 个模块。所有系统从零设计实现,不使用战斗中间件或现成框架。

已实现的战斗功能:

  • 基于连招链的轻攻击(左键)和技能/大招(E/Q 键)
  • Shift 闪避→冲刺链路,支持闪避中触发的特殊攻击(闪A)
  • 双角色切换(Tab 普通切换 / QTE 连携切人)
  • 霸体/耐力值分支的受击与格挡动画
  • 自动敌人锁定与攻击判定(SphereCast + 距离角度过滤)

已实现的辅助系统:

  • Cinemachine + Timeline 技能相机(Dolly 路径推近变焦)
  • ComboData→ComboContainerData→PlayerComboSOData 三层 ScriptableObject 数据驱动
  • 事件系统解耦伤害链路
  • VFX/SFX 对象池
  • Timer 对象池计时器
  • CinemachineImpulse 震屏 + Animator.speed 顿帧(实现但调用被注释)

二、架构总览

设计核心理念

两个相互独立的有限状态机并行运转:

1
2
3
Player (MonoBehaviour)
  ├── movementStateMachine   ← 移动 FSM(待机/行走/奔跑/冲刺/闪避/转身…)
  └── comboStateMachine      ← 连招 FSM(Null/ATKIng/Skill)

两者通过 OnAnimationTranslation(Unity StateMachineBehaviour)联动,但互不包含引用——联动仅通过 Player 层的 OnAnimationTranslateEvent 中转。

事件系统的角色

所有伤害判定、特效、音效通过 GameEventManager(类型安全的泛型 Action 封装)广播。攻击者只管发事件,不管谁接。敌人 Health 组件监听 "触发伤害" 事件,做伤害、动画、特效、音效四件事。

数据流向

1
2
3
4
5
输入 → 状态机 → ComboData取值 → CrossFade动画
                    ↓
       动画事件ATK() → 伤害判定 → GameEventManager → 敌人Health → 受击反馈
       动画事件PlayComboFX() → SFX_PoolManager → 音效
       CameraSwitcher + SkillTimelineManager → 相机控制

三、输入系统

文件

文件 行数 说明
Input/CharacterInput.inputactions — Input Action Asset,定义所有输入绑定
Input/CharacterInput.cs 720 自动生成的 C# 包装类,内嵌 JSON 绑定定义
Input/CharacterInputSystem.cs 127 单例,封装 PlayerMove、L_Atk、R_Atk、Run 等属性访问

按键绑定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Player Action Map:
  L_AtK       → <Mouse>/leftButton       轻攻击
  R_Atk       → <Mouse>/rightButton      重攻击(代码未接线)
  Movement    → WASD / 左摇杆            移动
  Run         → <Keyboard>/shift         闪避/冲刺
  Execute     → <Keyboard>/e             技能
  FinishSkill → <Keyboard>/q             终极大招

SwitchSkill Action Map:
  L           → <Mouse>/leftButton       QTE选左
  R           → <Mouse>/rightButton      QTE选右

上下文切换

QTE 切人时,TriggerSwitchSkill() 第 142 行禁用 Player Action Map,CharacterCombo.StartSwitchSkill() 第 166 行注册 SwitchSkill Map。左键从攻击变为选人,右键同理。取消或完成切人后恢复 Player 映射。

按键到事件链路

1
2
3
4
5
按下左键 → Input System → L_AtK.started
  → PlayerComboState.OnAttackInput(InputAction.CallbackContext)
    → 当前角色校验 → CanBaseComboInput(检查canInput + 不在Hit/Parry/Skill)
      → 冲刺/闪避中? → DodgeComboInput
      → 普通状态   → LightComboInput

四、动画状态机与事件路由

文件

文件 说明
OnAnimationTranslation.cs 挂在 Animator Controller 每个动画状态上,负责进入/退出时通知 Player
Player.cs OnAnimationTranslateEvent 根据枚举分发到 movement / combo FSM

OnAnimationTranslation 设计

1
2
3
4
5
6
7
public enum OnEnterAnimationPlayerState
{
    Idle, Walk, Run, Sprint,
    Dash, DashBack, TurnBack,
    Switch, SwitchOut,
    ATK, Null
}

每个 Animator 动画状态上挂一个 OnAnimationTranslation,Inspector 中选对应的枚举值:

  • Dash / DashBack → 闪避动画
  • ATK → 普通攻击动画
  • Null → 技能动画(进入时不触发任何逻辑,退出时走 default 分支两者都调)
  • Switch / SwitchOut → 切人进出场

OnStateExit 分流逻辑(最终版)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
switch (onEnterAnimationState)
{
    case Dash / DashBack / TurnBack / Switch / SwitchOut
         / Idle / Walk / Run / Sprint:
        player.OnMovementAnimationExitEvent();   // 只调 movement
        break;

    case ATK:
        player.OnMovementAnimationExitEvent();   // 调 movement(恢复移动)
        player.OnComboAnimationExitEvent();      // 调 combo(退出攻击状态)
        break;

    default:  // 含 Null(技能动画)
        player.OnAnimationExitEvent();            // 两者都调(向后兼容)
        break;
}

踩过的坑

中间版本 ATK 分支只调了 combo 忘了调 movement → 攻击结束卡死在 PlayerMovementNullState 无法移动。修复后发现翻滚退出也同时触发了 combo FSM → 技能被打断。最终版加 default 分支向后兼容 Null 状态的技能动画,Dash 系列只调 movement。


五、连招系统

核心文件

文件 行数 职责
CharacterComboBase.cs 330 核心连招逻辑:输入处理、动画播放、伤害触发
CharacterCombo.cs 241 角色特殊逻辑:闪A、技能、QTE、敌人检测
ComboContainerData.cs 194 ScriptableObject,多段连招容器
ComboData.cs 50 ScriptableObject,单段攻击数据
PlayerComboSOData.cs 14 角色五类攻击的顶层入口
PlayerComboReusableData.cs 24 运行时状态(comboIndex、canInput、canATK等)

普攻流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
左键 → LightComboInput()
  ├── currentCombo = comboData.lightCombo     ← 设为轻攻击链
  ├── ResetComboDates()                        ← 恢复第一段(防止闪A残留)
  └── ExecuteBaseCombo()
        ├── hasATKCommand = true               ← 攻击指令排队
        └── canInput = false                   ← 锁输入

下一帧 UpdateComboAnimation() 消费指令:
  ├── comboName = currentCombo.GetComboName(comboIndex)
  ├── CrossFade(comboName, 0.111f)            ← 播放动画
  ├── PlayCharacterVoice / PlayWeaponSound     ← 播放音效
  └── comboIndex++, hasATKCommand=false, canATK=false

预输入窗口

动画事件的 EnablePreInput 在攻击动画结束前设 canInput=true。玩家在这个窗口内再按左键 → comboIndex++ → 下一段攻击。DisableLinkCombo 在最后一段攻击中关闭 canLink=false → 连招链终止。

三层数据驱动

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
ComboData.asset          → 单段攻击的所有参数
  ├── comboName: "ATK_01"
  ├── comboDamage: 15
  ├── hitName[]: "Hit_Front", "Hit_Back"
  ├── parryName[]: "Parry"
  ├── ATKCount: 2
  └── shakeForceList: [0.3, 0.5]

ComboContainerData.asset → 多段连招链
  └── comboDates[]: [ComboData_01, ComboData_02, ...]
      └── DodgeATKData: 闪A替换第一段

PlayerComboSOData        → 角色的五类攻击
  ├── lightCombo
  ├── heavyCombo
  ├── executeCombo
  ├── skillCombo
  ├── finishSkillCombo
  └── switchSkill

comboIndex 和 ATKIndex

  • comboIndex:当前连招段索引(0→1→2→0 循环)
  • ATKIndex:段内伤害判定序号(从 1 开始),用于读取 ShakeForceList 和 PauseFrameTimeList。每次 ATK() 动画事件触发时 UpdateATKIndex() 自增。comboIndex 变化时通过 BindableProperty.OnValueChanged 自动回调 ReSetATKIndex 清零。

闪A

闪避/冲刺中按左键 → PlayerComboState.OnAttackInput 检测 currentMovementState == "Sprinting" || AnimationAtTag("Dodge") → DodgeComboInput() → ComboContainerData.SwitchDodgeATK() 把第一段攻击替换为 DodgeATKData。


六、技能系统

流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
按 E → OnSkill()
  → CanSkillInput()         ← 不在Hit/Parry/Skill/ATK中
  → SkillInput()
    → currentSkill = comboData.skillCombo
    → ExecuteSkill()
      → ReSetATKIndex(0)    ← 手动清零伤害判定序号
      → PlayCharacterVoice(currentSkill) / PlayWeaponSound(currentSkill)
      → CrossFadeInFixedTime(comboName, 0.1f)
    → ChangeState(SkillState)

SkillState.Enter:
  → movement → playerMovementNullState    ← 禁止移动
  → CameraSwitcher.ActiveStateCamera      ← 激活技能相机
  → SkillTimelineManager.PlaySkillTimeline ← 播放 Timeline 推近变焦

动画播完 → OnAnimationExitEvent → ChangeState(NullState) → 恢复

与普攻共用 AttackTrigger

1
2
3
4
AttackTrigger():
  if   animTag("ATK")   → AttackDetection(currentCombo)      → 普通攻击
  elif animTag("Skill") → SkillDetection(currentSkill)        → 技能
  else                  → AttackDetection(executeCombo)       → 处决

技能和普攻共用同一个 ATK() 动画事件方法。Animator 的 Tag 区分当前是什么类型的动画,走不同的检测和数据源。


七、相机系统

核心文件

文件 说明
CameraSwitcher.cs 单例,管理 VCam Priority(激活/关闭)
SkillTimelineManager.cs 单例,管理 (角色, 技能类型) → PlayableDirector 映射
CameraHitFeel.cs 震屏、顿帧、慢动作
VCamDollyHelper.cs 暴露 Dolly 的 PathPosition 给 Animation Track
OnAnimationTranslation.cs 技能动画退出时通知状态机
PlayerSkillState.cs 进入/退出技能时调 CameraSwitcher + SkillTimelineManager

CameraSwitcher 设计

1
2
3
4
5
stateCameraPool[CharacterNameList][AttackStyle] → CinemachineVirtualCameraBase

ActiveStateCamera(Skikk)   → Priority = 20
UnActiveStateCamera()      → Priority = 0
UnActiveAllStateCameras()  → 遍历该角色全部VCam → Priority=0(切人时用)

StateCameraInfo.stateCamera 最终使用基类 CinemachineVirtualCameraBase,支持直接拖入普通 VCam 或 CinemachineStateDrivenCamera。

Timeline 方案演进

整个过程走了六种方案,每种都有具体的失败原因:

尝试 方案 失败原因
1 Cinemachine Track(编辑器) Signal Emitter 在 Animator State 上搜不到
2 Animation Event + Object 参数 动画事件的 Object 字段不支持拖场景对象
3 Prefab Mode + Animation Track ExposedReferences 在 Prefab 上下文丢失
4 场景多 Director + 动画事件 同上,场景对象引用在 .anim 的 AnimationEvent 中不可序列化
5 PlayableDirector 放角色 Prefab + VCam 子物体 GetGenericBinding 返回 NULL,Prefab 实例化后绑定丢失
6 SkillTimelineManager 运行时自动补绑 ✅ 最终方案

最终方案

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// SkillTimelineManager.PlaySkillTimeline():
foreach (var track in timeline.GetOutputTracks())
{
    if (track is AnimationTrack && director.GetGenericBinding(track) == null)
    {
        var vcam = playerRoot.GetComponentInChildren<CinemachineVirtualCameraBase>();
        if (vcam != null)
            director.SetGenericBinding(track, vcam.gameObject);
    }
}

VCam 必须是场景中角色实例的子物体,名字无所谓。GetComponentInChildren 自动找到第一个,SetGenericBinding 补绑。CameraSwitcher 管 Priority 激活,Timeline Animation Track 管 PathPosition(Dolly 路径位置,一个 float 曲线)。

切人应急复位

SwitchCharacter.ExecuteSwitchCharacter 最前面两行:

1
2
SkillTimelineManager.MainInstance.StopAllForCharacter(旧角色);
CameraSwitcher.MainInstance.UnActiveAllStateCameras(旧角色);

先停 Timeline 不再写值,再强制 Priority=0 覆盖。

震屏兼容

每个技能 VCam 额外挂 CinemachineImpulseListener 组件,确保切到技能相机后 CameraHitFeel.CameraShake 的脉冲信号仍被接收。


八、角色切换系统

核心文件

文件 说明
SwitchCharacter.cs 单例,管理所有角色实例、候补列表、切人演出

两种切人

Tab 普通切人:

1
2
3
4
5
6
SwitchInput()
  → 旧角色名移入 waitingCharacterList,新角色移出
  → ExecuteSwitchCharacter(新角色, isSwitchATK=false)
    → 新角色在旧角色侧面 spawn → Play("SwitchIn")
    → 旧角色:攻击中? → 等播完 → CrossFade("SwitchOut") → 非攻击 → 立即退场
    → switchOutCharacterTime 后 SetActive(false) 旧角色

QTE 连携切人:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
连招满足条件 → TriggerSwitchSkill()
  → 禁用 Player Input
  → 激活切人相机 CameraSwitcher.ActiveSwitchCamera(true)
  → 0.3s后 Time.timeScale = 0.06(慢动作)
  → 0.2s后 StartSwitchSkill()
    → 3秒倒计时
    → 弹出 SwitchTimeUI(候补角色头像 + 定时)
    → 注册 SwitchSkill.L/R 输入
  → 玩家选人 → SwitchSkill(角色名)
    → CameraSwitcher.ActiveSwitchCamera(false)
    → 恢复时间 → 恢复输入
    → ExecuteSwitchCharacter(新角色, isSwitchATK=true)
      → 新角色从敌人生成位置登场 → 播切人大招动画

WaitAttackFinishAndExit 协程

1
2
3
4
5
6
7
8
IEnumerator WaitAttackFinishAndExit(SwitchCharacterInfo info, string switchOutAnim)
{
    while (info.animator.AnimationAtTag("ATK") || info.animator.AnimationAtTag("Skill"))
        yield return null;           // 每帧检查,攻击播完再继续

    info.animator.CrossFadeInFixedTime(switchOutAnim, 0.1f);
    StartDeactivateCoroutine();      // 延迟 switchOutCharacterTime 后隐藏
}

同时做了防护:如果同一角色被切出去后又切回来,ExecuteSwitchCharacter 中检查 waitAttackExitTarget == newCharacterInfo.character → StopCoroutine 取消旧协程,防止把退场动画覆盖到新登场动画上。

候补列表维护

1
2
3
4
5
6
// newCharacterName 变化时(BindableProperty.OnValueChanged)
OnSetWaitingCharacterList(newValue):
  waitingCharacterList.Remove(newValue);          // 新上场的角色移出
  waitingCharacterList.Add(currentCharacterName); // 旧角色移入(如果不在列表中)

// 效果:waitingCharacterList 永远只存"不在场"的角色

Collider 互忽略

1
2
3
4
IgnoreAllPlayerCollisions():
  遍历 switchCharacterInfos 所有两两组合
    GetComponentsInChildren<Collider>() × 2
      Physics.IgnoreCollision(ca, cb);    // 一对永久忽略

在 Start() 中调一次,Physics.IgnoreCollision 生命周期内有效。


九、伤害与霸体系统

核心文件

文件 说明
CharacterComboBase.cs AttackTrigger() → 距离角度判定 → 发事件
GameEventManager.cs 类型安全泛型事件调度
CharacterHealthBase.cs OnCharacterHitEventHandler → 调子类的动画/伤害分支
CharacterHeath.cs CharacterHitAction → 霸体判断 → 播受击或格挡

完整链路

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
动画关键帧 ATK() 事件触发
  → Player.ATK()
    → PlayerATKIngState.ATK()
      → CharacterComboBase.ATK()
        → AttackTrigger()

AttackTrigger():
  判断 animTag:
    "ATK":
      AttackDetection(距离<comboDistance && 角度<80°)
      → 取得 hitName/parryName(ComboData 数组中随机取一个字符串)
      → GameEventsManager.CallEvent("触发伤害",
          damage, hitName, parryName, attacker, bearer, combo)

GameEventManager → EventHandler<float,string,string,Transform,Transform,ComboBase>.CallBack
  → Action.Invoke(damage, hitName, parryName, attacker, bearer, combo)

CharacterHealthBase.OnCharacterHitEventHandler:
  if (Bearer != this.transform) return;         ← 过滤:只处理被击中的那个敌人
  → CharacterHitAction(Damage, HitName, ParryName)
    → CharacterHeath.CharacterHitAction():
        if (hasStrength)  → animator.CrossFade(parryName)  ← 格挡动画
        else              → animator.CrossFade(hitName)    ← 受击动画
  → OnCharacterDamageAction → TakeDamage/TakeStrength
  → SetHitFVX → VFX_PoolManager.GetVFX(Enemy, "Hit", 命中点)
  → SetHitSFX(被注释)

霸体=耐力槽

1
2
3
4
5
6
7
HasStrength = true:
  → TakeStrength(damage)        ← 扣耐力值
  → 播 parryName 格挡动画

HasStrength = false:
  → TakeDamage(damage)          ← 扣血量
  → 播 hitName 受击动画

耐力归零时触发 "达到QTE条件" 事件 → 通知攻击者可以 QTE 切人。

震屏与顿帧

代码已实现,调用在 AttackTrigger 中被注释(第 255、267、290、294、312 行):

1
2
// CameraHitFeel.MainInstance.CameraShake(shakeForce[ATKIndex-1]);
// CameraHitFeel.MainInstance.PF(pauseFrameTime);

恢复后,ComboData.ShakeForceList[] 和 PauseFrameTimeList[] 与 ATKCount 长度对齐。每次 ATK() 动画事件 → ATKIndex 自增 → 读取数组对应位置。


十、特效系统

核心文件

文件 说明
VFX_PoolManager.cs 单例,三级字典对象池
EffectItem.cs 挂预制体上,播放→定时回收
PoolItemBase.cs 池对象基类,OnEnable→Spawn / OnDisable→ReSycle
VFXItemData.cs MonoBehaviour,定义角色特效列表
VFXManager.cs 全局粒子速度控制器

三级字典结构

1
2
3
4
effectPool[CharacterNameList][VFXNameString] → Queue<GameObject>

例: effectPool[Seele]["Slash"]    → Queue(刀光×3)
    effectPool[Enemy]["Hit"]      → Queue(火花×5)

EffectItem 生命周期(已修复)

1
2
3
4
5
6
7
8
TryGetVFX → Dequeue(不活跃) → Init(角色,特效名) → SetActive(true)
  → OnEnable → Spawn → StartPlay
    → ParticleSystem.Play()
    → TimerManager.GetOneTimer(playTime, StartReCycle)

playTime到期 → StartReCycle → ReturnVFX(角色,特效名,go)
  → SetActive(false) → OnDisable → ReSycle → ParticleSystem.Stop()
  → Enqueue(回池)

修复过的 Bug

早期版本 Dequeue 后立即 Enqueue 回去——池中实例全部活跃时再次 Dequeue 拿到活跃对象,SetActive(true) 是空操作,粒子不启动。修复方案:对象只在播放完毕 ReturnVFX 时才回池,队列只含未活跃实例。

双 API

1
2
3
4
5
// 有父级(刀光附着武器骨骼,不改位置)
VFX_PoolManager.TryGetVFX(Seele, "Slash");

// 无父级(世界坐标生成火花)
VFX_PoolManager.GetVFX(Enemy, "Hit", worldPos, rotation);

受击特效点计算

1
2
Vector3 hitDir = (attacker.position - hitter.position).normalized;
Vector3 targetPos = hitter.position + hitDir * 0.8f + Vector3.up * 1f;

十一、音效系统

核心文件

文件 说明
SFX_PoolManager.cs 单例,两套词典(普通/按角色名分池)
SoundItem.cs 池化预制体组件,Spawn 时从 SoundData 取 Clip
ComboSFXtem.cs 池化预制体组件,Spawn 时从 ComboData 取 Clip
SoundData.cs ScriptableObject,Clip 总库
CharacterComboBase.cs PlayOneShot + PlayComboFX

两套播放路径

直配 PlayOneShot(语音+武器声):

在 UpdateComboAnimation() / ExecuteSkill() 中自动调用,从 ComboData._characterVoice[] 和 _weaponSound[] 随机取 Clip,通过角色 GameObject 上的 AudioSource.PlayOneShot 播放。ComboData 资产填好数组即可,不需要动画事件。

池化 SFX_PoolManager(3D 空间音效):

通过动画关键帧上的 PlayComboFX 事件触发,从 soundCenter 或 bigSoundCenter 取出 SoundItem 预制体激活,播完自动回池。需配置:

  1. ComboData 勾选 App Audio Prefab + 设 Universal Sound(SoundStyle 枚举值)
  2. SFX_PoolManager.Sound Pools 列表中注册对应 SoundStyle + Sound Prefab + Sound Count
  3. 动画关键帧上加 PlayComboFX() 事件

PlayComboFX 技能支持

普通攻击读 currentCombo,技能读 currentSkill。修复后的回退逻辑:

1
2
3
4
if (currentCombo != null)  → 取 currentCombo.GetComboSoundStyle()
else if (currentSkill != null && currentSkill.AppAudioPrefab)
  → 取 currentSkill.universalSound
else → return(静默)

池化路径的并发 Bug

早期 TryGetSoundPool 也是 Dequeue→SetActive→立即 Enqueue,和 VFX 同样 Bug。已添加 Count == 0 守卫防止队列耗尽时报错。更深层的并发修复与 VFX 同理,尚未完全同步改造。


十二、辅助系统

TimerManager(计时器对象池)

文件 说明
TimerManager.cs 单例,管理 _notWorkTimers 队列 + _isWorkingTimers 列表
GameTimer.cs 单个计时器,支持 deltaTime / unscaledDeltaTime

Update 中倒序遍历工作中的计时器,完成时回收:

1
2
3
4
GetOneTimer(float time, Action task)       // 受 timeScale 影响
GetTimer(float time, Action task)          // 同上,返回引用供取消
GetRealTimer(float time, Action task)      // 不受 timeScale 影响
UnregisterTimer(GameTimer timer)           // 手动取消

使用场景:闪避冷却 0.5s、QTE 超时 3s、缓冲到 Idle 0.15s、攻击动画退出后延时 0.2s 回 NullState。

GameBlackboard(全局黑板)

单例,存当前锁定敌人 BindableProperty<Transform> enemy 和所有角色引用 Dictionary<string, Player>。SetEnemy(Transform) 触发 OnValueChanged,Player 监听更新 Inspector 显示。

BindableProperty(响应式属性)

1
2
3
4
5
public class BindableProperty<T>
{
    public T Value { get; set; }
    public event Action<T> OnValueChanged;
}

使用处:enemy(黑板更新)、newCharacterName(候补列表维护)、currentIndex(comboIndex 变化时清零 ATKIndex)

自动追踪敌人

CharacterCombo.UpdateEnemy() 每帧 Physics.SphereCast(detectionOrigin, radius, dir, out hit, length, WhatIsEnemy) 扇形扫描,命中 → 更新 GameBlackboard.SetEnemy。

UpdateDetectionDir() 综合相机朝向 × 玩家移动输入计算扫描方向。UpdateAttackLookAtEnemy() 攻击中缓动朝向敌人(<6.5m 且 >0.09m)。


十三、ScriptableObject 数据体系

完整配置层次

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
PlayerSO.asset                    ← 角色总资产
  └── PlayerMovementData
        ├── bufferToIdleTime: 0.15s
        ├── turnBackAngle: 135°
        ├── PlayerDashData:      闪避动画名、冷却0.5s、淡入12帧
        ├── PlayerSprintData:    移动倍率3x
        ├── PlayerRunData / WalkData / IdleData
        ├── PlayerReturnRunData
        └── PlayerCameraRecenteringData: 相机居中角度范围

  └── PlayerComboData
        └── PlayerComboSOData
              ├── lightCombo: ComboContainerData.asset
              ├── heavyCombo: ComboContainerData.asset
              ├── executeCombo: ComboContainerData.asset
              ├── skillCombo: ComboData.asset (Skikk)
              ├── finishSkillCombo: ComboData.asset (FinishiSkill)
              └── switchSkill: ComboData.asset (SwitchSkill)

ComboContainerData.asset:
  comboDates[]: List<ComboData>       按顺序排列的攻击段
  DodgeATKData: ComboData             闪A替换第一段
  BackDodgeATKData: ComboData         后闪A替换第一段

ComboData.asset (每一段):
  动画: _comboName
  伤害: _comboDamage, _attackDistance, _comboOffset
  受击: _hitName[], _parryName[]
  音效: _weaponSound[], _characterVoice[], _universalSound, appAudioPrefab
  打击: _ATKCount, _shakeForceList[], _pauseFrameTime, _pauseFrameTimeList[]
  类型: attackStyle (Skikk/FinishiSkill/SwitchSkill)

运行时映射关系

1
2
comboIndex  → comboDates[comboIndex] → ComboData → comboName → CrossFade
ATKIndex    → ShakeForceList[ATKIndex-1] / PauseFrameTimeList[ATKIndex-1]

十四、UI 系统

核心文件

文件 说明
UIManager.cs 单例,自动查找子 UI
StateBarUI.cs 血条,World→Screen 定位 + fillAmount 更新
BloodPos.cs 挂在敌人上,每帧调 ShowAt 定位
SwitchTimeUI.cs QTE 倒计时 + 候补角色头像 + 滑入滑出动画
SwitchTimeCountDownUI.cs 空类,未实现

血条绑定

1
2
3
4
5
6
7
8
UIManager.Awake → transform.Find("UIRoot/State Bar") → StateBarUI

BloodPos.LateUpdate → UIManager.stateBarUI.ShowAt(敌人位置)
  → Camera.WorldToScreenPoint → UI 定位 + 距离缩放

CharacterHealthBase.OnUpdatePH → stateBarUI.UpdateBlood(HP/maxHP)
  → GreenBloodBar.fillAmount = percentage
  → Update(): if(Green < Red) Red -= Time.deltaTime * 0.05(红条缓退)

QTE 倒计时选人 UI

1
2
3
4
5
6
7
8
9
// ActiveImage(List<CharacterNameList>, float time)
// 1人: R_Image 隐藏,只显示 L
// 2人: 双头像显示
// 0人: 直接 return

// MatchImage(CharacterNameList) → 返回 Sprite(Seele/Stick)

// UnActive() → 协程 MoveUI 滑出 → SetActive(false)
// MoveUI(Vector3 src, Vector3 dst, float speed, bool canUnActive)

修复过:旧版 ActiveImage 固定取 waitingCharacterList[0] 和 [1],单角色时越界崩溃。改版为接收 List 参数,调用方 StartSwitchSkill 检查 Count 后再传。


十五、已知薄弱点

代码层

问题 影响 修复方向
重攻击未接线 R_Atk.started += OnHeavyAttackInput 没有在 AddInputActionEvent 中注册 注册回调一行即通
震屏/顿帧调用被注释 AttackTrigger 中 5 处 CameraHitFeel 调用被注释 取消注释,确认 CinemachineImpulseListener 挂到所有 VCam
SFX 池并发 Bug TryGetSoundPool 还保留了立即 Enqueue 的设计,池耗尽后失效 参照 VFX 的修复方案,加 ReturnSFX 延迟回池
Timeline 绑定依赖代码补绑 每次 Play 前 SetGenericBinding 遍历查找 可接受但不够优雅,Inspector 绑定可直接解决
硬编码枚举 CharacterNameList 和 AttackStyle 写死,加角色需改代码 改为 ScriptableObject 配置表
CameraHitFeel.PF/SlowMotion 依赖 CharacterManager 单例可能在初始化时未就绪 改为事件回调或 Start 中延迟初始化
SkillCombo.cs 的 DodgeComboInput 用 switch 逐角色判断 加角色需补 case 合并为统一逻辑

架构层

问题 影响 修复方向
Movement 和 Combo FSM 无显式协调协议 靠 Player 层中转,隐式依赖正确 引入状态协调接口
Timeline 和 CameraSwitcher 各管一半 理解成本高,配置易遗漏 封装为一个 SkillCameraController
对象池缺少预热/扩容机制 高并发时特效/音效静默失败 加 EnsureCapacity 自动扩容或返回警告

功能层

问题 说明
仅支持 2 角色切换 加角色需改枚举、补 CameraSwitcher、补 DodgeComboInput 分支等
无网络/多人 单机 Demo
无存档 所有数据在内存
无战斗 UI 完整实现 SwitchTimeUI.MatchImage 只有 2 个角色头像映射
闪避冷却无视慢动作 GetOneTimer 受 timeScale 影响,0.06x 时冷却几乎永久
血条不支持多敌人 只有一个 StateBarUI,多敌人无法各自显示

十六、配置清单(附录)

必须的 Inspector 配置

组件 位置 配置项
Player Player Prefab PlayerSO, PlayableDirector(如用 Timeline), Animator
PlayerSO Player.Script MovementData, ComboData 拖入对应 SO 资产
ComboData 每个攻击段 AttackStyle, ComboName, ComboDamage, HitName[], ParryName[], ATKCount, ShakeForceList[], 音效数组
ComboContainerData 每个连招链 comboDates[]顺序拖入,DodgeATKData, BackDodgeATKData
CameraSwitcher 场景单例 State Camera Info List: CharacterName + AttackStyle + State Camera(VCam)
SkillTimelineManager 场景单例 Entries: CharacterName + AttackStyle + Director
PlayableDirector 每个技能 playableAsset = .timeline 文件
Timeline .timeline 资产 Animation Track → VCam → 录制 PathPosition 曲线
VCam Player 子物体 Body=Tracked Dolly, Path=SmoothPath, VCamDollyHelper, CinemachineImpulseListener
SFX_PoolManager 场景单例 Sound Pools: SoundStyle + SoundPrefab + Count
VFX_PoolManager 场景单例 Effect Dates: Style + VFXItemData
VFXItemData 场景对象 effectItems: VFXName + VFXPrefab + count + applyParentPos
SwitchCharacter 场景单例 SwitchCharacterInfos[]: CharacterName + Character + AimAtPos + FollowAtPos + SpawnDistance
UIManager 场景单例 子路径 UIRoot/State Bar + UIRoot/Switch Time 存在
SwitchTimeUI UIManager 子物体 SeeleImage, StickImage 拖入 Sprite
Enemy 敌人 Prefab CharacterHeath, BloodPos, CharacterHealthInfo
Animator Controller 角色/敌人 Animator 每个动画状态挂 OnAnimationTranslation 选对应枚举,Tag 设好(ATK/Skill/Hit/Parry/Dodge/Movement)

项目持续迭代中。当前版本(2026-06)核心战斗循环完整可运行,相机系统 Timeline 方案稳定,切人、伤害、特效、音效路径闭环。已知薄弱点见第十五节。欢迎提 Issue 或直接 PR。


十七、补充细节

根运动(Root Motion)复用

CharacterMoveControllerBase.OnAnimatorMove() 根据当前动画 Tag 决定位移倍率:

1
2
3
if   (AnimationAtTag("Movement")) → Move(dir * deltaTime * moveMult)
elif (AnimationAtTag("Dodge"))    → Move(dir * deltaTime * dodgeMult)
// 其他 Tag 不移动

同一个移动逻辑通过 Tag 复用,不需要每个状态单独写一段位移代码。闪避动画设 "Dodge" Tag 走 dodge 倍率,步行/奔跑/冲刺动画设 "Movement" Tag 走各自的 inputMult 倍率。

ExpandClass.AnimationAtTag 扩展方法

整个项目中最高频的辅助方法,一行代码:

1
2
public static bool AnimationAtTag(this Animator animator, string tagName, int layerIndex = 0)
    => animator.GetCurrentAnimatorStateInfo(layerIndex).IsTag(tagName);

出现在:CanBaseComboInput 的不在 Hit/Parry/Skill 中才能攻击判断、AttackTrigger 的三路分支、PlayerMovementNullState.CheckStateExit 的"攻击中不能退出"守卫、CharacterHealthBase.LookAtAttacker 的"受击中才转脸"判断。Tag 在 Animator Controller 中设,不写在代码枚举里,加新 Tag 改 Animator 即可。

AnimatorID 参数哈希

1
2
3
public static readonly int MovementID  = Animator.StringToHash("Movement");
public static readonly int HasInputID  = Animator.StringToHash("HasInput");
public static readonly int TurnBackID  = Animator.StringToHash("TurnBack");

字符串哈希避免每帧字符串比较的 GC。MovementID 被 HandInput() 每帧 SetFloat 驱动 Blend Tree;HasInputID 切换待机/移动动画混合;TurnBackID 触发转身动画。

PlayerMovementNullState.CheckStateExit 守卫

攻击/技能中,这个守卫防止动画退出时误切移动状态:

1
2
3
4
5
6
7
8
void CheckStateExit()
{
    if (AnimationAtTag("ATK") || AnimationAtTag("Skill"))
        return;   // 攻击还没播完,不退

    if (PlayerMove != zero) → ChangeState(runningState)
    else                    → ChangeState(idlingState)
}

没有这个守卫,每次攻击动画退出过渡都会触发移动状态迁移,导致攻击连不上下一段。

移动状态链

1
2
3
4
Idle ←→ Walk ←→ Run ←→ Sprint
  ↑                      ↑
  └── Dash退出有方向 ────┘
  └── Dash退出无方向 → Idle

各状态通过 inputMult 控制 Blend Tree 的 Movement 参数(Walk=1, Run=2, Sprint=3),同一个混合树复用不同速度。

VFXManager 全局粒子速度控制

1
2
3
public void PauseVFX()    → 所有粒子 simulationSpeed = 0         ← 顿帧用
public void SetVFXSpeed(f) → 所有粒子 simulationSpeed = f          ← 慢动作用
public void ResetVXF()    → 所有粒子恢复 speedMult                 ← 恢复

EffectItem.Awake() 把自己挂载的 ParticleSystem 注册到 VFXManager.allParticleSystems 列表。全局速度控制不用遍历场景,直接用列表批量设。

相机水平居中(Recentering)

PlayerCameraUtility.EnableRecentering(float waitTime, float recenteringTime) 操作 CinemachinePOV.m_HorizontalRecentering。PlayerMovementState 根据 WASD 方向 + 相机垂直角度查 PlayerCameraRecenteringData 配置表,决定是否启用居中。松键时 Movement.canceled 停用。

相机遮挡避免

TP_CameraController_Pus.cs:射线检测角色到相机的路径,被遮挡时拉近相机避免穿模。

CharacterComboBase.AttackDetection 三条件

1
2
3
4
GetEnemy() == null      → return false
距离 > comboDistance     → return false
角度 > 80°               → return false
全部通过                 → return true

和敌人检测(SphereCast 锁定)不是同一个方法。检测负责"找到敌人",判定负责"当前能不能打到"。两者独立,一个在 UpdateEnemy 扫描,一个在 AttackTrigger 过滤。

不嘻嘻
使用 Hugo 构建
主题 Stack 由 Jimmy 设计