简介:在游戏开发学习中,Unity作为主流引擎,提供了从场景搭建到逻辑实现的完整工具链。格斗游戏因其核心机制明确、模块化程度高,成为理解引擎原理的理想实践场景。其技术价值在于,开发者能够通过角色控制、动画状态机、物理检测与UI绑定,掌握游戏循环的基本构建方法。从输入系统到CharacterController的移动方案,从Animator驱动的连招表现到基于Trigger的攻击判定,从NavMeshAgent的敌人追踪到血条数据的实时反馈,这些基础概念共同构成了可扩展的工程框架。在实际应用中,无论是期末项目还是独立游戏原型,这套流程都能有效控制复杂度并保证演示效果。本文围绕Unity格斗游戏开发,系统拆解从零搭建到答辩展示的关键环节,为初学者提供一条可复用的实践路径。 作为教了几年Unity、带过不少期末项目的开发者,我见过太多“想做格斗游戏但又怕做不完”的情况。今天聊的这个项目——unity打怪物格斗游戏,是期末大作业里很稳妥的一条路:核心玩法明确、功能点好拆分、技术栈不复杂,做出来演示效果又挺抓眼球。无论你是零基础刚入门,还是学过一阵但没完整做过游戏,这套方案都值得参考。
这个项目最终做出来是什么样?玩家控制一个角色在场景里移动,靠近怪物后按攻击键打出连击,怪物有血量和追击行为,被打会掉血,血条清空后播放死亡动画,玩家也会因为被攻击而掉血,血量归零则游戏结束,中间穿插摄像机跟随、地面UI、音效和特效。这套东西刚好覆盖Unity里最常用的几个模块:场景搭建、角色动画、物理检测、UI绑定、状态管理,而且每个模块都能独立调试,特别适合作为学习收尾的综合性作业。
下面我把整个项目的设计思路、实操步骤、踩坑记录和答辩注意点一次讲清楚。
1. 项目整体设计与思路拆解
1.1 为什么格斗游戏适合做期末大作业
期末大作业有两个硬约束:时间有限、老师要看“工作量”。格斗游戏恰好在这两点上表现很好。核心机制就是“移动、攻击、被攻击、死亡”,听起来简单,但展开之后能挂靠的知识面很广:角色控制涉及Input输入和CharacterController或刚体物理,动画涉及Animator和动画状态机,战斗判定涉及碰撞器和触发器,UI涉及血条和游戏状态切换,音效特效涉及AudioSource和ParticleSystem,再加上场景光照、摄像机控制、敌人的简单AI,整个项目至少能写十几个脚本、搭六七套动画状态,工作量足够撑起一次答辩展示。
拿“做一个能玩的打怪游戏”和“做一个完美的格斗游戏”对比,前者适合期末,后者适合毕设。我给学生的建议是:把范围控制在3分钟内能体验完的“核心循环”,也就是出生→找怪→打怪→怪死→继续打→玩家死或通关。别一上来就想着做多角色、多技能、连招系统,那是商业项目的复杂度。期末大作业真正需要的是你完整跑通一个游戏循环,并能在答辩时讲清楚每个环节为什么这么设计。
1.2 技术栈选型:2D还是3D,用什么渲染管线
这个项目的标题就决定了方向,“打怪物格斗游戏”按传统认知通常做横版2D,比如街机风格,但考虑到初学者上手,我反而建议优先做3D俯视角或者第三人称视角,原因有几点:
- 3D场景不需要逐帧绘制角色,直接用Unity商店里的免费模型和Animator控制器就行,省下大量美术工作量。
- 2D格斗需要一帧一帧的打击感设计,比如受击硬直、击退、判定框,这些对初学者来说不太好调;3D俯视角格斗把判定简化为“近战范围内检测敌人”,逻辑更直白。
- 3D场景可以用Unity自带的地形工具、摆放几个Cube当围墙,天然显得“像个游戏”,而2D场景空荡荡的话一眼露怯。
渲染管线我推荐使用Unity默认的Built-in管线,或者URP。URP现在几乎是新项目的默认选择,性能好、光照效果也够用。初学者不需要纠结选择,直接新建项目时选Universal 3D模板即可。老版本项目保持在Built-in也没有问题,因为这里用到的功能在两种管线下写法一致,顶多是UI或特效材质有微小差异。
1.3 功能模块划分与开发顺序
整个项目可以拆成5个大模块,我建议的按依赖顺序实现:
- 场景搭建与角色控制:造地面、围墙、灯光,导入角色模型,实现WASD移动和跳跃。
- 动画状态机:把Idle、Walk、Run、Attack、Hit、Death这几种动画串起来,让角色动起来不僵硬。
- 敌人AI与战斗系统:敌人巡逻或追踪,玩家攻击检测敌人,扣血和死亡逻辑。
- UI与游戏状态:玩家血条、敌人血条、击杀数或胜利失败文本,重开按钮。
- 打磨细节:摄像机跟随、攻击特效、音效、场景装饰。
这样做的好处是每一步都有可运行版本。哪怕最后时间不够,砍掉第5步,前4步已经是一个完整的、能演示的游戏。我带过的学生里,几乎都是按这个顺序推进,最后很少出现“推到一半项目跑不起来”的情况。
2. 核心细节解析与实操要点
2.1 角色控制:CharacterController还是刚体
格斗游戏的角色移动有两种主流做法:CharacterController组件和Rigidbody刚体。
CharacterController适合人形角色,自带碰撞和斜坡处理,不会因为物理原因四处乱滑,代码控制感很强。Rigidbody适合强调物理互动的玩法,比如打击可以让敌人飞出去,但初学者调刚体容易遇到“人推不动”“撞到东西乱弹”的问题。
我的建议是玩家角色使用CharacterController,敌人被攻击后的击退效果可以单独通过在敌人刚体上施加力来实现,这样两边优势都拿到。如果你把玩家和敌人都设成Rigidbody,会很容易出现互相碰撞导致的乱飞,往往不是你代码的问题,而是物理材质和约束没调好,无形中增加调试成本。
玩家控制的核心代码如下(挂在玩家角色上):
using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; public float rotateSpeed = 720f; public float jumpHeight = 1.2f; public float gravity = -9.81f; private CharacterController controller; private Animator animator; private Vector3 velocity; void Start() { controller = GetComponent<CharacterController>(); animator = GetComponent<Animator>(); } void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 move = new Vector3(h, 0, v).normalized; if (move.magnitude > 0.1f) { // 将输入方向从世界空间转换到角色面对方向要更合理, // 这里用主摄像机方向做基准,避免俯视角下方向混乱。 Vector3 cameraForward = Camera.main.transform.forward; Vector3 cameraRight = Camera.main.transform.right; cameraForward.y = 0; cameraRight.y = 0; cameraForward.Normalize(); cameraRight.Normalize(); Vector3 worldMove = cameraForward * v + cameraRight * h; controller.Move(worldMove * moveSpeed * Time.deltaTime); Quaternion targetRotation = Quaternion.LookRotation(worldMove); transform.rotation = Quaternion.RotateTowards( transform.rotation, targetRotation, rotateSpeed * Time.deltaTime); animator.SetBool("IsRunning", true); } else { animator.SetBool("IsRunning", false); } // 跳跃与重力 if (controller.isGrounded && velocity.y < 0) { velocity.y = -2f; } if (Input.GetButtonDown("Jump") && controller.isGrounded) { velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); animator.SetTrigger("Jump"); } velocity.y += gravity * Time.deltaTime; controller.Move(velocity * Time.deltaTime); } }注意代码里的一个关键点:移动方向不能直接使用世界坐标的h和v,否则摄像机旋转时移动手感会乱。上面代码用摄像机的前方向做基准,把输入方向转换到世界空间,这样无论摄像机怎么偏转,按W就是朝屏幕远处走,手感符合直觉。这也是很多初学者容易忽略的细节。
CharacterController的Move方法在每次Update里调用,传入的是速度乘Time.deltaTime,这叫每帧位移。如果传入的是原始速度不乘deltaTime,帧率越高跑得越快,这个坑非常经典。
2.2 动画系统:Animator状态机与参数设计
动画是格斗游戏“打击感”的第一层。Unity里的Animator状态机听起来高大上,实际就是把几个状态串联起来,用参数控制切换。
推荐给角色的动画设置这些状态:
- Idle(待机)
- Run(跑)
- Attack1(攻击1)
- Attack2(攻击2)
- Hit(受击)
- Death(死亡)
每个状态之间添加过渡条件,比如Idle → Run的条件是IsRunning为true,Run → Idle是IsRunning为false,Idle → Attack1是Trigger Attack,Attack1 → Idle可以是动画播完后自动退出,或者用一个Exit Time。初学者用Animator最怕的是“动画互相抢”,所以建议攻击动画一定勾选Has Exit Time,并且把过渡时间设成0.05到0.1秒,这样连招手感会更干脆。
在Animator窗口里,参数类型用Bool和Trigger。IsRunning是Bool,Attack是Trigger。Trigger的好处是用完自动重置,不会像Bool那样需要手动设回false。在代码里触发攻击:
if (Input.GetMouseButtonDown(0)) { animator.SetTrigger("Attack"); }如果你想让玩家连续追加攻击,可以增加攻击次数变量,每次按攻击键时递增并mod取模,再设置对应的Trigger,让动画状态机在Atttack1和Attack2之间切换。这就是最简版的“连招系统”。实际操作时要注意:攻击动作播放中不能被跳帧打断,一般要在Animator里给攻击动画添加“不能与其他状态互相过渡”的选项,或者在代码里用一个IsAttacking的bool作为锁。
另外提醒一句,动画模型最好使用Unity官方的“Unity-chan”或商店里的免费低多边形角色模型,这些模型自带一套标准动作,包含Idle、Run、Attack、Hit、Death等,导入后Animator Controller可以直接引用。如果用自己K的动画,控制起来会非常崩溃。
2.3 怪物AI:巡逻、追击与攻击
敌人AI不需要复杂到用NavMeshAgent,初学者直接写一个简单的状态机即可。状态就三种:巡逻、追击、攻击。用一个枚举变量表示当前状态,每帧检查玩家距离,切换状态。
public enum EnemyState { Patrol, Chase, Attack }最简单的一个敌人行为脚本:
using UnityEngine; using UnityEngine.AI; public class EnemyAI : MonoBehaviour { public Transform player; public float chaseRange = 8f; public float attackRange = 2f; public float moveSpeed = 3f; public float attackCooldown = 1.5f; private Animator animator; private NavMeshAgent agent; private float lastAttackTime; void Start() { animator = GetComponent<Animator>(); agent = GetComponent<NavMeshAgent>(); agent.speed = moveSpeed; } void Update() { if (player == null) return; float distance = Vector3.Distance(transform.position, player.position); if (distance <= attackRange) { agent.isStopped = true; AttackPlayer(); } else if (distance <= chaseRange) { agent.isStopped = false; agent.SetDestination(player.position); animator.SetBool("IsRunning", true); } else { agent.isStopped = false; agent.SetDestination(transform.position); // 站在原地表示巡逻 animator.SetBool("IsRunning", false); // 如果要做真正的巡逻,可以设置几个路点,每次向路点移动 } } void AttackPlayer() { animator.SetBool("IsRunning", false); animator.SetTrigger("Attack"); if (Time.time - lastAttackTime >= attackCooldown) { lastAttackTime = Time.time; // 在这里实现对玩家造成伤害 player.GetComponent<PlayerHealth>()?.TakeDamage(10); } } }这里用了NavMeshAgent,需要在场景里设置Navigation静态烘焙。初学者可以这样理解:NavMeshAgent就像给角色一个GPS导航,它自己会避开障碍物,你只要告诉它目标位置就行。烘焙步骤是:选中地面和障碍物 → 勾选Navigation Static → 打开Window → AI → Navigation → 点Bake。不烘焙的话Agent没法寻路。
很多人第一次做的时候卡在“敌人不会追我”,多半就是忘了Bake。还有一个常见问题是NavMeshAgent和CharacterController同时挂在敌人身上会冲突,因为它们都在尝试控制位置。要么敌人用CharacterController自己写移动逻辑,要么直接用NavMeshAgent但不要挂CharacterController。我建议敌人用NavMeshAgent,省事。
巡逻逻辑真正做起来,可以设置几个空物体作为路点,敌人从路点A走向路点B,到附近后换下一个路点。这样看起来比傻站着生动很多。不过如果期末答辩时间紧,巡逻做成“站在原地偶尔转身”也说得过去,核心是保证追击和攻击这两个行为能被演示出来。
2.4 战斗判定:为什么用触发器而不是碰撞器
很多初学者做攻击判定时,喜欢在攻击瞬间去检测“敌人是否在某个距离内”,这个思路可行,但不够专业。更规范的做法是在玩家身上创建一个子物体,挂一个盒状碰撞器并勾选Is Trigger,攻击时激活触发器,通过OnTriggerEnter来检测敌人。
这个子物体叫攻击判定体,位置放在玩家模型前方,大小要大致覆盖拳头或武器的范围。触发器的优势是侦测范围可视化,而且可以用OnTriggerEnter自动检测,不需要每帧去遍历敌人。
代码结构如下:
Player ├── CharacterController ├── Animator ├── PlayerController.cs └── AttackBox (空物体 + BoxCollider(IsTrigger)) └── AttackController.cs攻击触发时,用代码主动激活攻击判定体:
using System.Collections; using UnityEngine; public class AttackController : MonoBehaviour { public GameObject attackBox; public int damage = 20; public float activeTime = 0.2f; void Start() { attackBox.SetActive(false); } public void EnableAttackBox() { StartCoroutine(ActiveAttackBox()); } IEnumerator ActiveAttackBox() { attackBox.SetActive(true); yield return new WaitForSeconds(activeTime); attackBox.SetActive(false); } void OnTriggerEnter(Collider other) { if (other.CompareTag("Enemy")) { other.GetComponent<EnemyHealth>()?.TakeDamage(damage); } } }但这里涉及到“攻击判定体开关的触发时机”问题,典型做法是在攻击动画的特定帧触发,初学者最简单的方法是:在PlayerAttack脚本的Update里检测到攻击键按下后,调用EnableAttackBox,同时用开始攻击动画。因为动画是一瞬间的,攻击框只激活0.2秒,足够在播放挥拳动画时命中敌人。这种做法比较“物理外挂”,但在期末项目里足够稳定。
如果要更精确地让动画帧和判定帧同步,可以在动画事件里调用EnableAttackBox。选中攻击动画,在Animation窗口的对应帧添加Animation Event,调用一个公共方法开启判定体。这样每次挥拳到“出拳最强点”时才会触发伤害,视觉判定完全同步。
OnTriggerEnter脚本如果挂在Player根物体上,需要记得把敌人和玩家的碰撞层分开,以免玩家自己的攻击框和玩家身体碰撞误判。另一种办法是把攻击判定体脚本挂到子物体上,让子物体上的Trigger只和敌人的Collider发生交互,这样就算攻击框碰到其他东西也不会误伤。
2.5 血条UI与数据绑定
UI使用Unity的Canvas + Image组件。血条有两种做法:Slider组件和直接修改Image的fillAmount。我推荐使用Slider,因为它自带拖拽和背景,但设置起来略复杂。如果你追求效率,可以直接使用Image的fillAmount,把Image的Fill Method设为Horizontal,然后通过filling = 0.5f来控制血条显示一半。
血条一般跟随角色头顶,或者放在屏幕底部。初学者推荐屏幕底部固定位置,因为世界空间UI涉及坐标转换和正对摄像机,调不好就是血条倒着显示。屏幕空间UI更容易控制,而且期末展示时比较清晰。
玩家血量的脚本:
using UnityEngine; using UnityEngine.UI; public class PlayerHealth : MonoBehaviour { public int maxHealth = 100; public int currentHealth; public Image healthBar; public GameObject gameOverPanel; void Start() { currentHealth = maxHealth; UpdateHealthBar(); } public void TakeDamage(int damage) { currentHealth -= damage; currentHealth = Mathf.Clamp(currentHealth, 0, maxHealth); UpdateHealthBar(); if (currentHealth <= 0) { Die(); } } void UpdateHealthBar() { healthBar.fillAmount = (float)currentHealth / maxHealth; } void Die() { // 触发死亡动画、暂停玩家控制、显示游戏结束面板 GetComponent<Animator>().SetTrigger("Death"); GetComponent<PlayerController>().enabled = false; if (gameOverPanel != null) { gameOverPanel.SetActive(true); Time.timeScale = 0f; } } }Time.timeScale = 0f在这里可以让整个游戏暂停,实现“游戏结束”的状态。注意timescale归零后,如果UI上还有动画或需要点击重启按钮,按钮本身是可以工作的,因为UI的EventSystem不受timescale影响。重启时要把timescale恢复为1。
敌人血量和玩家血量结构差不多,只是还需要“死亡后移除敌人并计数”的逻辑。敌人死亡时可以先播死亡动画,等动画播完再禁用物体或销毁,避免“尸体突然消失”的突兀感。可以用协程等待2秒再销毁,或者通过Animator的事件在动画末尾调用销毁方法。
2.6 摄像机跟随:让视野始终照顾到战斗
摄像机是影响体验最大的隐性因素。很多初学者把摄像机固定在一个角落,结果角色跑远后根本看不到自己。最简单稳定的方案是让摄像机一直跟随玩家,同时根据角色和敌人的位置做轻微偏移。
基础做法:
using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0, 10, -6); public float smoothSpeed = 5f; void LateUpdate() { if (target == null) return; Vector3 desiredPosition = target.position + offset; transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed * Time.deltaTime); transform.LookAt(target.position + Vector3.up * 1f); } }这里必须使用LateUpdate而不是Update。原因是物理更新阶段结束后,Animator和CharacterController可能还没完成位置更新,LateUpdate可以保证摄像机使用的是角色这一帧最终位置,避免画面抖动。这也是资深Unity开发者的通用经验。
如果你做的是俯视角格斗,摄像机的旋转不跟随角色,始终保持固定角度即可。如果做第三人称动作格斗,可以让摄像机绕着角色旋转,这就涉及更复杂的相机控制,建议初学者先做俯视角或固定角度,把时间省出来去打磨战斗细节。
3. 实操过程与核心环节实现
3.1 从零开始搭建场景的具体步骤
这里我把完整实操流程按顺序走一遍,照着做基本就能跑起来。
第一步,新建工程。打开Unity Hub,新建项目,模板选择Universal 3D(Built-in也可以),项目名称随意,位置选个空间大的盘。进入编辑器后,先把默认场景保存为MainScene,养成随手Ctrl+S的习惯。
第二步,搭建地面。在Hierarchy里右键 → 3D Object → Plane,把Scale改成(5,1,5)。给地面新建一个Material,颜色选偏深灰或草地绿,避免纯白天花板感。再添加几个Cube作为围墙,Scale调成细长条,围住地面四周。这一点非常重要,不然角色会走出场地,演示时显得很业余。
第三步,给地面处理光照。场景默认有Directional Light,把它的Rotation调成大约(50, -30, 0),让地面有阴影。Light组件里勾选Shadows属性,选Soft Shadows。如果觉得自己打光效果差,可以在Window → Rendering → Lighting环境里把环境光强度稍微提高一点。
第四步,导入角色模型。在Asset Store或者Unity自带资源包里找一个“Free Low Poly Character”之类的人物模型,导入后拖到场景。给模型添加Animator组件(模型自带的话不用重复添加),创建Animator Controller并命名为PlayerAnimator,把模型对应的动画片段拖进状态机。
第五步,添加CharacterController组件。选中玩家根物体,在Inspector点Add Component,搜索CharacterController,把Height调到约1.8,Radius约0.4。确保玩家模型在Collider内部,不是悬空也不是穿地。可以调整Center的y值让控制器底部贴近脚底。
第六步,挂上PlayerController脚本,设置移动速度5,旋转速度720。运行场景,用WASD测试角色能不能走路,转向是否自然。
第七步,创建敌人。做法类似,找一个怪物模型(比如低多边形小恶魔丧尸),拖到场景里,加NavMeshAgent,把Speed设成3。如果不想用NavMeshAgent,也可以加CharacterController,用简单的Vector3.MoveToward追踪玩家,不过代码量会多一点。
第八步,把地面和围墙都选中,在Inspector右上角勾选Navigation Static,打开Window → AI → Navigation,点击Bake。注意围墙也要参与烘焙,Agent才能绕开墙。烘焙完成后,敌人就可以通过NavMeshAgent寻路。
第九步,实现攻击检测。在玩家下创建空物体AttackBox,放在角色前方1米左右,添加BoxCollider并勾选IsTrigger。创建AttackController脚本,挂到AttackBox上,把attackBox拖入Inspector,这样脚本可以通过EnableAttackBox开关自己。
第十步,做UI。右键Canvas,在Canvas下创建Image并改名为PlayerHealthBar,在Image组件里把Image Type设为Filled,Fill Method设为Horizontal,初始Fill Amount为1。把它作为血条的前景。再复制一个Image作为血条背景,放在下层,颜色改为黑色。将前景Image拖到PlayerHealth脚本的healthBar槽里,运行后就能看到掉血变化。
第十一步,挂摄像机跟随。把玩家作为target传入CameraFollow脚本,调整offset为合适的俯视角位置。
第十二步,添加音效和特效。在资源包里找攻击挥拳的音频和打击光效ParticleSystem。攻击时在AttackController里实例化特效。此处不展开代码,但这是一个锦上添花的部分,答辩时很加分。
3.2 敌人死亡与重生逻辑
敌人被打死之后,不能就这样让场景空掉,那样没法继续体验。建议有两种处理方式:
一种是一次性关卡,敌人数固定,全部击杀后显示“Victory”界面。这种方式实现最简单,适合期末项目“有一个明确结局”。
另一种是无限刷怪,玩家头顶有击杀计数,每击杀一个敌人,间隔几秒后在刷新点生成新怪物。这种方式更接近游戏的实际玩法,演示时有持续可玩性。
无限刷怪的简单实现:
using System.Collections; using UnityEngine; public class EnemySpawner : MonoBehaviour { public GameObject enemyPrefab; public Transform player; public Transform[] spawnPoints; public int maxEnemiesOnField = 3; public float spawnInterval = 5f; private int currentEnemyCount = 0; void Update() { if (currentEnemyCount < maxEnemiesOnField) { StartCoroutine(SpawnAfterDelay()); } } IEnumerator SpawnAfterDelay() { yield return new WaitForSeconds(spawnInterval); if (currentEnemyCount >= maxEnemiesOnField) yield break; Transform point = spawnPoints[Random.Range(0, spawnPoints.Length)]; GameObject enemy = Instantiate(enemyPrefab, point.position, Quaternion.identity); EnemyHealth health = enemy.GetComponent<EnemyHealth>(); health.player = player; health.OnDeath += () => currentEnemyCount--; currentEnemyCount++; } }注意OnDeath是一个事件。在EnemyHealth死亡方法里,调用OnDeath?.Invoke(),这样Spawner能及时知道敌人个数减一。这个“事件”的概念对初学者来说有点抽象,但如果课上讲到了委托,用它实现非常优雅。
如果不想用事件,也可以直接在Spawner里每帧统计场景里EnemyTag的物体数量,代码稍微低效一点但也能用。对期末项目来说,两种都行。
3.3 打击感增强:屏幕震动、停顿帧与特效
格斗游戏好不好玩,打击感是关键。所谓“打击感”其实是一系列反馈的组合:画面震动、攻击命中时短停顿、伤害飘字、音效、敌人后仰。期末答辩时,哪怕你功能简单,只要把这几个反馈做出来,效果立刻上了一个档次。
屏幕震动最简单的方式是写一个很短的相机抖动脚本:
using System.Collections; using UnityEngine; public class CameraShake : MonoBehaviour { public float shakeDuration = 0.1f; public float shakeAmount = 0.2f; private Vector3 originalPos; void Start() { originalPos = transform.localPosition; } public void Shake() { StartCoroutine(ShakeCoroutine()); } IEnumerator ShakeCoroutine() { float elapsed = 0f; while (elapsed < shakeDuration) { transform.localPosition = originalPos + Random.insideUnitSphere * shakeAmount; elapsed += Time.deltaTime; yield return null; } transform.localPosition = originalPos; } }在AttackController的OnTriggerEnter里,如果触发到了敌人,就调用Camera.main.GetComponent<CameraShake>()?.Shake();。为了避免相机位置被震乱,记得让这个脚本和CameraFollow同时工作,最好的做法是在CameraFollow脚本里获取一个初始坐标,并在LateUpdate里先设置好期望位置,再叠加震动偏移。
停顿帧的实现就更简单了:命中敌人时,用Time.timeScale = 0.1f并立刻yield return new WaitForSeconds(0.02f)恢复1。注意WaitForSeconds受timescale影响,所以要乘以timescale的修正值,或者直接用WaitForSecondsRealtime。这部分代码量不大,但是效果极其明显。
伤害飘字则是UI中最吸引眼球的一个小功能。做法是实例化一个Text预制体放在敌人头顶,飘起来并渐隐。初学者可以用DoTween插件,也可以手动写一个UIFloatingText协程,在0.5秒内把位置往上移并降低透明度。这个功能本质上就是处理一个坐标变化和颜色变化,没什么难度,但答辩演示时,观众一眼就能看出你做了很多细节。
3.4 期末答辩时演示什么、怎么讲
期末大作业的评分不只是看游戏好不好玩,更看你能不能讲清楚里面的逻辑。答辩演示的时候,建议按这个流程走:
- 简单介绍游戏类型和玩法,用时30秒。
- 演示基本移动,讲解角色控制用的CharacterController和为什么不用刚体。
- 演示攻击,重点讲解攻击判定体的设计,为什么用Trigger,动画事件如何同步。
- 演示怪物AI,讲解NavMeshAgent寻路和状态切换。
- 演示UI更新、死亡结束、重生,讲解血条怎么绑定。
- 如果你做了音效震动,一定要提,这是你“超出预期”的工作量证明。
整个演示过程控制在3到4分钟。老师问问题的时候,大概率会挑一个模块深挖,比如:“你的攻击为什么有时候打不到敌人?”“敌人的寻路是怎么避开墙的?”“Time.timeScale = 0会不会导致UI也不响应?”这些答案你在做项目的过程中其实都碰到过,所以一定要自己动手调试,而不是只抄代码。
4. 常见问题与排查技巧实录
4.1 角色移动了但动画不切换
最常见的原因是Animator参数的名称和代码里不一致。检查Animator窗口的Parameters列表里有没有名为IsRunning的参数,并且类型是Bool。代码里用的是animator.SetBool("IsRunning", true),如果参数名叫isRunning,大小写不同,就会失败。另外还有一个地方容易错:模型导入时默认把Animation Type设为Legacy,这种动画不能直接在Animator里使用,需要在模型导入设置里把Animation Type改为Humanoid或Generic,然后点击Apply。
4.2 攻击框激活了但敌人不掉血
这种问题通常出在Tag或Layer上。攻击框OnTriggerEnter里检查了other.CompareTag("Enemy"),如果敌人根物体没有打上Enemy标签,就永远不会进入伤害函数。检查方法:选中敌人,在Inspector顶部Tag下拉菜单里选择Enemy。如果找不到这个标签,先点击Add Tag创建。
另一个可能性是攻击框激活时间太短,运行帧率又低,导致在两次OnTriggerEnter之间就关闭了。把activeTime调成0.3秒,或者直接用Debug.Log在OnTriggerEnter里打印,看命中的到底是什么物体。还可以检查敌人是否挂载了EnemyHealth脚本,以及TakeDamage方法是否是public。
4.3 敌人通过NavMeshAgent行走时,动画漂移
NavMeshAgent控制位移时,角色的位置是Agent自己移动的,但Animator的Run动画需要角色本身有速度,这时候如果你把Agent的velocity赋值到Animator参数里,动画就会自然播放。通常在EnemyAI的Update里这样加:
animator.SetFloat("Speed", agent.velocity.magnitude);然后在Animator状态机里用Speed参数控制Idle和Run的过渡。这样敌人移动时脚步动画就会匹配实际速度,不会出现“滑冰”现象。
另外,如果敌人移动时朝向不对,可以用agent.updateRotation = true让Agent自动转向移动方向。如果你的模型本身朝向是反的(比如模型面朝-Z而非+Z),在模型导入设置的Model标签里调整Rotation -90度,或者在敌人根物体包一个空物体,让模型子物体旋转180度,保证模型的“前方”和Agent前进方向一致。
4.4 攻击时角色原地滑步、动作不协调
这通常是因为攻击动画没有和移动锁定配合。设计上,普通攻击时角色不应该继续移动,否则会出现一边挥拳一边滑走的奇怪画面。在PlayerController里加一个是否攻击的判断:
bool isAttacking = false; public void SetAttacking(bool value) { isAttacking = value; }在攻击代码里:
if (Input.GetMouseButtonDown(0) && !isAttacking) { isAttacking = true; animator.SetTrigger("Attack"); StartCoroutine(ResetAttack(0.5f)); }同时,在移动逻辑里判断如果isAttacking为true,就不执行CharacterController.Move,只让角色保持原地。这个0.5秒锁定时间要和攻击动画长度匹配,建议直接读取animator.GetCurrentAnimatorClipInfo(0)[0].clip.length来动态获得动画长度,而不是写死。
4.5 摄像机乱穿墙壁或视野没了
俯视角相对简单,如果你做的是第三人称自由视角,就会出现摄像机穿墙。最简单的规避方法:把摄像机碰撞检测用SphereCast,射线检测墙,碰到墙就把摄像机位置前移。写起来类似:
RaycastHit hit; if (Physics.SphereCast(target.position, 0.3f, desiredPosition - target.position, out hit, offset.magnitude)) { transform.position = hit.point; }不过我强烈建议初学者把摄像机固定成俯视角,这样完全不用处理穿墙。如果非要做第三人称,可以给墙壁设忽略摄像机的Layer,或者把摄像机Follow的offset设得高一点,让摄像机始终高于墙壁,视觉上就不会穿帮。
4.6 打包后UI字体变糊或按钮失效
针对期末作业,大部分情况下只在编辑器里运行演示,但如果老师要求提交源码或可执行文件,打包时要注意Unity的UI字体在build后有时会丢失或显示异常。解决办法是在打包前把文字字体设置为Unity自带的LegacyRuntime.ttf,或是在Project Settings里勾选Extra Options,确保字体被包含。按钮失效的原因通常是场景里没有EventSystem,UICanvas生成时一般会自动创建EventSystem,如果你手动删了,记得重新添加。
4.7 性能卡顿和“画质不错但是帧率低”
优化不是期末核心,但答辩时老师可能会问。如果你的场景里怪物数量超过10只,每只都实时寻路且动画同步,可能稍卡。几个简单优化手段:
- 限制场上敌人数量,用对象池代替Instantiate和Destroy。
- 将远处怪物的动画播放速度降低或禁用Animator。
- 使用URP的LOD或减少光源数量。
- 场景中不要放大量实时点光源。
- NavMeshAgent的AvoidancePriority可以设置,防止大量单位互相排斥导致“打架”。
不要在一开始写代码时就预优化,先跑通再说。你会在过程中发现瓶颈在哪里。
5. 从做完到做好的关键经验
5.1 如何把期末项目做得“不像是交作业”
很多人的期末项目功能齐全,但就是看起来很差。差异通常来自细节:角色碰撞体没对齐、UI布局乱七八糟、怪物死亡瞬间消失、场景地面太单调、攻击没有特效音效。如果你是初学者,请优先补这些细节,而不急着加新技能:
- 给地面加一个简单贴图,让网格感消失。
- 角色待机时做一下Y轴浮动或旋转,让画面不静止。
- 攻击命中时生成一个半透明的冲击波粒子特效。
- 敌人被攻击时向后闪一下,配合受击动画。
- 玩家和怪物脚下加一个小圆片阴影,立体感立刻增强。
这些小改动每个只需要10分钟,加上去之后效果立竿见影。答辩时你甚至可以不提,但一对比两个项目,老师自然看得出谁用了心。
5.2 怎么在两周内完成这个项目
如果你现在才开始做,时间其实完全够。列一个最简单的倒排计划:
- 第1-2天:场景搭建、导入模型、角色移动。
- 第3-4天:动画状态机、攻击、敌人AI。
- 第5-6天:血条、游戏状态、死亡重生。
- 第7-8天:补充特效音效震动,优化手感。
- 第9-10天:留出调试和答辩PPT的时间。
每天保证两到三个小时,这个计划非常宽松。真正比较费时间的是导入资源时处理模型动作,以及排查动画和碰撞的问题。如果卡住了,先试着把功能拆小,一步一步验证。
5.3 答辩时最好准备好哪些代码讲解点
建议准备几个“你为什么这么写”的答案:
- 为什么用CharacterController而不是Rigidbody调控玩家?
- 答:CharacterController更适合人形角色的运动控制,避免了物理导致的意外滑动和旋转,且自带碰撞体,方便移动和跳跃。
- 为什么攻击用Trigger而不是距离判断?
- 答:Trigger可视化范围,且触发器事件自动触发,逻辑更清晰,方便扩展多个攻击判定区域。
- 为什么摄像机跟随放LateUpdate?
- 答:确保所有移动和动画更新的最终位置已经确定,避免摄像机抖动。
- 为什么用NavMeshAgent?
- 答:利用烘焙导航网格自动寻路,开发者不需要自己实现避障算法,效率高且可靠。
把这些原理吃透,你就能从“代码能跑”升级到“确实理解这个项目”,这对期末成绩的帮助比多写一段花哨代码大得多。
根据我这些年带项目的经验,期末大作业不怕你功能少,就怕你功能多但演示跑不通。这套“打怪物格斗游戏”的方案,核心玩法完整,每个模块都能扩展,难度曲线也适合初学者。最后再分享一个小技巧:每次改完代码运行前,先看一眼Console有没有红色报错;出现了就当场解决,千万别带着报错继续堆功能,否则后面根本不知道是哪个环节坏了。把这个习惯养成之后,你不仅这一门课轻松,后续所有游戏项目都会省很多事。
本文还有配套的精品资源,点击获取