多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

零基础用Unity6和C#实战2D RPG战斗系统:从输入到伤害反馈

零基础用Unity6和C#实战2D RPG战斗系统:从输入到伤害反馈 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 FilesAsset Serialization设成Force Text。这两个设置是为了后续如果你要备份或者协作不会因为二进制文件冲突搞得一团糟。第二在Project Settings Player里把Company Name和Product Name填上不然后面打包出来的可执行文件名字会很难看。第三如果你打算做像素风在Project Settings Graphics里确认一下渲染管线是URP还是Built-inUnity6默认推荐URP2D项目用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。默认所有层互相碰撞但战斗系统里我们通常不希望玩家的攻击判定框和玩家的受击框碰撞也不希望敌人的攻击判定框和敌人自己碰撞。所以提前规划好LayerPlayer玩家本体Enemy敌人本体PlayerAttack玩家攻击判定EnemyAttack敌人攻击判定Ground地面然后在碰撞矩阵里把PlayerAttack和Player、EnemyAttack和Enemy的勾去掉。这个设置不提前做后面攻击判定框一出来就会跟角色自己碰撞产生莫名其妙的物理推挤排查起来很烦。3. 角色移动与输入系统从旧Input到新Input System的取舍3.1 为什么我建议新手先用旧Input ManagerUnity6里有两套输入系统旧的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 GetComponentRigidbody2D(); } 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个单位。如果你做的是像素风PPUPixels Per Unit设成16或32那移动速度可能要调到3到4之间具体看你Sprite的尺寸。3.3 角色朝向与翻转别用Rotate2D角色左右翻转新手容易想到用transform.Rotate(0, 180, 0)但这样会把角色的碰撞体、攻击判定框全部转过去后面攻击判定会出各种奇怪的问题。正确做法是改SpriteRenderer.flipXif (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.GetComponentEnemyHealth()?.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。参数用SpeedFloat控制Idle和Run的切换AttackTrigger触发攻击动画HurtTrigger触发受击动画DieBool触发死亡动画过渡条件这样设Idle到Run用Speed 0.1Run到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; } }玩家和敌人都实现这个接口。好处是攻击判定那边不需要关心打的是玩家还是敌人直接GetComponentIDamageable()?.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攻击。用简单的距离判断来切换玩家距离大于chaseRangeIdle玩家距离小于chaseRange但大于attackRangeChase玩家距离小于attackRangeAttack这个逻辑用一个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 QueueGameObject pool new QueueGameObject(); 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动画结束时设为falseUpdate里判断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就return0.2秒后恢复。注意硬直时间不能太长否则敌人会被无限连击游戏就没难度了。11. 项目跑通之后下一步可以往哪扩展战斗系统跑通之后你手里已经有一套可复用的框架了。接下来可以往几个方向扩展多种敌人用继承或者ScriptableObject配置不同敌人的血量、速度、攻击力多种武器把攻击范围、伤害、动画做成可配置的数据技能系统在普通攻击之外加冷却更长的特殊技能掉落系统敌人死亡后掉落金币或道具。但我的建议是先把当前这套战斗系统打磨到自己满意再加新功能。很多新手项目死在功能越加越多每个都没做完。你把这套战斗做到手感舒服、没有明显bug、代码结构清晰这本身就是一份可以拿得出手的作品。面试的时候面试官更看重你对一个系统的理解深度而不是你做过多少个半成品。我个人在实际操作中的体会是做2D RPG战斗系统最大的收获不是学会了某个API而是理解了输入→状态→判定→反馈这条完整链路。这条链路想清楚了后面做任何类型的战斗不管是横版、俯视角还是回合制底层思路都是相通的。
返回列表