☰
零基础用Unity6和C#实战2D RPG战斗系统:从输入到伤害反馈
2026/10/1 18:58:42 网站建设 项目流程

1. 为什么零基础做2D RPG战斗系统,反而比做完整游戏更靠谱

很多人一上来就想做一款完整的2D RPG,结果卡在背包系统、对话系统、任务系统里出不来,三个月过去连一场像样的战斗都没跑起来。我见过太多这样的案例,包括我自己早期也是这么栽跟头的。后来我调整了思路:先只做战斗系统。为什么?因为战斗系统是RPG里技术密度最高、反馈最直接、最容易验证学习成果的模块。你做完一场战斗,立刻能看到角色挥剑、敌人掉血、伤害数字弹出,这种正反馈是支撑你继续学下去的关键燃料。

Unity6在这个时间点做2D RPG战斗系统,有几个很实际的优势。它的2D工具链已经非常成熟,Sprite Editor、Tilemap、2D物理系统、Animation窗口这些基础模块开箱即用,不需要你额外装一堆第三方插件。C#作为脚本语言,语法清晰,对新手友好,同时又能支撑你后续往复杂逻辑扩展。关键词里提到的Unity6、2D RPG、C#、游戏开发,这四个词基本框定了我们这次要聊的全部范围。

这篇文章适合谁?如果你刚装好Unity6,打开编辑器一脸懵,不知道从哪下手;或者你跟着一些教程做过Flappy Bird、贪吃蛇,但一碰到角色攻击判定、动画状态切换、敌人AI就卡住;再或者你是个有编程基础但没碰过游戏引擎的人,想快速理解Unity的2D工作流——那这篇内容就是给你写的。我会从项目结构开始,一步步把战斗系统的核心模块拆开讲,包括输入处理、攻击判定、动画状态机、伤害计算、敌人行为、UI反馈,最后还会聊一些新手最容易踩的坑和优化思路。

需要提前说明的是,我不会只给你一堆代码让你复制粘贴。每做一个选择,我会告诉你为什么这么选,有没有别的方案,实际做的时候哪里容易出问题。这些经验很多是文档里不会写的,只有真正动手做过、踩过坑的人才会在意。

2. 动手之前的项目结构规划与Unity6环境确认

2.1 Unity6安装后必须检查的三个设置

装好Unity6之后,别急着建场景。先做三件事:第一,在Edit > Project Settings > Editor里,把Version Control Mode设成Visible Meta Files,Asset Serialization设成Force Text。这两个设置是为了后续如果你要备份或者协作,不会因为二进制文件冲突搞得一团糟。第二,在Project Settings > Player里,把Company Name和Product Name填上,不然后面打包出来的可执行文件名字会很难看。第三,如果你打算做像素风,在Project Settings > Graphics里确认一下渲染管线是URP还是Built-in,Unity6默认推荐URP,2D项目用URP的2D Renderer对Sprite光照支持更好。

注意:Unity6的2D模板里有一个"2D (URP)"模板,新建项目时直接选这个,省得后面手动配渲染管线。如果你已经建了Built-in的项目,也不是不能做,但后续想加2D光照会麻烦一些。

2.2 文件夹结构:别等乱了再整理

我见过太多新手项目,Assets根目录下堆了几百个文件,找一张贴图要翻半天。从一开始就建好文件夹结构,后面会省很多时间。我的习惯是这样的:

  • _Project/Art/Sprites:放角色、敌人、特效的Sprite
  • _Project/Art/Animations:放Animator Controller和Animation Clip
  • _Project/Audio:音效和BGM
  • _Project/Prefabs:预制体,角色、敌人、伤害数字都做成Prefab
  • _Project/Scripts/Player:玩家相关脚本
  • _Project/Scripts/Enemy:敌人相关脚本
  • _Project/Scripts/Combat:伤害计算、攻击判定等战斗核心逻辑
  • _Project/Scripts/UI:血条、伤害数字等UI脚本
  • _Project/Scenes:场景文件

前面加下划线是为了让_Project排在Assets目录最上面,方便快速定位。这个习惯是我做了几个项目之后养成的,看起来是小事,但实际开发中能省不少找文件的时间。

2.3 2D物理设置里那个容易忽略的层碰撞矩阵

在Project Settings > Physics 2D里,有一个Layer Collision Matrix。默认所有层互相碰撞,但战斗系统里我们通常不希望玩家的攻击判定框和玩家的受击框碰撞,也不希望敌人的攻击判定框和敌人自己碰撞。所以提前规划好Layer:

  • Player:玩家本体
  • Enemy:敌人本体
  • PlayerAttack:玩家攻击判定
  • EnemyAttack:敌人攻击判定
  • Ground:地面

然后在碰撞矩阵里,把PlayerAttack和Player、EnemyAttack和Enemy的勾去掉。这个设置不提前做,后面攻击判定框一出来就会跟角色自己碰撞,产生莫名其妙的物理推挤,排查起来很烦。

3. 角色移动与输入系统:从旧Input到新Input System的取舍

3.1 为什么我建议新手先用旧Input Manager

Unity6里有两套输入系统:旧的Input Manager和新的Input System包。新Input System功能更强,支持多设备、按键重映射、输入动作资产,但学习曲线陡。对于零基础做2D RPG战斗,我的建议是先用旧Input Manager把战斗跑通,等你理解了输入到行为的完整链路,再回头换新系统。

原因很简单:旧系统的Input.GetAxisRaw("Horizontal")一行代码就能拿到方向,新系统你要先建Input Actions资产、配Action Map、生成C#类、再订阅回调。新手在这个阶段容易被这些配置绕晕,反而忽略了战斗逻辑本身。等你把攻击、受击、死亡这一套都做完了,再换新Input System,那时候你对"输入要驱动什么"已经心里有数,迁移起来会顺很多。

3.2 移动脚本的核心逻辑与参数解释

玩家移动脚本我通常叫PlayerController,核心就几行:

public class PlayerController : MonoBehaviour { [SerializeField] private float moveSpeed = 5f; private Rigidbody2D rb; private Vector2 moveInput; void Awake() { rb = GetComponent<Rigidbody2D>(); } void Update() { moveInput.x = Input.GetAxisRaw("Horizontal"); moveInput.y = Input.GetAxisRaw("Vertical"); moveInput = moveInput.normalized; } void FixedUpdate() { rb.MovePosition(rb.position + moveInput * moveSpeed * Time.fixedDeltaTime); } }

这里有几个细节值得说。moveInput.normalized是为了防止斜向移动速度比直线快,因为斜向的向量长度是1.414,不归一化的话斜着走会快41%。FixedUpdate里用MovePosition而不是直接改transform.position,是因为2D物理系统在FixedUpdate里更新,直接改transform会导致物理插值出问题,角色移动看起来会抖。

moveSpeed设成5是一个比较舒服的值,配合Unity默认的1单位=1米,角色每秒移动5个单位。如果你做的是像素风,PPU(Pixels Per Unit)设成16或32,那移动速度可能要调到3到4之间,具体看你Sprite的尺寸。

3.3 角色朝向与翻转:别用Rotate

2D角色左右翻转,新手容易想到用transform.Rotate(0, 180, 0),但这样会把角色的碰撞体、攻击判定框全部转过去,后面攻击判定会出各种奇怪的问题。正确做法是改SpriteRenderer.flipX:

if (moveInput.x != 0) { spriteRenderer.flipX = moveInput.x < 0; }

这样只翻转视觉,不影响任何逻辑和碰撞。这个坑我早期踩过,角色一转身攻击判定框跑到背后去了,排查了半天才发现是Rotate惹的祸。

4. 攻击判定:从碰撞盒到OverlapBox的完整实现

4.1 攻击判定的三种方案对比

做2D近战攻击判定,常见有三种方案:

方案原理优点缺点
碰撞盒触发攻击时启用一个BoxCollider2D,靠OnTriggerEnter检测实现简单,可视化依赖物理帧,快速攻击可能漏判
OverlapBox攻击时用Physics2D.OverlapBox直接查询区域即时检测,不依赖物理帧需要手动指定检测中心和大小
射线检测从角色向前发射射线适合远程和精确判定近战范围判定不直观

我推荐OverlapBox,因为它在攻击瞬间立即返回结果,不会因为物理更新时机导致漏判。而且你可以用Gizmos在Scene窗口画出检测范围,调试非常方便。

4.2 OverlapBox攻击判定的代码实现

public class PlayerAttack : MonoBehaviour { [SerializeField] private Transform attackPoint; [SerializeField] private float attackRange = 0.8f; [SerializeField] private LayerMask enemyLayers; [SerializeField] private int attackDamage = 10; public void PerformAttack() { Collider2D[] hitEnemies = Physics2D.OverlapBoxAll( attackPoint.position, new Vector2(attackRange, attackRange), 0f, enemyLayers ); foreach (Collider2D enemy in hitEnemies) { enemy.GetComponent<EnemyHealth>()?.TakeDamage(attackDamage); } } void OnDrawGizmosSelected() { if (attackPoint == null) return; Gizmos.color = Color.red; Gizmos.DrawWireCube(attackPoint.position, new Vector2(attackRange, attackRange)); } }

attackPoint是一个空物体,挂在角色下面,位置放在角色面朝方向的前方。攻击时根据角色朝向调整attackPoint.localPosition的x值正负。enemyLayers在Inspector里勾选Enemy层,这样只检测敌人,不会打到地面或者自己。

OnDrawGizmosSelected是调试利器,选中角色时会在Scene窗口画出红色方框,你一眼就能看出攻击范围对不对。调整attackRange的时候不用猜,直接看框。

4.3 攻击节奏控制:别让玩家无脑连点

如果没有攻击冷却,玩家按住攻击键就能每帧触发一次OverlapBox,敌人瞬间被秒。所以需要加一个攻击间隔:

private float attackCooldown = 0.4f; private float lastAttackTime; public void TryAttack() { if (Time.time - lastAttackTime < attackCooldown) return; lastAttackTime = Time.time; PerformAttack(); }

0.4秒是一个比较通用的近战攻击间隔,配合动画长度调整。如果你的攻击动画是0.3秒,那冷却设0.35到0.4之间比较自然。这个值没有绝对标准,要跟着动画节奏走。

提示:攻击冷却不要用Invoke或者协程来写,用时间戳判断更直观,也更容易在动画事件里控制。后面我们会讲到用Animation Event在动画的特定帧触发攻击判定,那时候时间戳的方式配合起来更顺。

5. 动画状态机:让攻击、受击、死亡不再打架

5.1 Animator Controller的基本结构

2D角色动画状态机,我通常建这几个状态:Idle、Run、Attack、Hurt、Die。参数用:

  • Speed(Float):控制Idle和Run的切换
  • Attack(Trigger):触发攻击动画
  • Hurt(Trigger):触发受击动画
  • Die(Bool):触发死亡动画

过渡条件这样设:Idle到Run用Speed > 0.1,Run到Idle用Speed < 0.1。Any State到Hurt用Hurt触发器,Any State到Die用Die为true。Attack从Idle或Run都能进,但攻击动画播完后要回到Idle或Run,这个用Has Exit Time配合过渡来实现。

5.2 用Animation Event在正确帧触发攻击判定

这是整个战斗系统里最关键的一个技巧。很多人把攻击判定写在按下攻击键的瞬间,结果动画还没挥到敌人,伤害就已经结算了,视觉和逻辑对不上。正确做法是在攻击动画的特定帧打一个Animation Event,在那个事件里调用PerformAttack()。

具体操作:在Animation窗口里,把播放头拖到角色武器挥到最前面的那一帧,点Add Event,在Inspector里选择PlayerAttack.PerformAttack方法。这样动画播到那一帧才会真正检测伤害,视觉和判定完全同步。

这个技巧是我做了好几个项目之后才养成的习惯。早期我都是按键就结算,后来发现玩家反馈"明明没打到却掉血"或者"明明打到了却没伤害",都是因为判定时机和动画不同步。

5.3 动画状态切换的优先级处理

战斗系统里经常出现的情况是:角色正在攻击,突然被敌人打中,应该播受击动画还是继续攻击?我的处理原则是受击优先于攻击,死亡优先于一切。在Animator里,Any State到Hurt的过渡要设置Can Transition To Self为false,防止连续受击时动画重置。同时Hurt状态的过渡中断源要勾上Interruption Source为Current State,这样受击能打断攻击。

死亡状态用Bool而不是Trigger,因为死亡只需要触发一次,用Bool可以确保动画播完后停在最后一帧,不会循环。

6. 伤害计算与血量系统:数字背后的设计逻辑

6.1 伤害公式:别把简单问题复杂化

新手容易一上来就设计复杂的伤害公式,什么攻击力减防御力、暴击率、暴击伤害、属性克制。我的建议是第一版战斗系统只用固定伤害,比如玩家攻击10点,敌人血量30点,三下打死。等你把整个流程跑通了,再往公式里加变量。

为什么?因为伤害公式是数值设计,不是程序架构。你程序架构没搭好,公式再复杂也跑不起来。而且固定伤害最容易验证:打三下敌人死,这个预期非常明确,出问题一眼就能看出来。

等你需要扩展的时候,把attackDamage从固定值改成从PlayerStats里读,加一个defense变量,公式改成Mathf.Max(1, attack - defense),这样最小伤害保底1点,防止出现0伤害导致敌人永远打不死。

6.2 血量系统的接口设计

我习惯用一个IDamageable接口来统一所有可受伤对象:

public interface IDamageable { void TakeDamage(int damage); bool IsDead { get; } }

玩家和敌人都实现这个接口。好处是攻击判定那边不需要关心打的是玩家还是敌人,直接GetComponent<IDamageable>()?.TakeDamage(damage)就行。后面如果你要加可破坏的箱子、木桶,也实现这个接口,攻击逻辑完全不用改。

6.3 受击反馈:没有反馈的战斗是没有灵魂的

敌人掉血了,但玩家看不出来,那战斗体验就是灾难。受击反馈至少要做三样:颜色闪烁、击退、伤害数字。

颜色闪烁最简单,受击时把SpriteRenderer的color改成红色,0.1秒后改回白色。击退用rb.AddForce给一个反向的力,力度不用大,2到3个单位就够。伤害数字用一个TextMeshPro预制体,在受击位置生成,然后向上飘并淡出。

public void TakeDamage(int damage) { currentHealth -= damage; StartCoroutine(FlashRed()); StartCoroutine(Knockback()); ShowDamageNumber(damage); if (currentHealth <= 0) Die(); }

这三个反馈叠加起来,打击感立刻就不一样了。我试过只做掉血不做反馈,玩起来像在打空气;加上这三个之后,同样的逻辑,手感提升非常明显。

7. 敌人行为:从站桩木偶到会追会打的对手

7.1 敌人状态机的简化设计

敌人AI不需要做得太复杂,第一版只要三个状态:Idle(待机)、Chase(追击)、Attack(攻击)。用简单的距离判断来切换:

  • 玩家距离大于chaseRange:Idle
  • 玩家距离小于chaseRange但大于attackRange:Chase
  • 玩家距离小于attackRange:Attack

这个逻辑用一个EnemyAI脚本就能搞定,不需要行为树或者GOAP那些重型方案。新手先把这套跑通,理解状态切换的本质,后面再学复杂AI会容易很多。

7.2 追击逻辑与物理移动

敌人追击用Vector2.MoveTowards或者rb.MovePosition都行。我倾向用MovePosition,和玩家移动保持一致,物理表现更稳定。

void Chase() { Vector2 direction = (player.position - transform.position).normalized; rb.MovePosition(rb.position + direction * moveSpeed * Time.fixedDeltaTime); spriteRenderer.flipX = direction.x < 0; }

注意敌人也要做朝向翻转,不然追玩家的时候会倒着走,看起来很怪。

7.3 敌人攻击的前摇与后摇

敌人攻击不能瞬间结算,要有前摇。前摇的作用是给玩家反应时间,让战斗有来有回。实现方式是在攻击状态开始时等0.3秒,然后再做攻击判定,判定完再等0.5秒后摇,才能再次攻击。

IEnumerator AttackRoutine() { isAttacking = true; yield return new WaitForSeconds(0.3f); // 前摇 PerformAttack(); yield return new WaitForSeconds(0.5f); // 后摇 isAttacking = false; }

前摇0.3秒、后摇0.5秒是手感比较舒服的值。前摇太短玩家来不及躲,太长敌人显得很蠢;后摇太短敌人会连续攻击,玩家没有喘息机会。

8. 战斗UI:血条、伤害数字与状态提示

8.1 世界空间血条 vs 屏幕空间血条

玩家血条通常放在屏幕左上角,用Screen Space - Overlay的Canvas。敌人血条我建议用World Space的Canvas,挂在敌人头顶,这样每个敌人都有自己的血条,而且会跟着敌人移动。

World Space Canvas要注意一点:它的Scale要调小,通常0.01左右,不然血条会比敌人还大。另外要把Canvas的Sorting Layer设成和敌人Sprite同一层或者更高,防止被遮挡。

8.2 伤害数字的生成与回收

伤害数字如果每次受击都Instantiate再Destroy,频繁创建销毁会产生GC(垃圾回收)压力。战斗激烈的时候,一秒可能生成十几个伤害数字,GC一触发游戏就会卡顿。

我的做法是用对象池:预先创建10个伤害数字预制体,禁用后放在池子里,需要的时候取出来设置位置和文字,用完再放回去。这样运行期间几乎不产生GC。

public class DamageNumberPool : MonoBehaviour { [SerializeField] private GameObject damageNumberPrefab; private Queue<GameObject> pool = new Queue<GameObject>(); public GameObject Get() { if (pool.Count > 0) { GameObject obj = pool.Dequeue(); obj.SetActive(true); return obj; } return Instantiate(damageNumberPrefab); } public void Return(GameObject obj) { obj.SetActive(false); pool.Enqueue(obj); } }

这个对象池模式在游戏开发里非常通用,子弹、特效、敌人,凡是频繁生成销毁的东西都适用。新手早点掌握这个思路,后面做任何游戏都用得上。

8.3 血条平滑过渡:别让血条瞬间跳变

血条直接从满血变成半血,视觉上很突兀。用Mathf.Lerp或者Image.fillAmount配合协程做平滑过渡,血条会缓缓降下去,看起来舒服很多。

IEnumerator SmoothHealthBar(float targetFill) { float startFill = healthBar.fillAmount; float elapsed = 0f; while (elapsed < 0.3f) { elapsed += Time.deltaTime; healthBar.fillAmount = Mathf.Lerp(startFill, targetFill, elapsed / 0.3f); yield return null; } healthBar.fillAmount = targetFill; }

0.3秒的过渡时间比较合适,太快看不出来,太慢玩家会以为血条卡了。

9. 新手最容易踩的五个坑与排查思路

9.1 攻击判定框跟着角色旋转导致判定错位

前面提过,用transform.Rotate翻转角色会让攻击判定框跑到背后。排查方法:在Scene窗口选中角色,看攻击判定框的Gizmos是不是在角色面朝方向。如果反了,检查是不是用了Rotate而不是flipX。

9.2 动画事件没触发:检查方法名和参数

Animation Event调用方法时,方法必须是public的,而且参数类型要匹配。如果事件里传了int参数,方法签名必须是public void PerformAttack(int damage),不能是无参的。另外方法所在的脚本必须挂在和Animator同一个GameObject上,或者Animator能通过SendMessage找到。

9.3 敌人死亡后还在攻击:状态机没切断

敌人血量归零后,如果AI脚本还在运行,敌人会继续追着玩家打。解决方法是在Die方法里把AI脚本禁用掉:enabled = false,同时把Rigidbody2D设成simulated = false,防止尸体继续参与物理碰撞。

9.4 伤害数字被UI遮挡:Sorting Layer没设对

World Space的伤害数字如果Sorting Layer比UI Canvas低,会被UI挡住。在Sprite Renderer或者Canvas上把Sorting Layer设成"DamageNumber"这种专门的层,然后在Project Settings > Tags and Layers里把层的顺序调高。

9.5 攻击时角色还能移动:状态锁没加

攻击动画播放期间,玩家不应该能移动。在PlayerController里加一个isAttacking标志,攻击开始时设为true,动画结束时设为false,Update里判断if (isAttacking) return;。动画结束的时机用Animation Event在最后一帧触发,或者用Animator的State Machine Behaviour的OnStateExit。

10. 从能跑到好用:几个提升战斗手感的细节

10.1 攻击时的轻微前冲

角色攻击时向前位移一小段距离,大概0.3到0.5个单位,会让攻击更有力度感。实现方式是在攻击动画开始时给Rigidbody2D一个短促的力,或者用协程在0.1秒内把位置往前推一点。这个细节很多商业游戏都有,加上之后手感提升很明显。

10.2 受击时的屏幕震动

屏幕震动是增强打击感的经典手段。用一个简单的协程,在受击时让Camera的transform随机偏移0.1秒:

IEnumerator ShakeCamera(float duration, float magnitude) { Vector3 originalPos = camera.transform.localPosition; float elapsed = 0f; while (elapsed < duration) { float x = Random.Range(-1f, 1f) * magnitude; float y = Random.Range(-1f, 1f) * magnitude; camera.transform.localPosition = new Vector3(x, y, originalPos.z); elapsed += Time.deltaTime; yield return null; } camera.transform.localPosition = originalPos; }

震动幅度不要太大,0.1到0.2就够了,太大玩家会晕。

10.3 攻击音效的随机音调

同一个攻击音效连续播放会显得很单调。在AudioSource上设置pitch在0.9到1.1之间随机,每次播放音调略有不同,听起来自然很多。

audioSource.pitch = Random.Range(0.9f, 1.1f); audioSource.PlayOneShot(attackClip);

这个技巧成本极低,但效果很好,很多独立游戏都在用。

10.4 敌人受击时的硬直

敌人受击时短暂停止行动,0.2秒左右,会让玩家觉得攻击有"打断"效果。实现方式是在TakeDamage里设一个isStunned标志,AI的Update里判断如果isStunned就return,0.2秒后恢复。注意硬直时间不能太长,否则敌人会被无限连击,游戏就没难度了。

11. 项目跑通之后,下一步可以往哪扩展

战斗系统跑通之后,你手里已经有一套可复用的框架了。接下来可以往几个方向扩展:多种敌人,用继承或者ScriptableObject配置不同敌人的血量、速度、攻击力;多种武器,把攻击范围、伤害、动画做成可配置的数据;技能系统,在普通攻击之外加冷却更长的特殊技能;掉落系统,敌人死亡后掉落金币或道具。

但我的建议是,先把当前这套战斗系统打磨到自己满意,再加新功能。很多新手项目死在"功能越加越多,每个都没做完"。你把这套战斗做到手感舒服、没有明显bug、代码结构清晰,这本身就是一份可以拿得出手的作品。面试的时候,面试官更看重你对一个系统的理解深度,而不是你做过多少个半成品。

我个人在实际操作中的体会是,做2D RPG战斗系统最大的收获不是学会了某个API,而是理解了"输入→状态→判定→反馈"这条完整链路。这条链路想清楚了,后面做任何类型的战斗,不管是横版、俯视角还是回合制,底层思路都是相通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询