☰
Unity 2D动作游戏开发:从Animator状态机到攻击判定实战指南
2026/9/28 12:32:02 网站建设 项目流程

简介:一套基于Unity引擎的2D街机游戏开发资源,以经典“三国战纪”式玩法为线索,系统梳理从场景搭建、Sprite精灵、Animator动画、2D物理碰撞、输入处理到C#游戏逻辑、音频管理、UI界面、性能优化与多平台打包的完整技术链条。资源共443个文件,压缩包仅4.5MB,其中包含90个C#脚本、30个PNG素材、24个Anim动画文件、22个Asset以及Prefab、Shader、Controller等,类型覆盖代码、美术、动画、配置与场景数据,便于对照学习或直接拆解复用。目前已有335人学习下载。适合Unity初学者或希望了解2D街机动作游戏开发流程的开发者,通过实际资源快速掌握精灵动画、状态机、刚体碰撞及角色技能等关键环节;同时可作为小型横版闯关项目启动模板,节省从零搭建的重复劳动。

1. 拿到一套三国战纪风格的2D动画资源:先搭状态机,再谈写代码

把 Skill1.anim、Skill2.anim、Attack2.anim、Hurt3.anim、Idle.anim、FrontJump.anim、Run.anim、Walk.anim、RunAttack.anim 这一串动画剪辑丢进 Unity 的时候,很多人的第一反应是写移动脚本。我的建议正好相反:先搭 Animator 状态机,脚本放到第二步。因为这些 .anim 文件本身不会让角色动起来,它们只是关键帧数据,真正驱动角色的是状态机里那一条条转换箭头。

这篇文章适合两类人:第一次在 Unity 里做 2D 街机动作游戏、对着资源不知道从哪下手的;以及手上正好有序列帧动画资源、想复现三国战纪那种清版动作手感的人。这类横版游戏的角色动画诉求高度固定——待机、行走、跑动、轻攻击、跑动攻击、技能、跳跃、受击,这套资源基本都覆盖了,把它理顺就是一次完整的 Unity 2D 游戏流程演练。

2. Unity 2D街机游戏从哪里入手:场景、精灵与Animator状态机的搭法

2.1 先搞清楚.anim剪辑引用的是谁:精灵切片与资源导入

很多人拿到 .anim 文件直接往场景里拖,结果看到洋红色方块或者空白,第一反应是资源坏了。其实 .anim 是 Unity 的动画剪辑文件,里面存的是关键帧、曲线和事件,真正的像素来自 Sprite。检查方法很简单:选中某个 .anim,在 Inspector 里看它的关键帧引用了哪张贴图。

我一般按这个顺序处理动画资源:

  1. 把序列帧大图导入项目,在 Inspector 里把 Texture Type 设为 Sprite (2D and UI),Sprite Mode 设为 Multiple,点击 Apply。
  2. 打开 Sprite Editor,按 Grid by Cell Size 切片。切多大取决于动画资源的分辨率,比如单帧是 128x128,就设 128 到 128。
  3. 切完片后,检查每个 .anim 的 Sprite 引用是否指向切好的帧。如果引用的还是旧贴图,把对应帧拖进关键帧位置替换。
  4. 再把 .anim 全部选中拖进场景里同一个 GameObject,挂上 Animator 组件,把 Animator Controller 指进来。

这里最容易被忽略的是 Pivot 设置。人物类的切片我统一用 Bottom Center,也就是脚底中心。为什么?因为横版游戏里角色的脚要踩在地面上,Box Collider 2D 生成时也会自动贴合身体底部。如果你用默认的 Center,后面碰撞器对位的时候十有八九要手动调 offset,调来调去角色就悬空了。

2.2 搭Animator状态机:十个动画按一套逻辑串起来

这套动画清单正好是一个标准的横版清版动作角色的完整动作集:待机 Idle、行走 Walk、跑动 Run、轻攻击 Attack2、跑动攻击 RunAttack、两段技能 Skill1/Skill2、跳跃 FrontJump、受击 Hurt3。

我的状态机结构大致是这样一张转换表:

当前状态驱动条件目标状态备注
Any StateHurt3 TriggerHurt3受击优先级最高,打断一切动作
Any StateFrontJump TriggerFrontJump跳跃同样可以打断普通攻击
IdleSpeed > 0.1Walk带 Has Exit Time 做平滑过渡
WalkSpeed > 0.5Run用阈值区分走和跑
Run / Idle / WalkAttack TriggerAttack2攻击不带 Exit Time,按下立刻切
RunRunAttack TriggerRunAttack跑动攻击,播完回 Run
Idle / 移动Skill1 / Skill2 TriggerSkill1 / Skill2技能是硬直状态,期间锁移动
Attack2 / Skill动画播完Idle用 Exit Time 回到待机

记住一条原则:攻击、跳跃、受击这类需要“立刻响应”的转换,Has Exit Time 必须取消勾选,只靠 Trigger 驱动;而 Attack2 播完回 Idle 这个转换要勾选 Has Exit Time,否则动画还没播完就被强行切回去。

Transition Duration 也要设为 0。默认的 0.25 秒过渡在移动状态之间是手感润滑剂,但在攻击动作上就是灾难——Attack2 还没打完就开始往 Idle 插值,角色的刀会“飘”一段中间帧。

2.3 用代码驱动状态切换:Trigger与Bool的正确用法

状态机搭好后,需要一个驱动脚本。我习惯把动画驱动单独写一个脚本,和角色控制分开:

using UnityEngine; public class PlayerAnimatorDriver : MonoBehaviour { private Animator anim; private void Awake() { // Animator组件必须存在,取不到直接报错,避免后面空引用 anim = GetComponent<Animator>(); } private void Update() { // Horizontal是Input Manager里默认的轴,GetAxisRaw没有平滑值 float h = Input.GetAxisRaw("Horizontal"); // Speed是Animator窗口里的Float参数,名字不能拼错 anim.SetFloat("Speed", Mathf.Abs(h)); if (Input.GetKeyDown(KeyCode.J)) { // 先Reset再Set,防止上一次Trigger没被消费导致连按闪跳 anim.ResetTrigger("Attack"); anim.SetTrigger("Attack"); } if (Input.GetKeyDown(KeyCode.K)) { anim.ResetTrigger("Jump"); anim.SetTrigger("Jump"); } } }

逻辑说明:Speed 参数负责 Idle、Walk、Run 这组“移动类”状态的切换,Attack 和 Jump 用 Trigger 做一次性触发。Trigger 的特点是用完自动归位,不会卡住状态机;但同一帧里如果连按两次,Trigger 会被合并成一次,所以先 ResetTrigger 再 SetTrigger 是防抖的标准做法。

参数命名有一点必须注意:代码里的参数名和 Animator 窗口里的参数名大小写完全一致。Unity 不会因为你传错了 Trigger 名字报错,只是静默失败——这是最坑的地方,我见过有人排查了一晚上,最后发现 Skill1 在动画面板里写的是 skill1。

3. 输入与角色控制:三道C#脚本把街机按键映射到键盘

3.1 街机按键布局的映射方案

三国战纪这类清版动作游戏,标准配置是摇杆加三个攻击键。搬到键盘上,我的映射是:

街机按键功能键盘映射说明
摇杆移动WASD 或 方向键响应要生,不加平滑
A 键攻击J主攻击,放在左手食指位
B 键跳跃K食指旁边,方便连按
C 键技能L技能释放频率低,放远一点不误触
D 键跑动攻击U和 B 分开,避免误触

为什么不推荐用鼠标?因为这类游戏的输入本质是“方向 + 动作”的组合,比如跑动攻击是“按住方向再按攻击”。键盘和手柄天然支持多键同时按住,鼠标做不到。另外手柄玩家不用改逻辑,Unity 的旧输入系统会自动把手柄轴映射到 Horizontal。

3.2 用Input读取移动与攻击指令:Update里收集,FixedUpdate里消费

输入读取我单独放在一个脚本里,不跟角色控制器混在一起。原因很简单:输入要在 Update 里采集,物理运动在 FixedUpdate 里执行。如果直接在 FixedUpdate 里读 Input,遇到帧率波动会漏掉按键。

using UnityEngine; public class PlayerInput : MonoBehaviour { public float Horizontal { get; private set; } public bool JumpPressedThisFrame { get; private set; } private void Update() { // GetAxisRaw返回-1到1,没有平滑滤波,适合街机这种强调即时响应的场景 Horizontal = Input.GetAxisRaw("Horizontal"); // GetKeyDown只在按下的那一帧返回true,适合做Trigger JumpPressedThisFrame = Input.GetKeyDown(KeyCode.K); if (Input.GetKeyDown(KeyCode.J)) { OnAttackPressed?.Invoke(); } if (Input.GetKeyDown(KeyCode.L)) { OnSkillPressed?.Invoke(); } } public event System.Action OnAttackPressed; public event System.Action OnSkillPressed; }

逻辑说明:Horizontal 用属性暴露出来,角色控制器在 FixedUpdate 里拿这个值去改刚体速度。攻击和技能用 C# 事件抛出去,这样输入脚本不关心谁在听,状态机脚本和攻击脚本各自订阅。JumpPressedThisFrame 是给角色控制器判断用的,用一次清一次。

这里我特意用 Input.GetAxisRaw 而不是 Input.GetAxis,两者的区别是平滑滤波。GetAxis 自带一段斜坡加速,在横版动作里会让角色“飘”,起步和停步都不干脆。街机手感要的是按下去立刻走、松开立刻停,Raw 是正确选择。

3.3 移动、攻击、跳跃的脚本协同:攻击时锁定移动

角色控制器是这套代码的核心。它的职责有三块:移动、朝向、配合动画状态机。

using UnityEngine; [RequireComponent(typeof(Rigidbody2D))] public class CharacterController2D : MonoBehaviour { public float moveSpeed = 6f; public float jumpForce = 8f; private Rigidbody2D rb; private Animator anim; private bool isAttacking = false; private void Awake() { rb = GetComponent<Rigidbody2D>(); anim = GetComponent<Animator>(); } private void FixedUpdate() { // 攻击和技能释放期间锁死移动,这是动作游戏手感的基本盘 if (isAttacking) return; float h = GetComponent<PlayerInput>().Horizontal; rb.velocity = new Vector2(h * moveSpeed, rb.velocity.y); // 用Scale.x正负控制朝向,注意子物体判定框也要跟着翻 if (h != 0f) { Vector3 scale = transform.localScale; scale.x = Mathf.Sign(h) * Mathf.Abs(scale.x); transform.localScale = scale; } anim.SetFloat("Speed", Mathf.Abs(h)); } public void SetAttacking(bool value) { isAttacking = value; } }

逻辑说明:移动统一用 Rigidbody2D.velocity 赋值,不是 transform.Translate。因为玩家是 Dynamic 刚体,物理引擎按照速度积分算位置,你在 Update 里直接改 transform,下一物理帧会被刚体速度覆盖回去,表现出来就是抖动。

moveSpeed = 6 是 2D 横版角色的常见初值,配合 Orthographic 相机的 Size 来感受。如果屏幕里角色跑得太慢,先别急着改代码,检查 Camera 的 Orthographic Size 是不是默认 5,这个值决定了一个屏幕里能看到多少世界单位。一般来说,1920x1080 的分辨率下 Orthographic Size 设为 8,角色按 6 的速度跑动节奏比较合适。

攻击锁定这步最容易被新手漏掉。我在攻击脚本里这样接线:

public void LaunchAttack() { SetAttacking(true); anim.ResetTrigger("Attack"); anim.SetTrigger("Attack"); }

SetAttacking(true) 之后,FixedUpdate 里的移动代码直接 return,角色出招过程中推摇杆也不会走位。攻击动画播完,由 Animation Event 或者协程回调 SetAttacking(false)。这个标志位如果没做,就会出现动画在攻击、角色还在地上滑行的违和感。

4. 2D碰撞与攻击判定:碰撞器参数、Animation Event与LayerMask实战

4.1 Rigidbody2D与Collider2D的参数选择

横版动作里,物理组件的参数直接决定手感,我把推荐值整理成一张表:

组件参数推荐值原因
Rigidbody2DBody TypeDynamic玩家角色由物理驱动,静态刚体留给地面
Rigidbody2DCollision DetectionDiscrete常规移速足够用,高速冲刺再开 Continuous
Rigidbody2DInterpolateInterpolated防止低帧率下手感发顿
Rigidbody2DConstraints冻结 Z 旋转角色倒地乱转基本是没冻结旋转
BoxCollider2D大小贴合身体,不含武器判定按身体算,武器是攻击判定框的事
攻击判定框isTrigger开启触发器不参与物理碰撞,只做伤害检测

Collision Detection 这个参数值得多说一句。Discrete 是离散检测,每物理帧检查一次,角色速度不高时完全够用。但如果你把 moveSpeed 调成 20 以上,角色就会在帧间穿过薄墙——这时候才需要把检测模式改成 Continuous。注意 Continuous 是有性能代价的,只用在高危对象上,不要全场景无脑开。

角色脚下还有一个必须处理的点:落地检测。两次跳跃之间的间隔靠什么判断?常见做法是脚底放一个很小的空物体挂 BoxCollider2D,只参与地面层碰撞,然后在地面物体的 OnCollisionEnter2D 回调里置 isGrounded 为 true。这个做法比在 Update 里发射射线稳定,射线很容易漏掉高速下落的那一帧。

4.2 用Animation Event在攻击帧触发伤害判定

攻击判定我不用 Update 轮询,而是用 Animation Event。这样刀挥到哪一帧触发伤害,直接在动画时间轴上看百分百直观。选中 Attack2.anim 文件,在 Inspector 底部的事件轨上点 Add Animation Event,把事件拖到攻击帧——也就是动作幅度最大、刀伸到最前面的那一帧附近,方法名填 HitCheck。

public class AttackHitBox : MonoBehaviour { public GameObject hitBoxObject; // 攻击判定子物体,默认隐藏 public float activeTime = 0.15f; // 判定框存活时间,单位秒 public void HitCheck() { // 先激活判定框,注意碰撞响应发生在下一个物理帧 hitBoxObject.SetActive(true); StopAllCoroutines(); StartCoroutine(HideAfterDelay()); } private System.Collections.IEnumerator HideAfterDelay() { yield return new WaitForSeconds(activeTime); hitBoxObject.SetActive(false); } }

逻辑说明:HitCheck 里激活判定框,用协程延迟关闭,确保判定框只在攻击帧那一段时间内存在。activeTime = 0.15 秒大约是 9 帧,覆盖一次普通攻击的伤害窗口足够了。技能 Skill1/Skill2 如果有多段伤害,就在动画里多挂几个事件,每段各激活一次。

这里的坑在于 SetActive(true) 之后,OnTriggerEnter2D 不会在当前帧立刻触发。Unity 的物理系统是独立步进的,碰撞回调要到下一个物理帧才会派发。所以判定框要提前几帧激活,不能把事件挂在攻击动画的最后一帧,否则伤害会吞掉。我一般把事件挂在攻击帧的前 1-2 帧。

4.3 LayerMask与OnTriggerEnter2D的实战配置

伤害检测的代码要用 LayerMask 做过滤,而不是在 OnTriggerEnter2D 里判断 tag 字符串。字符串比较容易写错字,而且性能比位运算差。

public class PlayerAttackHitBox : MonoBehaviour { public LayerMask enemyLayer; public int attackDamage = 10; private void OnTriggerEnter2D(Collider2D other) { // LayerMask位运算:把other所在层转成bit,再和enemyLayer按位与 if (((1 << other.gameObject.layer) & enemyLayer.value) == 0) { return; // 不是敌人层,直接忽略 } EnemyHealth enemy = other.GetComponent<EnemyHealth>(); if (enemy != null) { enemy.TakeDamage(attackDamage); } } }

逻辑说明:1 << other.gameObject.layer是把层索引转成对应的二进制位,比如层 6 转出来是 1 左移 6 位,等于 64。enemyLayer.value 是在 Inspector 里勾选层的组合值。两者按位与之后为 0,说明这个碰撞体不在敌人层上,直接返回。这个写法比other.CompareTag("Enemy")快,也更安全——Inspector 里勾选层不会拼错字符串。

LayerMask 和 RenderingLayerMask 是两套东西,新手经常在 Inspector 里找错字段。LayerMask 是物理用的,负责碰撞矩阵、射线检测、伤害过滤;RenderingLayerMask 是渲染管线用的,负责遮挡剔除和光照分组。攻击判定里用的是物理 LayerMask,不要被名字里带 Layer 的字段带偏。

最后检查一处:Physics2D 的碰撞矩阵。在 Edit -> Project Settings -> Physics2D 里,玩家层和敌人层要勾上碰撞,攻击判定框层和敌人层要勾上 Trigger 响应。矩阵默认是全勾的,但项目里层多之后,很容易出现玩家攻击框和道具层误开物理响应,导致攻击判定被道具挡掉。

5. 避坑:横版动作手感复现里最常见的五个坑

5.1 受击动画Hurt3根本播不出来

现象:角色被打时没有反应,技能动画播到一半完全无视 Hurt3,或者 Hurt3 播了一帧就被弹回 Idle。

原因:Animator 里受击转换挂在 Any State 上,但它的优先级低于当前状态的内部转换。技能 Skill1 的动画转换到自身循环的箭头排在前面,Hurt3 的 Trigger 虽然接收到了,但是被技能状态内部的优先级压住了。

解决:在 Animator 窗口的 Layer 面板里找到 Hurt 转换箭头,把它拖到转换列表的顶部。右键转换条件,确认 Can Transition To Self 处于关闭状态。这样无论角色在哪个状态,受击 Trigger 都能抢到控制权。代码侧同时保证 ResetTrigger("Hurt3") 和 SetTrigger("Hurt3") 成对出现。

5.2 攻击时还能推摇杆跑动,状态机乱跳

现象:Attack2 动画播放过程中,角色还能在地上滑来滑去,看起来像滑冰。

原因:动画状态机这边攻击转换做对了,但代码侧没有锁移动。FixedUpdate 里还在照常读 Horizontal 并给刚体赋值速度,玩家按住方向键,角色就边打边跑。

解决:用 isAttacking 标志位,在 LaunchAttack() 里 SetAttacking(true),攻击动画的结束事件里 SetAttacking(false)。FixedUpdate 开头先查标志位,为 true 直接 return。如果要更狠一点,攻击期间连二段跳都锁,保证出招全程是硬直。这套做法是从格斗游戏里抄来了,角色在攻击动作里就只有攻击这一件事。

5.3 角色贴地悬空,碰撞器和精灵对不上

现象:角色看起来踩在地砖上,但碰撞器明显悬在半空,或者碰撞器在地板下面,导致角色不能正常下台阶。

原因:Sprite Pivot 用了默认的 Center。序列帧大图切片之后,人物在图中的位置可能偏高或偏低,Center 基准会让碰撞器跟着人物贴图的重心走,而不是脚底。

解决:在 Sprite Editor 里把这一套动画相关的切片 Pivot 全部改为 Bottom Center,然后重新生成 Box Collider 2D。如果是已经调好的项目,可以手动改 BoxCollider2D 的 offset 和 size,把底部对齐到角色脚底。以后导入任何人物类素材,第一件事就是统一 Pivot 到脚底,省掉后面所有的对位返工。

5.4 连续攻击的第一刀没有伤害

现象:每次进入战斗后的第一次攻击打不到人,第二刀、第三刀正常。

原因:攻击判定框挂的是 SetActive 方案。第一次 HitCheck 激活判定框时,角色和敌人之间的距离够近,但物理系统要到下一物理帧才派发 OnTriggerEnter2D;等事件派发时,判定框已经因为协程超时被关掉了。通俗说就是激活后的首帧碰撞还没结算,框就没了。

解决:把 Animation Event 放在攻击动作的前 1 到 2 帧,给物理结算留出时间差。另一个方向是把判定框常驻,用 LayerMask 过滤攻击窗口,攻击期间才允许伤害触发,这样就不依赖激活时机了。经验值是:任何攻击判定,如果你发现连续攻击第二次正常第一次打空,先查事件帧和物理帧的时间差,而不是查碰撞器。

5.5 角色翻转时攻击判定框方向错位

现象:角色朝左攻击时,判定框跑到了背后,或者判定框的位置和手臂方向相反。

原因:翻转用了localScale.x *= -1,但攻击判定框的 localPosition 是正方向的值。Scale 翻转只翻 Sprite 和挂接的子物体坐标,而判定框如果设了对称位置,比如 x=0.8,翻转后它跑到 x=-0.8,正好是背对敌人。

解决:翻转不要用 Scale,改用transform.rotation = Quaternion.Euler(0, 180, 0)。这样整个坐标系跟着角色镜像,localPosition 始终是正前方。如果项目里已经全面使用了 Scale 翻转,那判定框位置要用代码重新算:attackBox.localPosition = new Vector3(isFacingRight ? 0.8f : -0.8f, 0, 0)。我更推荐第二种,因为只需要改一处,不用把所有 Sprite 的翻转习惯推翻。

6. 进阶:用动画Layer与调试技巧把战纪手感调出来

6.1 跑动攻击的终极方案:动画分层

RunAttack 这个动作在横版街机里有个经典需求:角色边跑边出拳,上半身挥拳,下半身继续迈腿。如果把它放在同一个动画层里,就只能在“跑”和“跑动攻击”之间硬切,动作容易断。Animator Controller 支持多 Layer,把 Attack2、Skill1、Skill2 放到 UpperBody 层,Base Layer 只保留 Idle、Walk、Run、Hurt3、FrontJump。

Layer 面板里给 UpperBody 设一个 Avatar Mask,只留手臂和上半身的骨骼权重;权重参数在代码里用 SetLayerWeight 控制。街机手感推荐 0 和 1 之间不带过渡,直接从 0 切到 1 再切回 0,响应最脆。这一层还能顺手解决技能释放中的受击表现——Hurt3 放在 Base Layer 优先级最高,UpperBody 的动画不会抢。

6.2 不靠猜排错:用normalizedTime精确定位动画帧

调试状态机的时候,不要盯着 Scene 窗口看角色动作猜当前在哪一帧,直接在代码里打印:

private void CheckAnimState() { AnimatorStateInfo info = anim.GetCurrentAnimatorStateInfo(0); // IsName判断当前状态,normalizedTime超过1说明已经开始退出 Debug.Log($"当前动画: {info.IsName("Attack2")}, 进度: {info.normalizedTime:F2}"); }

normalizedTime 是动画播到第几遍的归一化进度,0.35 就代表播放到第三成。我在调伤害帧的时候,都是先用这段代码跑一遍攻击,看事件该挂在哪个百分比,再回动画面板对准。比反复进 Play 模式试错快得多。

做这套东西做多了之后我有一个习惯:每次搭角色动画,都强制在 Animator 窗口里逐条过一遍转换箭头,把 Any State 的滥用改掉,受击权重拉到最顶,伤害事件的时间戳先写进策划表再挂事件,不靠试。状态机这事,乱拉箭头一时爽,调手感火葬场。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询