
简介一套基于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 里看它的关键帧引用了哪张贴图。我一般按这个顺序处理动画资源把序列帧大图导入项目在 Inspector 里把 Texture Type 设为 Sprite (2D and UI)Sprite Mode 设为 Multiple点击 Apply。打开 Sprite Editor按 Grid by Cell Size 切片。切多大取决于动画资源的分辨率比如单帧是 128x128就设 128 到 128。切完片后检查每个 .anim 的 Sprite 引用是否指向切好的帧。如果引用的还是旧贴图把对应帧拖进关键帧位置替换。再把 .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跑动攻击播完回 RunIdle / 移动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 GetComponentAnimator(); } 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 GetComponentRigidbody2D(); anim GetComponentAnimator(); } private void FixedUpdate() { // 攻击和技能释放期间锁死移动这是动作游戏手感的基本盘 if (isAttacking) return; float h GetComponentPlayerInput().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常规移速足够用高速冲刺再开 ContinuousRigidbody2DInterpolateInterpolated防止低帧率下手感发顿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.GetComponentEnemyHealth(); 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 和挂接的子物体坐标而判定框如果设了对称位置比如 x0.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 的滥用改掉受击权重拉到最顶伤害事件的时间戳先写进策划表再挂事件不靠试。状态机这事乱拉箭头一时爽调手感火葬场。希望帮到你。本文还有配套的精品资源点击获取