Unity3D ACT游戏战斗框架:状态机、连招表与打击感调优

Unity3D ACT游戏战斗框架:状态机、连招表与打击感调优 简介该资源为基于Unity3D的ACT游戏设计与实现全套毕业设计资料面向游戏开发方向的高校学生、初学Unity的开发者以及需要完成课题答辩的同学可解决选题落地、技术选型与代码实现等实际难题。压缩包共1个docx文件约11.34MB内含完整论文正文及配套源码说明论文围绕Unity引擎、C#语言展开涵盖UGUI与Text Mesh Pro界面搭建、Cinemachine虚拟相机、有限状态机驱动的角色行为与动画切换、物理系统碰撞检测以及ScriptableObject武器切换系统等关键模块并配有绪论、开发工具介绍、游戏设计、测试优化等章节结构。目前已有192人学习下载读者可据此梳理一套以战斗为核心的2D动作游戏的完整开发脉络理解各功能模块如何协同工作同时借鉴低配置设备上的性能优化思路与测试方法对撰写毕业论文或搭建个人Demo均有较高参考价值。1. 从零认识基于 Unity3D 的 ACT 游戏手感为什么比画面更难做很多人拿到 ACT 游戏的选题第一反应是找资源堆场景结果跑起来角色滑步、攻击打空、连招吞输入、镜头乱甩。ACT 这类游戏的难点从来不在画面而在「输入→状态→动画→判定→反馈」这条链路的时序精度玩家按键到角色出招之间差 50 毫秒手感就会从「利落」变成「黏手」。基于 Unity3D 的 ACT 游戏的设计与实现真正需要交付的是一套参数可调的战斗框架——角色控制器、有限状态机、连招表、攻击判定盒、顿帧与锁定相机美术资源只是挂在框架上的皮。这份内容适合三类人做课程设计或毕业设计、需要一份能跑起来可改的 Unity3D 游戏源码当底子的人第一次写动作游戏状态机、搞不清前摇后摇和取消帧的 Unity 开发者以及想让原型三个月内可玩、而不是先纠结美术的人。下面按控制层、战斗层、工程落地、调优验证的顺序推进所有参数给可改的默认值代码可以整段抄进工程再按自己的节奏改。2. Unity3D 角色控制与输入层移动、转向、镜头的最小可用组合控制层是 ACT 手感的地基。这一层做不好后面连招表调得再细也是白费角色走路带滑步攻击时转向对不上敌人镜头在墙角穿模玩家第一时间就会觉得「这游戏没做完」。所以先把三个模块定死——位移与重力的求解方式、输入的缓存策略、相机的跟随与锁定。2.1 CharacterController 与 Rigidbody 的选型以及模型导入的缩放坑ACT 主角一般不用全物理的 Rigidbody 自由受力因为被弹开、被击退、贴墙滑行这些行为需要精确控制物理引擎的碰撞响应反而不可预期。常见做法是二选一CharacterController 自带斜坡、台阶、碰撞滑动的处理写起来最省事但不走物理引擎被击退要靠代码位移另一种是 Rigidbody 设成 Kinematic用 MovePosition 驱动好处是能和物理查询、触发器、刚体敌人统一在同一套物理世界里代价是要自己处理贴地检测。方案适用场景代价CharacterController单机动作游戏、玩家角色、需要精确贴地不参与物理击退需手写位移RigidbodyKinematic需要和物理敌人、可推动物体交互贴地、台阶要自己写射线检测RigidbodyDynamic载具、被击飞的杂兵输入响应有延迟不适合主角另一个高频踩坑点是模型导入。从 Blender、3ds Max 或机械类软件导出的 FBX单位经常是毫米或厘米进 Unity3D 后角色高一百多米重力参数、相机裁剪面、碰撞胶囊全部对不上。在模型的 Import Settings 里把 Scale Factor 设成 0.01或者导出时就按 1 单位 1 米建模然后在 Prefab 上把胶囊体高度对齐到实际身高这一步不定死后面所有数值调参都是在错误基准上做。位移代码本身很短但转向必须用 RotateTowards 而不是 Slerp前者角速度恒定帧率波动时表现一致// Assets/Scripts/Player/PlayerMotor.cs using UnityEngine; [RequireComponent(typeof(CharacterController))] public class PlayerMotor : MonoBehaviour { public float moveSpeed 5.5f; // ACT 主角常见 4~7 米/秒 public float turnSpeed 900f; // 度/秒出招瞬间转向靠它兜底 public float gravity -22f; public float jumpSpeed 7f; public Transform cameraRoot; // 相机根节点用来把输入转到相机空间 private CharacterController _cc; private float _verticalVel; private void Awake() _cc GetComponentCharacterController(); private void Update() { Vector2 raw new Vector2(Input.GetAxisRaw(Horizontal), Input.GetAxisRaw(Vertical)); Vector3 inputDir new Vector3(raw.x, 0f, raw.y); if (inputDir.sqrMagnitude 1f) inputDir.Normalize(); // 斜向不加速 Vector3 camF Vector3.ProjectOnPlane(cameraRoot.forward, Vector3.up).normalized; Vector3 camR Vector3.ProjectOnPlane(cameraRoot.right, Vector3.up).normalized; Vector3 worldDir (camF * inputDir.z camR * inputDir.x).normalized; if (worldDir.sqrMagnitude 0.01f) { Quaternion target Quaternion.LookRotation(worldDir); transform.rotation Quaternion.RotateTowards( transform.rotation, target, turnSpeed * Time.deltaTime); } if (_cc.isGrounded _verticalVel 0f) _verticalVel -2f; // 贴地时留一点下压 _verticalVel gravity * Time.deltaTime; Vector3 motion worldDir * (moveSpeed * Time.deltaTime); motion.y _verticalVel * Time.deltaTime; _cc.Move(motion); } }这里的三个参数值得单独说moveSpeed 决定走位节奏ACT 里一般比射击游戏慢4 到 7 之间比较稳turnSpeed 给到 900 度/秒是因为攻击起手瞬间角色要能立刻转向锁定目标太慢会打出「背对敌人挥刀」的画面gravity 给到 -22 而不是 -9.8是为了让跳跃落地干脆接近动作游戏的体感。_verticalVel在贴地时压到 -2 而不是归零是让 CharacterController 的 isGrounded 稳定返回 true避免走下缓坡时出现一跳一跳的抖动。2.2 输入缓冲ACT 里 200 毫秒的宽容窗口怎么写输入缓冲解决的是连招吞招问题。玩家在攻击后半段按下一次攻击如果状态机此刻还在后摇、直接丢弃这次输入玩家就会觉得「我明明按了」。做法是把按键事件先塞进一个带时间戳的队列状态机在允许起手的那一刻去队列里取取不到才回到待机。窗口大小是核心参数小于 0.15 秒快节奏连点依然会丢大于 0.35 秒玩家停手后角色会自己莫名其妙多打一下。0.2 到 0.3 秒是比较通用的区间。// Assets/Scripts/Input/InputBuffer.cs using System.Collections.Generic; using UnityEngine; public class InputBuffer : MonoBehaviour { private const float Window 0.25f; // 宽容窗口按手感在 0.2~0.3 之间调 private struct Entry { public string Action; public float Stamp; } private readonly QueueEntry _queue new QueueEntry(4); // 用 unscaledTime顿帧期间 timeScale 会被压到 0.05用 Time.time 会让输入永不过期 public void Push(string action) _queue.Enqueue(new Entry { Action action, Stamp Time.unscaledTime }); public bool Consume(string action) { Trim(); if (_queue.Count 0 _queue.Peek().Action action) { _queue.Dequeue(); return true; } return false; } private void Trim() { while (_queue.Count 0 Time.unscaledTime - _queue.Peek().Stamp Window) _queue.Dequeue(); // 过期输入直接丢避免延迟吞招 } }调用时机要分清Push 放在 Update 里读键的位置Input.GetButtonDown(Fire1)一触发就入队Consume 只在状态切换到可攻击态时调用一次不要每帧轮询否则一次按键会被消费成多段攻击。队列用固定容量 4 就够ACT 里能有效排队的输入不会超过攻击、闪避、跳跃三个动作。如果项目用新版 Input System把 Push 挂到InputAction.performed回调上即可逻辑不变只是事件源从 GetButtonDown 换成了 Action 回调。2.3 第三人称相机与锁定目标的最小实现相机在 ACT 里承担两件事让玩家看清自己和敌人的相对位置以及提供打击时的镜头反馈。自由视角部分用鼠标控制 yaw 和 pitch锁定目标部分用 LerpAngle 把视角平滑拉向敌人。这里有个必须避开的坑——角色转向读相机的 forward相机又跟着角色转两者互相驱动会产生镜头甩动。解决办法是相机的 forward 投影到水平面后再给角色用锁定时相机的 yaw 直接由目标方向算出来不再叠加鼠标输入。// Assets/Scripts/Camera/ThirdPersonCamera.cs using UnityEngine; public class ThirdPersonCamera : MonoBehaviour { public Transform target; // 角色胸口的空物体别直接绑根节点 public Vector3 pivotOffset new Vector3(0f, 1.5f, 0f); public float distance 4.2f; public float yawSpeed 220f; public float pitchMin -30f, pitchMax 55f; public float followSharp 12f; // 越大跟得越紧8~16 之间 public Transform lockTarget; // 为空即自由视角 private float _yaw, _pitch 15f; private Vector3 _smoothPivot; private void LateUpdate() // 必须在角色移动之后执行否则会有一帧延迟 { float k 1f - Mathf.Exp(-followSharp * Time.deltaTime); // 帧率无关的插值系数 if (lockTarget ! null) { Vector3 dir lockTarget.position - (target.position pivotOffset); float wantYaw Quaternion.LookRotation(dir).eulerAngles.y; _yaw Mathf.LerpAngle(_yaw, wantYaw, k); _pitch Mathf.LerpAngle(_pitch, 10f, k); } else { _yaw Input.GetAxis(Mouse X) * yawSpeed * Time.deltaTime; _pitch - Input.GetAxis(Mouse Y) * yawSpeed * Time.deltaTime; _pitch Mathf.Clamp(_pitch, pitchMin, pitchMax); } Vector3 pivot target.position pivotOffset; _smoothPivot Vector3.Lerp(_smoothPivot, pivot, k); Quaternion rot Quaternion.Euler(_pitch, _yaw, 0f); transform.position _smoothPivot - rot * Vector3.forward * distance; transform.rotation rot; } }1f - Mathf.Exp(-followSharp * Time.deltaTime)这个写法替代了裸的Mathf.Lerp(a, b, 0.1f)原因是后者在 30 帧和 144 帧下跟随速度完全不同玩家换个机器手感就变了。相机的 pitch 上下限要设不然仰角超过 90 度会出现画面翻转。项目规模大之后可以考虑引入 Cinemachine 管理相机但锁定目标的角度插值和碰撞回退逻辑仍然要自己写Cinemachine 只帮你解决切换和叠加的问题。3. ACT 战斗核心状态机、连招表与动画事件驱动的判定窗口战斗层决定了这个 ACT 游戏到底能不能叫 ACT。核心是三件事用状态机把角色的行为切成互斥的块、用数据表描述每一招的前摇后摇和判定窗口、用动画事件在正确的时刻打开和关闭判定盒。这一层做扎实后面加新角色、新武器只是往表里填数据。3.1 用有限状态机管理 Idle/Move/Attack/Hit/Dodge不要用 Animator 的 Transition 来承载游戏逻辑Animator 只负责表现状态切换的判断、优先级、取消规则全部放在 C# 状态机里。这样做的直接好处是打断规则集中在一个地方调 bug 时不用在几十条连线里找。状态接口保持极简// Assets/Scripts/Battle/IPlayerState.cs public enum PlayerStateId { Idle, Move, Attack, Hit, Dodge, Dead } public interface IPlayerState { PlayerStateId Id { get; } void Enter(); void Tick(float dt); void Exit(); }转移优先级用一张表说清楚比散落在各个 if 里可靠得多当前状态可被打断为备注Idle / MoveAttack、Dodge、Hit、Dead自由态响应全部输入AttackDodge取消窗口后、Hit、Dead闪避取消是连招手感的关键DodgeHit非无敌帧、Dead无敌帧通常取起始 0.15~0.35 秒HitDead受击硬直中不接收任何输入Dead无只能靠重生逻辑重置实现上Attack 状态内部还需要一个子阶段前摇、判定、后摇。判定阶段用AnimatorStateInfo.normalizedTime或者提前埋好的动画事件来开关判定盒。用 normalizedTime 的好处是纯代码可控、不需要动画师配合用动画事件的好处是动作改长了不用同步改代码。稳妥做法是动画事件为主、normalizedTime 兜底防止动画事件被误删导致判定永不触发。3.2 连招表与 AttackData把帧数据从代码里挪出来连招参数写死在代码里改一次前摇就要重新编译策划和美术根本没法参与。用 ScriptableObject 把每段攻击做成资产Inspector 里直接拖时间滑块效率差一个量级。// Assets/Scripts/Battle/AttackData.cs using UnityEngine; [CreateAssetMenu(menuName ACT/AttackData, fileName Atk_)] public class AttackData : ScriptableObject { public string animState; // Animator 里的状态名建议同时缓存 StringToHash public float damage 10f; [Header(帧数据秒)] public float startup 0.18f; // 前摇到判定开启的时间 public float active 0.12f; // 判定持续时间 public float recovery 0.35f; // 后摇 public float cancelAt 0.42f; // 从起手算起可被下一段或闪避取消的时间点 [Header(判定盒本地空间)] public Vector3 hitboxCenter new Vector3(0f, 1.1f, 1.2f); public Vector3 hitboxSize new Vector3(1.6f, 1.6f, 2.2f); public float hitStop 0.06f; // 命中顿帧时长 public int nextComboIndex -1; // 下一段连招-1 表示收招 }字段的含义要对应到玩家感受startup 短到 0.15 以下攻击会显得没有蓄力、轻飘飘超过 0.3 秒玩家会觉得角色迟钝。active 取 0.1 到 0.2 秒覆盖武器划过身前的实际帧。recovery 本身不直接决定手感真正决定的是 cancelAt——它只要落在后摇区间内玩家就能在动作播完前接下一段这就是所谓「取消帧」。连招表用一个数组按索引串起来每段的 nextComboIndex 指向下一段最后一段指回 -1。判断能否接续时同时检查两件事当前时间是否超过 cancelAt以及输入缓冲里是否有攻击输入。3.3 攻击判定窗口动画事件、OverlapBox 与命中结算判定盒的检测一定不能每帧 new 数组用Physics.OverlapBoxNonAlloc配一个预分配的静态缓冲区。检测只在判定阶段执行且同一次挥砍对同一个目标只结算一次伤害否则角色站在原地能把敌人打成筛子。// Assets/Scripts/Battle/HitboxSweeper.cs using System.Collections.Generic; using UnityEngine; public class HitboxSweeper : MonoBehaviour { private static readonly Collider[] Buffer new Collider[16]; // 静态复用零 GC private readonly HashSetCollider _hitThisSwing new HashSetCollider(); public LayerMask enemyMask; public void BeginSwing() _hitThisSwing.Clear(); // 每次起手清空命中记录 public void Sweep(AttackData data) { Vector3 center transform.position transform.TransformVector(data.hitboxCenter); int n Physics.OverlapBoxNonAlloc( center, data.hitboxSize * 0.5f, Buffer, transform.rotation, enemyMask, QueryTriggerInteraction.Ignore); for (int i 0; i n; i) { Collider col Buffer[i]; Buffer[i] null; // 重要清引用别让静态数组持有已销毁对象 if (_hitThisSwing.Contains(col)) continue; if (!col.TryGetComponent(out IDamageable target)) continue; _hitThisSwing.Add(col); target.TakeDamage(new DamageInfo(data.damage, transform.position)); } } }几个容易忽略的点Buffer[i] null这行必须写静态数组跨帧持有 Collider 引用会阻止对象被回收敌人死亡后内存一直不释放。LayerMask 要单独建一层 Enemy不要用 Physics.DefaultRaycastLayers 全打。hitboxCenter 用本地空间坐标配合TransformVector转换是为了让判定盒跟随角色朝向旋转同时如果角色模型缩放变了也不会错位。判定阶段建议在 Scene 视图里用OnDrawGizmosSelected把盒子画出来调参时能看到盒子位置比盲调快得多。3.4 打断与优先级受击、闪避取消、霸体怎么定规则规则不统一是 ACT 项目后期最头疼的问题。建议把打断分成三类普通受击会中断当前攻击进入 Hit 状态霸体重击、大招前摇只掉血不中断用 AttackData 上加一个 bool 控制闪避在 cancelAt 之后可以取消攻击但闪避本身不能被攻击取消。等级高的打断覆盖等级低的同一帧出现多次打断请求时只取最高优先级避免一帧内状态来回跳。判定顺序也有讲究先处理受击再处理闪避最后处理攻击输入。原因是玩家在被打的瞬间往往也在按键如果先处理攻击会出现「挨打的同时挥出一刀」这种观感崩坏的情况。闪避优先于攻击是因为闪避承担了防御职责玩家的预期是「我按了翻滚就该滚出去」。4. 源码落地工程目录、关键脚本与打击感调试框架讲清楚之后落到能交付的工程结构。无论是自己维护还是作为课程设计、毕业设计的材料目录清晰、脚本职责单一、参数全部外置到 ScriptableObject这三条决定了别人拿到这份 Unity3D 游戏源码能不能看懂。4.1 目录分层与模块划分按职责分目录不要按文件类型分 Assets/Scripts/Manager 这种大杂烩。下面这套结构在中小型动作游戏里够用目录职责主要脚本Scripts/Player玩家输入、位移、相机PlayerMotor、PlayerInputScripts/Battle状态机、判定、伤害IPlayerState、AttackState、HitboxSweeperScripts/Enemy敌人 AI 与巡逻EnemyBrain、EnemySensorScripts/UI血条、伤害数字、UI 表现HealthBar、DamagePopupScripts/Core全局服务、事件总线GameEvents、HitStopRunnerData/Attacks连招数据资产Atk_Light1、Atk_Heavy1Data/Enemies敌人参数资产Enemy_Grunt伤害数字和血条用 UGUI 实现即可配合对象池复用文本节点别每次命中都 Instantiate 一个新的 Text。血条本身做两段式底条红色表示当前血量上层白色用 DOTween 之类做延迟追上玩家能直观看到这一刀掉了多少。敌人 AI 用最简单的方式起步就够了——巡逻、发现玩家、追到攻击距离、进入攻击状态、攻击冷却这五步用普通的状态机或简单的行为树都能实现不必一上来就上复杂框架。4.2 顿帧与命中反馈让每一次命中都被感觉到打击感八成来自命中反馈而这部分代码量极小。顿帧的做法是命中瞬间把 Time.timeScale 压到很低持续几十毫秒再恢复同时配一点镜头震动。实现时必须同步缩放Time.fixedDeltaTime否则物理步长和逻辑时间不一致快速移动的物体会穿模。// Assets/Scripts/Core/HitStopRunner.cs using System.Collections; using UnityEngine; public class HitStopRunner : MonoBehaviour { private Coroutine _running; public void Play(float realSeconds, float scale 0.05f) { if (_running ! null) StopCoroutine(_running); _running StartCoroutine(Run(realSeconds, scale)); } private IEnumerator Run(float realSeconds, float scale) { float baseFixed Time.fixedDeltaTime; Time.timeScale scale; Time.fixedDeltaTime baseFixed * scale; // 物理步长同步缩放 yield return new WaitForSecondsRealtime(realSeconds); // 必须用真实时间等待 Time.timeScale 1f; Time.fixedDeltaTime baseFixed; _running null; } }参数怎么定轻击 0.04 到 0.06 秒、scale 0.08重击 0.08 到 0.12 秒、scale 0.02越大越「砸得实」。这里有个陷阱——等待必须用WaitForSecondsRealtime用WaitForSeconds会因为 timeScale 被压到 0.05 而让等待时间放大 20 倍游戏直接卡死。另外别忘了恢复时的fixedDeltaTime漏掉会让整个工程的物理表现永久变慢而且只在运行时可见重启才恢复排查起来很费时间。闪避的无敌帧、受击的方向击退、格挡的硬直这三样和顿帧共用同一套时间控制思路时长全部走真实时间状态计时走缩放时间。分不清这两者的用法是动作游戏在顿帧引入后出现各种诡异 bug 的根源。5. 从能跑到能验收帧数据调参、性能与自测清单框架跑通之后剩下的时间基本都花在调参和排错上。这一章给的是可直接对照执行的检查表。5.1 帧数据调参表参数起手参考值调到什么程度算对前摇 startup0.15~0.25 秒短到能连上长到看得清起手判定窗口 active0.10~0.20 秒覆盖武器划过身前的实际帧后摇 recovery0.20~0.40 秒被取消窗口截断不是硬等取消窗口 cancelAt总时长的 60%~70%连招不粘手也不出现秒接输入缓冲 Window0.20~0.30 秒略大于取消窗口即可顿帧 hitStop轻击 0.05、重击 0.10 秒重击明显更长更沉镜头 followSharp8~16高不晃、低不糊调参的顺序也有讲究先定 moveSpeed 和 turnSpeed 把走位调到顺再定 startup 和 active 把单招打击感调出来最后用 cancelAt 和输入缓冲做连招衔接。反过来先调连招会在移动和单招都不对的基础上做无用功。每一组参数改完只录一段十秒的对比视频不要凭记忆判断手感这东西记不住。5.2 Update 里的 GC 与物理查询优化ACT 游戏同屏敌人多、特效多加上每帧的判定查询GC 很容易在战斗高潮时爆掉表现为偶尔的卡顿。几个具体做法判定查询统一用OverlapBoxNonAlloc和预分配的静态缓冲区不要在 Update 里 new 数组或 ListAnimator.Play、SetTrigger里的状态名参数用Animator.StringToHash提前缓存成 int字符串比较每帧都在产生垃圾伤害数字和特效走对象池池容量按同屏最大同时命中数留两倍余量GetComponent的结果在 Awake 缓存别写在 Tick 里。// 常见的每帧取组件写法战斗密集时是主要 GC 来源 private void Update() { var anim GetComponentAnimator(); // 错每帧都查 anim.SetTrigger(Attack); } // 改成缓存 哈希 private static readonly int AttackHash Animator.StringToHash(Attack); private Animator _anim; private void Awake() _anim GetComponentAnimator(); private void Tick() _anim.SetTrigger(AttackHash);调试时用 Profiler 的 GC Alloc 列看每帧分配量战斗状态下稳定在几百字节以内算正常出现 KB 级就按上面几条逐个查。物理查询也可以把检测频率从每帧降到每两帧一次判定窗口本身有 0.1 秒以上隔帧检测不会漏掉目标CPU 占用能降近一半。5.3 一套可复现的自测清单检查项通过标准连点攻击每一次按键都在下一段起手时兑现不吞招停手不按角色不会自己多挥一刀贴墙攻击判定盒穿墙命中敌人时不生效被击退击退位移在贴墙时不抖动、不穿模顿帧期间按键顿帧结束后按下的闪避能正常触发镜头锁定锁定两个目标间切换视角不翻转同屏 20 敌人帧率稳定Profiler 无明显 GC 尖峰最后补一个实用技巧把上面这些参数集中导出一份 JSON 或 CSV游戏启动时读入覆盖 ScriptableObject 默认值。这样调参不用每次都回编辑器直接把文件丢进 StreamingAssets 目录运行中按热键重载一轮迭代能从十几分钟压到几十秒。真机验证时也可以让测试同学改这份配置把「手感偏黏」这类主观描述直接落成可对比的数值差异。本文还有配套的精品资源点击获取