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

一个从空白 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.cs720自动生成的 C# 包装类,内嵌 JSON 绑定定义
Input/CharacterInputSystem.cs127单例,封装 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.csOnAnimationTranslateEvent 根据枚举分发到 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.cs330核心连招逻辑:输入处理、动画播放、伤害触发
CharacterCombo.cs241角色特殊逻辑:闪A、技能、QTE、敌人检测
ComboContainerData.cs194ScriptableObject,多段连招容器
ComboData.cs50ScriptableObject,单段攻击数据
PlayerComboSOData.cs14角色五类攻击的顶层入口
PlayerComboReusableData.cs24运行时状态(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 方案演进

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

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

最终方案

 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.csAttackTrigger() → 距离角度判定 → 发事件
GameEventManager.cs类型安全泛型事件调度
CharacterHealthBase.csOnCharacterHitEventHandler → 调子类的动画/伤害分支
CharacterHeath.csCharacterHitAction → 霸体判断 → 播受击或格挡

完整链路

 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.csMonoBehaviour,定义角色特效列表
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.csScriptableObject,Clip 总库
CharacterComboBase.csPlayOneShot + 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.csQTE 倒计时 + 候补角色头像 + 滑入滑出动画
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 池并发 BugTryGetSoundPool 还保留了立即 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 配置

组件位置配置项
PlayerPlayer PrefabPlayerSO, PlayableDirector(如用 Timeline), Animator
PlayerSOPlayer.ScriptMovementData, 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 曲线
VCamPlayer 子物体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 存在
SwitchTimeUIUIManager 子物体SeeleImage, StickImage 拖入 Sprite
Enemy敌人 PrefabCharacterHeath, 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 设计