一、项目概述
一个从空白 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 预制体激活,播完自动回池。需配置:
ComboData 勾选 App Audio Prefab + 设 Universal Sound(SoundStyle 枚举值)
SFX_PoolManager.Sound Pools 列表中注册对应 SoundStyle + Sound Prefab + Sound Count
- 动画关键帧上加
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 过滤。