简介:这是一份面向Unity初学者与在校学生的2D打怪物格斗游戏完整工程,可直接作为期末大作业或课程设计参考。项目实现了角色上下左右跳跃移动、攻击怪物、血量条显示、分数统计与游戏背景切换等核心玩法,并支持背景音乐调节和多个功能按钮,适合用来练习Unity基础组件、碰撞检测与UI交互。压缩包为zip格式,大小约50.78MB,内含工程源码与相关资源文件,解压后可直接导入Unity编辑器运行调试。目前已有2400人学习下载,热度较高。通过这份资源,读者可以拿到一套可直接运行的完整游戏案例,理解角色控制、怪物AI、血量与计分系统的实现思路,并在此基础上修改参数、替换素材,快速完成自己的期末作品或入门练习。
1. 从零搭一个能打的 Unity 格斗游戏:期末大作业为什么选它最稳
很多同学做 Unity 期末大作业时,第一反应是做个打砖块或者见缝插针,因为教程多、代码短。但如果你想让老师一眼记住,同时又能真正练到 Unity 的核心机制,打怪物格斗游戏其实是性价比最高的选择。它天然覆盖了状态机、碰撞检测、动画事件、UI 血条、音效反馈这几个必考知识点,代码量可控,美术资源用免费素材就能凑齐,初学者花两三天也能跑出一个完整可玩的 Demo。
这篇文章面向的是刚学 Unity 不久、需要交期末大作业、又不想只做换皮打砖块的初学者。我会按实际开发顺序,从场景搭建、角色控制、攻击判定、怪物 AI、血条 UI 到打包提交,把每一步的参数和踩坑点讲清楚。你跟着做,最后手里会有一个能运行、能演示、代码结构清晰的格斗游戏项目,而不是一堆散装脚本。
2. 场景搭建与角色控制:先把「能动能打」跑通
2.1 项目初始设置与素材导入
打开 Unity Hub 新建一个 2D 项目,模板选 2D Core,名字随便起,比如MonsterFighter。创建完成后,先别急着写代码,把项目结构理清楚。在 Assets 下建几个文件夹:Scripts、Sprites、Animations、Prefabs、Audio。这个习惯能让你后面找东西不抓狂,老师翻你工程的时候也会觉得你靠谱。
素材方面,初学者不用自己画。Unity Asset Store 里搜2D fighter或者pixel monster,筛选 Free 资源,下载几个带 idle、walk、attack、hurt 动画的角色包。导入后把贴图拖进Sprites文件夹,动画文件拖进Animations。注意,导入贴图时选中图片,在 Inspector 里把Texture Type确认为Sprite (2D and UI),Pixels Per Unit保持默认 100 就行。如果角色显示得太大或太小,调这个值,不要直接改 Transform 的 Scale,不然后面物理和动画会乱。
场景里建一个空物体叫Ground,加BoxCollider2D,用来做地面碰撞。再建一个Player空物体,把角色 Sprite 拖进去作为子物体,给 Player 加Rigidbody2D和CapsuleCollider2D。Rigidbody2D 的Gravity Scale设成 3 左右,Freeze Rotation Z勾上,防止角色被撞得旋转。Collider 的大小调到刚好包住角色身体,不要包到武器,武器判定后面单独做。
2.2 用状态机思路写角色移动和跳跃
初学者最容易犯的错是把移动、跳跃、攻击全塞在一个Update里,用一堆 bool 互相判断,最后自己都理不清。正确做法是用一个简单的枚举状态机。新建脚本PlayerController.cs,挂在 Player 上。
using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; public float jumpForce = 12f; public Transform groundCheck; public LayerMask groundLayer; private Rigidbody2D rb; private Animator anim; private bool isGrounded; private float moveInput; // 状态枚举,后续攻击状态也加在这里 private enum State { Idle, Run, Jump, Attack } private State currentState; void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponentInChildren<Animator>(); } void Update() { moveInput = Input.GetAxisRaw("Horizontal"); // 地面检测:以 groundCheck 为圆心画小圆,碰到 groundLayer 就算落地 isGrounded = Physics2D.OverlapCircle(groundCheck.position, 0.1f, groundLayer); if (Input.GetButtonDown("Jump") && isGrounded) { rb.velocity = new Vector2(rb.velocity.x, jumpForce); } // 根据输入切换状态 if (isGrounded) currentState = Mathf.Abs(moveInput) > 0.01f ? State.Run : State.Idle; else currentState = State.Jump; UpdateAnimation(); } void FixedUpdate() { rb.velocity = new Vector2(moveInput * moveSpeed, rb.velocity.y); } void UpdateAnimation() { anim.SetBool("isRunning", currentState == State.Run); anim.SetBool("isJumping", currentState == State.Jump); // 翻转角色朝向 if (moveInput != 0) transform.localScale = new Vector3(Mathf.Sign(moveInput), 1, 1); } }这段代码的逻辑说明:Update负责读输入和状态判断,FixedUpdate负责物理移动,两者分开是 Unity 的官方推荐做法。groundCheck是一个空物体,放在角色脚底,半径 0.1 的圆形检测足够判断是否落地。groundLayer在 Inspector 里设成 Ground 层,记得给 Ground 物体指定 Layer。Mathf.Sign用来翻转角色朝向,比手动写 if-else 简洁。
参数方面,moveSpeed设 5 比较跟手,jumpForce设 12 能跳过一人高的平台。如果你觉得跳跃太飘,把 Rigidbody2D 的Gravity Scale调到 4 或 5。动画状态机里建两个 Bool 参数isRunning和isJumping,分别连到 Run 和 Jump 动画,过渡条件记得取消Has Exit Time,否则动画会卡顿。
2.3 摄像机跟随与边界限制
摄像机跟随是格斗游戏的标配。新建脚本CameraFollow.cs,挂在 Main Camera 上。
using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; public float smoothSpeed = 0.125f; public Vector2 offset = new Vector2(0, 1); public float minX = -10f; public float maxX = 10f; void LateUpdate() { if (target == null) return; Vector3 desiredPos = new Vector3( Mathf.Clamp(target.position.x + offset.x, minX, maxX), target.position.y + offset.y, -10f ); transform.position = Vector3.Lerp(transform.position, desiredPos, smoothSpeed); } }LateUpdate在角色移动之后执行,避免摄像机抖动。smoothSpeed越小跟随越慢,0.125 是常见值。minX和maxX限制摄像机左右范围,防止跑出地图看到空白。offset的 y 设 1 让角色稍微偏下,视野更舒服。注意摄像机 z 轴保持 -10,2D 游戏固定这个值。
3. 攻击判定与怪物 AI:让战斗真正打起来
3.1 用动画事件触发攻击判定
攻击判定不要用Input.GetKeyDown直接扣血,那样会出现「按键就掉血」的假手感。正确做法是动画播放到出拳那一帧,用动画事件调用判定函数。在 Attack 动画的最后一帧附近右键Add Animation Event,函数名填OnAttackHit。
using UnityEngine; public class PlayerAttack : MonoBehaviour { public Transform attackPoint; public float attackRange = 0.5f; public LayerMask enemyLayer; public int attackDamage = 1; private Animator anim; void Start() { anim = GetComponentInChildren<Animator>(); } void Update() { if (Input.GetKeyDown(KeyCode.J)) { anim.SetTrigger("Attack"); } } // 动画事件调用,出拳瞬间执行 public void OnAttackHit() { Collider2D[] hitEnemies = Physics2D.OverlapCircleAll( attackPoint.position, attackRange, enemyLayer); foreach (Collider2D enemy in hitEnemies) { enemy.GetComponent<MonsterHealth>()?.TakeDamage(attackDamage); } } // 在 Scene 视图画出攻击范围,方便调试 void OnDrawGizmosSelected() { if (attackPoint == null) return; Gizmos.color = Color.red; Gizmos.DrawWireSphere(attackPoint.position, attackRange); } }attackPoint放在角色拳头位置,attackRange设 0.5 左右,enemyLayer指定 Monster 层。OnDrawGizmosSelected让你在 Scene 视图里看到红色圆圈,调范围的时候不用瞎猜。?.是空引用保护,防止怪物没有MonsterHealth组件时报错。动画状态机里加一个 Trigger 参数Attack,从 Idle 和 Run 都能过渡到 Attack,过渡时间设 0.1 秒,取消Has Exit Time。
3.2 怪物巡逻与追击的简易 AI
怪物 AI 不需要太复杂,初学者用「巡逻 + 发现玩家就追 + 进入攻击范围就打」三段式就够了。新建MonsterAI.cs。
using UnityEngine; public class MonsterAI : MonoBehaviour { public float patrolSpeed = 1.5f; public float chaseSpeed = 3f; public float detectRange = 5f; public float attackRange = 1f; public Transform player; public Transform patrolPointA; public Transform patrolPointB; public int damage = 1; private Rigidbody2D rb; private Animator anim; private Transform currentPatrolTarget; private float attackCooldown = 1f; private float lastAttackTime; void Start() { rb = GetComponent<Rigidbody2D>(); anim = GetComponentInChildren<Animator>(); currentPatrolTarget = patrolPointB; } void Update() { float distanceToPlayer = Vector2.Distance(transform.position, player.position); if (distanceToPlayer <= attackRange) { rb.velocity = Vector2.zero; anim.SetBool("isMoving", false); TryAttack(); } else if (distanceToPlayer <= detectRange) { ChasePlayer(); } else { Patrol(); } } void Patrol() { anim.SetBool("isMoving", true); Vector2 dir = (currentPatrolTarget.position - transform.position).normalized; rb.velocity = new Vector2(dir.x * patrolSpeed, rb.velocity.y); if (Vector2.Distance(transform.position, currentPatrolTarget.position) < 0.2f) { currentPatrolTarget = currentPatrolTarget == patrolPointA ? patrolPointB : patrolPointA; } FlipSprite(dir.x); } void ChasePlayer() { anim.SetBool("isMoving", true); Vector2 dir = (player.position - transform.position).normalized; rb.velocity = new Vector2(dir.x * chaseSpeed, rb.velocity.y); FlipSprite(dir.x); } void TryAttack() { if (Time.time - lastAttackTime < attackCooldown) return; lastAttackTime = Time.time; anim.SetTrigger("Attack"); // 实际伤害在动画事件里调用 DealDamage } public void DealDamage() { if (Vector2.Distance(transform.position, player.position) <= attackRange) { player.GetComponent<PlayerHealth>()?.TakeDamage(damage); } } void FlipSprite(float dirX) { if (Mathf.Abs(dirX) > 0.01f) transform.localScale = new Vector3(Mathf.Sign(dirX), 1, 1); } }逻辑说明:Update里按距离分三种情况,优先级是攻击 > 追击 > 巡逻。attackCooldown防止怪物攻速过快,1 秒是合理值。DealDamage同样挂在动画事件上,保证伤害和动画同步。patrolPointA和patrolPointB是两个空物体,放在场景里标记巡逻路线。detectRange设 5,attackRange设 1,这两个值根据你角色和怪物的大小微调。
3.3 血条 UI 与受伤反馈
血条用 Unity 自带的 Slider 就行。在 Canvas 下建两个 Slider,一个叫PlayerHealthBar,一个叫MonsterHealthBar。把 Slider 的Interactable取消勾选,Transition设为 None。然后写Health.cs统一管理。
using UnityEngine; using UnityEngine.UI; public class Health : MonoBehaviour { public int maxHealth = 5; public Slider healthBar; public bool isPlayer; private int currentHealth; void Start() { currentHealth = maxHealth; if (healthBar != null) { healthBar.maxValue = maxHealth; healthBar.value = currentHealth; } } public void TakeDamage(int amount) { currentHealth -= amount; currentHealth = Mathf.Clamp(currentHealth, 0, maxHealth); if (healthBar != null) healthBar.value = currentHealth; // 受伤闪红 StartCoroutine(FlashRed()); if (currentHealth <= 0) Die(); } System.Collections.IEnumerator FlashRed() { SpriteRenderer sr = GetComponentInChildren<SpriteRenderer>(); if (sr == null) yield break; Color original = sr.color; sr.color = Color.red; yield return new WaitForSeconds(0.1f); sr.color = original; } void Die() { if (isPlayer) { UnityEngine.SceneManagement.SceneManager.LoadScene( UnityEngine.SceneManagement.SceneManager.GetActiveScene().name); } else { Destroy(gameObject); } } }maxHealth玩家设 5,怪物设 3,数值小一点战斗节奏快。FlashRed用协程做受伤反馈,0.1 秒足够明显又不刺眼。玩家死亡直接重载当前场景,怪物死亡销毁物体。注意healthBar在 Inspector 里拖对应的 Slider,玩家和怪物各挂一个Health,勾选isPlayer区分。
4. 避坑与排查:初学者最容易翻车的 5 个地方
4.1 攻击判定打不中,Gizmos 圆圈明明碰到了
现象:动画事件触发了,OverlapCircleAll也执行了,但怪物不掉血。原因九成是 Layer 没设对。enemyLayer在 Inspector 里要选 Monster 层,同时怪物物体的 Layer 也要改成 Monster。如果怪物是子物体,Layer 要设在挂 Collider 的那一层。解决:选中怪物,Inspector 顶部 Layer 下拉选 Monster,如果没有就 Add Layer 新建。然后确认PlayerAttack脚本的enemyLayer字段选的是 Monster,不是 Default。
4.2 角色跳跃后卡在空中,或者落地检测一直为 false
现象:按跳跃能跳,但落地后isGrounded还是 false,导致跳不起来。原因是groundCheck的位置不对,或者groundLayer没设。解决:在 Scene 视图里选中 Player,看groundCheck空物体的位置是不是在脚底。如果角色 Collider 底部在 y=0,groundCheck的 y 设 -0.5 左右。groundLayer在 Inspector 里选 Ground 层,同时 Ground 物体的 Layer 也要改成 Ground。检测半径 0.1 如果还不行,调到 0.15。
4.3 动画播放但角色不移动,或者移动时动画不切换
现象:按方向键角色滑着走,但 Idle 动画一直在播。原因是 Animator 参数名和代码里写的不一致,或者过渡条件没设对。解决:打开 Animator 窗口,看 Parameters 里是不是有isRunning和isJumping,拼写要和代码完全一致,大小写敏感。然后点 Idle 到 Run 的过渡箭头,Conditions 里加isRunning为 true,取消Has Exit Time。Run 到 Idle 同理,isRunning为 false。
4.4 怪物攻击没有伤害,或者伤害触发多次
现象:怪物挥拳动画播了,但玩家不掉血,或者一次攻击掉好几滴血。原因是动画事件没加,或者加在了多个帧上。解决:打开怪物 Attack 动画,在出拳那一帧右键 Add Animation Event,函数名填DealDamage。检查时间轴上是不是只有一个事件标记,多了就删掉。另外确认MonsterAI脚本挂在怪物身上,DealDamage是 public 方法。
4.5 打包后 UI 血条不显示,或者分辨率错乱
现象:在 Editor 里跑得好好的,Build 出来血条不见了。原因是 Canvas 的Render Mode设成了Screen Space - Camera但没指定摄像机,或者 UI 缩放没适配。解决:选中 Canvas,Render Mode改成Screen Space - Overlay,这是最省事的。然后 Canvas Scaler 的UI Scale Mode选Scale With Screen Size,Reference Resolution设 1920x1080,Match设 0.5。这样不管什么分辨率,血条都在该在的位置。
5. 进阶技巧:用对象池和音效让手感上一个台阶
5.1 对象池管理怪物生成,避免频繁 Instantiate
如果你的游戏有刷怪逻辑,直接Instantiate和Destroy会产生 GC 卡顿。初学者可以用一个简单的对象池。新建ObjectPool.cs。
using System.Collections.Generic; using UnityEngine; public class ObjectPool : MonoBehaviour { public GameObject monsterPrefab; public int poolSize = 10; private Queue<GameObject> pool = new Queue<GameObject>(); void Start() { for (int i = 0; i < poolSize; i++) { GameObject obj = Instantiate(monsterPrefab); obj.SetActive(false); pool.Enqueue(obj); } } public GameObject GetMonster(Vector3 position) { if (pool.Count == 0) { GameObject obj = Instantiate(monsterPrefab); obj.SetActive(true); return obj; } GameObject monster = pool.Dequeue(); monster.transform.position = position; monster.SetActive(true); return monster; } public void ReturnMonster(GameObject monster) { monster.SetActive(false); pool.Enqueue(monster); } }poolSize设 10 够用,怪物死亡时不Destroy,而是调用ReturnMonster放回池子。这样运行久了也不会卡。注意怪物脚本里Die方法要改成调用对象池的ReturnMonster,而不是Destroy(gameObject)。
5.2 音效与打击感:三个必须加的音效点
格斗游戏的手感一半靠音效。至少加三个:挥拳破空声、击中反馈声、受伤呻吟声。在PlayerAttack的OnAttackHit里,击中敌人时播放hitSound;在Health.TakeDamage里播放hurtSound。AudioSource 挂在角色上,PlayOneShot播放短音效,不要用Play否则会打断。音量Volume设 0.7 左右,太高会爆。AudioClip 在 Asset Store 搜free fight sound就能找到。
5.3 用宏定义区分 Editor 和打包环境
热词里提到的unity宏定义在这里很有用。比如你想在 Editor 里显示 Gizmos 调试,但打包后不显示,可以用#if UNITY_EDITOR包住OnDrawGizmosSelected。再比如你想在 Editor 里按 R 键直接重开,打包后禁用,也可以加宏。这个技巧能让你的代码更专业,老师看到会觉得你考虑周全。
#if UNITY_EDITOR void Update() { if (Input.GetKeyDown(KeyCode.R)) { UnityEngine.SceneManagement.SceneManager.LoadScene( UnityEngine.SceneManagement.SceneManager.GetActiveScene().name); } } #endif最后说一个我自己的习惯:每次改完代码,先按 Ctrl+S 保存,再回 Unity 等编译完成,看到右下角没有红色报错再运行。这个习惯帮我省了无数次「改了没生效」的玄学时间。项目做完后,把Scripts文件夹里的脚本按功能分好,注释写清楚,打包前在 Build Settings 里把场景加进去,选 Windows 或 Mac 平台,点 Build 就行。希望帮到你。
本文还有配套的精品资源,点击获取