简介:面向希望用Unity打造2D街机游戏的开发者,这是一份完整度较高的“三国战纪”题材示例工程,覆盖从场景搭建、Sprite精灵、2D物理碰撞到Animator动画状态机、C#游戏逻辑、UI界面、音频管理与性能优化等核心环节,尤其适合已有Unity基础、想系统梳理2D动作游戏实现的初中级学习者。资源共443个文件,包体大小4.5MB,以C#脚本、动画剪辑、精灵贴图、Prefab预制体及场景资源为主,其中90个cs脚本和24个anim动画片段构成了主要的游戏逻辑与动作表现,另含Shader、Material等渲染配置,便于直接打开工程对照学习。目前已有336人学习下载。通过参考其中的角色移动、攻击、跳跃等动画状态切换与技能实现,可以快速理解2D街机游戏的项目结构、脚本协同方式和常见性能优化思路;对照动画控制器与脚本事件调用,还能深入理解动作游戏的角色行为链路,是一份轻量但完整的实战参考。
1. 用 Unity 开发 2D 街机游戏三国战纪:先复刻手感,再谈玩法创新
打开街机模拟器打一局“三国战纪”,你很快会发现它吸引人的不是画面,而是“砍上去那一瞬间的反馈”:攻击命中时画面顿一下、敌人闪白、血量跳字、屏幕轻微震动。把这些反馈搬到 Unity 2D 项目里,就是一套比想象中更依赖工程准备的横版动作游戏(beat 'em up)方案。这篇笔记面向想做第一款 2D 游戏或拿它当毕设的开发者,从项目骨架、角色控制器、动画状态机、打击判定到摄像机与打包,讲清楚每一块怎么落地、参数怎么调、最容易在哪里翻车。素材和角色名不建议直接用“三国战纪”原版资源,下面只讲玩法框架的搭建,美术用自绘或商店素材替换即可。
2. 拆玩法和搭骨架:Unity 2D 横版动作游戏的最小项目配置
2.1 从三国战纪拆出来的五个核心玩法模块
动手前先做减法。你不需要一开始就把连招表、道具养成、Boss 战全做完,第一部作品用“能打通一关”当目标就够了。三国战纪的玩法拆到 Unity 里,核心是五个模块:角色控制、战斗反馈、敌人 AI、道具掉落、关卡卷轴。我把它们和 Unity 的落点对应起来看:
| 玩法模块 | 具体内容 | Unity 里的落点 |
|---|---|---|
| 角色控制 | 左右移动、跳跃、蹲伏、四向攻击、前后滚翻 | PlayerController + Animator 状态机 |
| 战斗反馈 | 攻击命中、受击硬直、伤害浮字、顿帧、屏幕震动 | Trigger 命中回调 + HitFlash/HitStop/Shake 脚本 |
| 敌人与 AI | 巡逻、索敌、攻击前摇、受击硬直、死亡掉落 | EnemyController + 状态枚举 + Damageable 接口 |
| 道具与掉落 | 血瓶、武器、连击加分、队友抢道具 | Item 预制体 + 掉落概率表 + UI 拾取事件 |
| 关卡与演出 | 横向卷轴、出怪波次、Boss 出场演出 | 场景切块 + Trigger 区域 + 摄像机动画 |
模块拆完你会发现,三国战纪本质上是一个状态机套着另一个状态机:角色有移动、攻击、受击、死亡状态,敌人有巡逻和攻击状态,Boss 还有演出状态。代码结构不用复杂框架,常规的 MonoBehaviour 分层就够:PlayerController 只管输入和状态,攻击表现交给 Animator,命中判定交给碰撞体回调。Asset Store 上偶尔能看到 beat 'em up game template 这类 2D 模板,自带整套美术和技能树,但新手拿模板改容易被预制体嵌套绕晕,我建议只参考它的输入处理和摄像机配置,框架还是自己搭一遍更清楚。
2.2 Unity 项目创建与分辨率设置:从下载安装到第一个 2D 场景
安装阶段直接说结论:Unity Hub 安装编辑器时选择 2D 模板,这个模板会把编辑器切到 2D 视图、主相机预设为正交投影,省掉一半的初始设置。Windows 上用管理员身份启动编辑器会弹那个 is running with administrator privileges 的警告,不影响开发,直接忽略就行。真正要花心思的是分辨率和像素单位,这里决定后续所有素材规格。
我一般用 1280x720 做基准分辨率,像素单位 PPU(Pixels Per Unit)设为 32。PPU 的意思是“多少像素对应 Unity 世界坐标里的 1 个单位”,32 意味着 32x32 的素材贴到场景里恰好占一格。如果做的是高清手绘 2D,PPU 用 100 更方便;但三国战纪这种像素风,32 或 16 是常见选择。PPU 定下来后,正交相机的 Size 就是定死的:Size = 屏幕高度 / 2 / PPU。1280x720、PPU=32 时,Size = 720/2/32 = 11.25。
| 分辨率 | PPU | Orthographic Size |
|---|---|---|
| 1280x720 | 32 | 11.25 |
| 1920x1080 | 32 | 16.875 |
| 1280x720 | 16 | 22.5 |
| 1920x1080 | 100 | 5.4 |
设定路径是 Project Settings -> Player -> Resolution and Presentation,写死 Default Screen Width 和 Height;主相机 Projection 选 Orthographic,把算好的 Size 填进去。素材导入时,Sprite 的 Texture Type 设为 Sprite (2D and UI),Filter Mode 选 Point(像素风要的是锯齿感,不是模糊的边缘),Compression 先改成 None,等打包阶段再压缩。这些参数今天不统一,后面每一张图都要返工。
2.3 物理与碰撞分层:Rigidbody2D 的三种 Body Type 怎么选
2D 横版动作游戏物理上要做的判断不多,但选错 Body Type 会让手感变得非常奇怪。角色用 Dynamic,Gravity Scale 保持 1,Freeze Rotation Z 必须锁定,否则站在斜坡上角色会自己旋转;地面和平台用 Static,不挂任何刚体组件;敌人和箱子用 Kinematic 还是 Dynamic 取决于要不要被玩家撞动——三国战纪里箱子要能被砍飞,用 Dynamic 加较低质量;普通小兵我不希望被玩家推着走,用 Kinematic 自己控制移动。攻击判定碰撞体全部用 Trigger 模式,不参与物理碰撞,只负责回调。
碰撞分层是新手最容易漏的一步。把角色、敌人、地形、道具、攻击框分到不同 Layer,然后在 Edit -> Project Settings -> Physics2D -> Collision Matrix 里设置谁跟谁碰撞。常见做法是:角色与敌人本体保持碰撞(形成推挤感),攻击框只检测敌人层,道具层只检测角色层。Layer 分不对,最常见的现象就是攻击框明明碰到敌人却打不出伤害。物理材质方面,给角色 Rigidbody2D 挂一个 Friction 为 0 的 Physics Material 2D,否则横向移动时可能会因为地面摩擦出现微小的顿挫感,这种问题在像素游戏里非常隐蔽。
脚本目录提前规划好,后面不会乱。我一般按这个结构放:
Assets/ Scripts/ Player/ PlayerController.cs PlayerAttack.cs Enemy/ EnemyController.cs Combat/ Damageable.cs HitFlash.cs HitStop.cs CameraShake.cs Camera/ CameraFollow.cs Art/ Scenes/ Prefabs/Combat 目录里的脚本是跨越角色和敌人复用的统一战斗接口,后面第三章和第四章写的代码都会挂在这套结构下。目录本身用中文还是英文看团队习惯,个人项目我坚持英文路径,因为部分打包工具对中文路径支持不够稳定。
3. 角色控制器与动画状态机:跑动、跳跃和四向攻击的代码实现
3.1 输入缓冲与移动:用 Input.GetAxisRaw 配合 Rigidbody2D 而不是改 transform
2D 街机游戏里移动手感的第一原则是“响应要快”。Input.GetAxisRaw 返回 -1、0、1 三个值,没有平滑过渡,按下就是满速,松开立刻停;如果换成 Input.GetAxis,移动会有加速度过渡,角色会像踩了冰面一样滑出去。三国战纪的移动是干净利落的,所以用 GetAxisRaw。
using UnityEngine; public class PlayerController : MonoBehaviour { [Header("移动参数")] [SerializeField] private float moveSpeed = 5f; [SerializeField] private float jumpForce = 11f; [SerializeField] private float groundCheckRadius = 0.12f; [SerializeField] private LayerMask groundLayer; [Header("组件引用")] [SerializeField] private Rigidbody2D rb; [SerializeField] private Animator animator; [SerializeField] private Transform groundCheckPoint; private bool isGrounded; private bool isAttacking; private float facingDirection = 1f; private void Update() { // 输入在 Update 里读,物理在 FixedUpdate 里应用 float h = Input.GetAxisRaw("Horizontal"); if (!isAttacking) { animator.SetFloat("Speed", Mathf.Abs(h)); if (h != 0f) { facingDirection = Mathf.Sign(h); transform.localScale = new Vector3(facingDirection, 1f, 1f); } } if (Input.GetButtonDown("Jump") && isGrounded) { animator.SetTrigger("Jump"); rb.velocity = new Vector2(rb.velocity.x, jumpForce); } if (Input.GetButtonDown("Fire1") && !isAttacking) { // StartAttack 的具体逻辑由 PlayerAttack 脚本接管,这里只做输入节流 StartAttack(); } } private void FixedUpdate() { float h = Input.GetAxisRaw("Horizontal"); if (!isAttacking) { rb.velocity = new Vector2(h * moveSpeed, rb.velocity.y); } isGrounded = Physics2D.OverlapCircle( groundCheckPoint.position, groundCheckRadius, groundLayer); animator.SetBool("IsGrounded", isGrounded); } private void StartAttack() { animator.SetTrigger("Attack"); } }几个参数值得细说。moveSpeed 在 5 左右比较合适,配合 PPU=32 的像素单位,大概是每秒走过 5 个格子,街机角色跑动速度更快,可以到 7,但攻击判定不变的情况下跑太快会让玩家觉得“飘”。jumpForce 是试出来的,11 到 12 之间对应大约 3 格跳跃高度,三国战纪的重力感偏沉,不建议用太小的重力加速度做出轻飘飘的跳跃。groundCheckRadius 是 0.12,比角色底部碰撞体略大;跳跃判定我倾向于用 OverlapCircle 而不是 OnCollisionStay,因为 OnCollisionStay 在下落速度很快时偶尔会漏判定,让玩家明明站在地面却跳不起来,这是横版游戏里经典的血泪教训。
Input 映射在 Edit -> Project Settings -> Input Manager 里改:Jump 对应空格,Fire1 对应鼠标左键或键盘 J 键。街机适配手柄的话,Fire1 映射到手柄 A 键,Jump 习惯上放在 B 键,这样玩家能用拇指同时完成跳砍操作。攻击期间锁住移动速度,这是硬派动作游戏跟 RPG 最不一样的地方:攻击动作是“站桩”的,角色不会一边挥刀一边滑行,去掉这个锁,后续连段节奏会变得很难控制。
3.2 Animator 参数与动画过渡:把动作切换交给状态机而不是散乱的 if
角色动画的状态切换如果全写在代码里,后期每加一个动作就要改一遍 Update 方法;Animator 的职责就是把这些切换关系用可视化的过渡图管理起来。调 Animator 窗口时,我会先列一张参数清单,避免动画师跟程序各自维护一套命名:
| 参数名 | 类型 | 作用 |
|---|---|---|
| Speed | Float | 控制 Idle 和 Run 的混合 |
| IsGrounded | Bool | 区分地面和空中状态 |
| IsAttacking | Bool | 进入攻击状态的开关 |
| AttackIndex | Int | 选择四向攻击对应的动画剪辑 |
| IsHit | Bool | 受击硬直状态的开关 |
| IsDead | Bool | 死亡状态 |
状态机里最关键的过渡是:Attack 从 Idle 和 Run 进入,但过渡条件里 Has Exit Time 必须取消勾选——大部分教程默认勾选,在动作游戏里这是导致出招延迟的元凶。Has Exit Time 的意思是“当前动画播完才能切到下一个状态”,街机游戏的逻辑是“按下攻击键立刻出招”,所以攻击相关的过渡都要关掉它,并且 Transition Duration 设为 0。攻击动画播完后怎么回到 Idle?在攻击动画末尾添加一个 Animation Event,事件函数里调用 ResetAttack 方法切换回待机。
public void ResetAttack() { animator.SetBool("IsAttacking", false); animator.SetInteger("AttackIndex", 0); }注意 SetTrigger 和 SetBool 的配合。如果只用 Trigger 控制攻击,连段时第二次点击触发时,上一次 Trigger 状态还没被消耗,触发会被吞掉。我习惯用 bool + 事件重置:攻击开始 SetBool("IsAttacking", true),动画事件里 ResetAttack 把 bool 关掉。这样无论玩家点什么,事件驱动的顺序都不会乱。动画剪辑本身建议用 Sprite 序列帧,Unity 的 Sprite Editor 切成 Multiple 模式后,一次性拖入所有帧自动生成动画片段。每个攻击方向单独建一个 clip,AttackIndex 参数用来在状态机里选择播哪一个。
3.3 攻击判定与连段:用子物体碰撞体模拟四向攻击
四向攻击是三国战纪的招牌操作:站桩按攻击是正前方,按住上再按是挑空,按住下再按是下劈。Unity 里实现四向攻击不需要复杂的射线检测,直接在角色身上挂四个子物体碰撞框:ForwardAttackBox、UpAttackBox、DownAttackBox,默认全部禁用,攻击动画播放到特定帧时,通过 Animation Event 打开对应的碰撞框,动画结束再关闭。
public class PlayerAttack : MonoBehaviour { [SerializeField] private Collider2D forwardAttackBox; [SerializeField] private Collider2D upAttackBox; [SerializeField] private Collider2D downAttackBox; [SerializeField] private int attackDamage = 1; private HashSet<GameObject> alreadyHit = new HashSet<GameObject>(); public void OnAttackFrame(string attackKind) { Collider2D box = forwardAttackBox; if (attackKind == "Up") { box = upAttackBox; } else if (attackKind == "Down") { box = downAttackBox; } box.enabled = true; alreadyHit.Clear(); } public void OnAttackEnd() { forwardAttackBox.enabled = false; upAttackBox.enabled = false; downAttackBox.enabled = false; } private void OnTriggerEnter2D(Collider2D other) { // 防止拳皇式乱打时同一个敌人被判两次 if (alreadyHit.Contains(other.gameObject)) { return; } if (other.TryGetComponent(out Damageable damageable)) { alreadyHit.Add(other.gameObject); damageable.TakeDamage(attackDamage); } } }alreadyHit 这个 HashSet 很关键。一个攻击碰撞体在开启期间会连续产生 OnTriggerEnter2D 回调,不记已经打过的目标,一刀下去敌人会受三次伤害。每次进入攻击帧时清空列表,保证一个攻击帧只结算一次。连段的实现思路是在攻击脚本里增加 comboCount 和 comboWindow 两个字段:每次攻击 comboCount 加一,超过三下归零;玩家在 comboWindow 时间窗内再次按攻击键才能继续连段,窗口一到自动归零。第一段用正前方攻击,第三段可以切换成上挑框,实现“打浮空再接一次跳跃攻击”的经典连段。
动画事件与攻击帧对齐这件事,我习惯把攻击动画丢进 Timeline 或者直接用帧预览逐帧看。攻击框通常在动画的第 3 到第 5 帧开启,因为角色出招的动作要配合打击判定一起出现,判定来得太晚,玩家看着刀砍到敌人但敌人毫无反应,手感会非常“空”。这部分没有捷径,只能反复调帧数。还有一点,四向攻击时角色如果翻转了 localScale.x,攻击框的位置会跟着翻转,挂箱子时注意让攻击框放在角色朝向一侧即可。
4. 打击感三件套:硬直、闪白与顿帧的参数调优
4.1 受击硬直:用状态锁和 Damageable 接口统一敌人与 Boss 的受击表现
打击感的第一步是让被打的敌人有反应。所有可被攻击的物体,不管是小兵、Boss 还是可破坏的箱子,统一实现同一个 Damageable 接口。受击后进入 IsHit 状态,播一小段向后仰的动画,同时锁定敌人自己的 AI 逻辑,这段时间叫做硬直。硬直时间决定了连段能接多快,太短感受不到打击,太长游戏节奏又拖沓。
using UnityEngine; using System.Collections; public class Damageable : MonoBehaviour { [SerializeField] private int startHealth = 3; [SerializeField] private Animator animator; [SerializeField] private SpriteRenderer spriteRenderer; private int currentHealth; private void Awake() { currentHealth = startHealth; } public void TakeDamage(int amount) { currentHealth -= amount; // 受击硬直:状态机里 IsHit 为 true 时切到 Hit 动作 animator.SetBool("IsHit", true); StartCoroutine(GetComponent<HitFlash>().Flash(0.1f, 0.2f)); if (currentHealth <= 0) { animator.SetTrigger("Death"); } } public void ResetHit() { // 在 Hit 动画结束帧调用,解除硬直锁 animator.SetBool("IsHit", false); } }受击优先级要用状态锁保证。三国战纪里敌人被打时会出现“顿住”的效果,但如果攻击动作同时发生,玩家的攻击动画不能被打断,否则玩家会觉得自己操作失灵。做法是 Animator 里 Any State 通过 IsHit 切换到 Hit 状态,Hit 动画完成后 ResetHit 释放状态。Boss 和普通敌人的区别只在硬直时长:普通小兵硬直 0.35 秒,Boss 通常要做霸体处理,只有特定击破招式才能打断当前动作,否则玩家一套连招就能把 Boss 钉死在墙角。
4.2 闪白与顿帧:用协程与 Time.timeScale 调出“砸到肉”的反馈
受击闪白是 2D 像素游戏里最经济的效果,不用换材质,直接改 SpriteRenderer 的 color。把角色瞬间刷成白色再恢复原色,视觉上像被光闪了一下。三国战纪原版的受击反馈偏重,闪白时间和恢复过程要控制得短促,太久会像幽灵。
using UnityEngine; using System.Collections; public class HitFlash : MonoBehaviour { private SpriteRenderer sr; private Color originColor; private void Awake() { sr = GetComponent<SpriteRenderer>(); originColor = sr.color; } public IEnumerator Flash(float duration, float restoreTime) { sr.color = Color.white; yield return new WaitForSeconds(duration); // 逐渐恢复原色,这也是脚本控制逐渐消失的常见写法 float t = 0f; while (t < restoreTime) { t += Time.deltaTime; sr.color = Color.Lerp(Color.white, originColor, t / restoreTime); yield return null; } sr.color = originColor; } }闪白只适合整张角色图片统一处理。如果角色用了多部位拼接或者想要“只有刀光闪白”,需要单独给对应部位挂脚本,或者写成通用组件挂接收到闪白信号的具体对象上。restoreTime 别传 0,否则 Lerp 除零,调用时给 0.2 这种安全值。另外闪白期间不能同时播放多个协程,协程会互相覆盖,我的做法是在 Flash 开头先 StopAllCoroutines,保证每次闪白都是完整流程。
顿帧是打击感真正的灵魂。所谓顿帧,就是命中瞬间让整个游戏画面暂停几十毫秒,造成一种“这一刀狠狠砸进去了”的错觉。但这里有个大坑:直接用 Time.timeScale = 0 会让 WaitForSeconds 永远等不完,而且很多点击音效、摄像机动画也一起停了。我惯用的是 Time.timeScale = 0.02,接近暂停但不是完全归零,配合 WaitForSecondsRealtime 保证恢复逻辑走完。
using UnityEngine; using System.Collections; public static class HitStop { public static IEnumerator Stop(float duration) { // 不要用 0,接近静止即可,避免 UI 和音效逻辑被卡死 Time.timeScale = 0.02f; yield return new WaitForSecondsRealtime(duration); Time.timeScale = 1f; } }屏幕震动也放在这一节,因为街上最直接的“重击感”来自镜头晃动。用小幅度随机偏移模拟手震,注意偏移幅度不能超过 0.3,像素游戏里镜头一晃,角色和背景的像素对齐错一格,视觉上会比预想脏很多。震动协程里的时间用 Time.unscaledDeltaTime 累积,这样顿帧期间震动仍然生效,否则顿帧一触发震动就被冻结,重击感大打折扣。
4.3 参数调优:用手感和帧数把“玄学”变成可量化的数值
打击感调优最怕全靠感觉。我一般把参数集中到一个调试面板脚本或 Inspector 的 [Header] 分组里,先定个基准值再逐项试。
| 参数 | 推荐初始值 | 调试区间 | 说明 |
|---|---|---|---|
| 顿帧时长 | 0.06s | 0.03 - 0.09s | 超过 0.1s 会明显卡顿,街机手感偏 0.04-0.06 |
| 闪白时长 | 0.08s | 0.05 - 0.12s | 一闪而过,停留久了像“变色” |
| 屏幕震动幅度 | 0.15 | 0.1 - 0.25 | 像素风建议不超过 0.2 |
| 攻击前摇 | 0.15s | 0.1 - 0.2s | 反映角色起手速度,越快越爽 |
| 攻击后摇 | 0.25s | 0.15 - 0.3s | 后摇太长连段衔接卡手 |
| 敌人硬直 | 0.35s | 0.25 - 0.5s | 低于 0.2 玩家感觉不到命中 |
判断顿帧和震动的效果,我不在编辑器里看,因为 Edit Mode 下帧率不同,感受没参考价值。正确姿势是 Build 一个最低画质的 PC 包,锁 60 帧,把 Time.timeScale 恢复逻辑里加一行 Debug.Log 记录帧号,回看命中帧和画面顿帧的对应关系。手感调试最终要落到“这一刀下去,玩家是否能感知到伤害发生了”,跟前摇和后摇的纸面数值比,顿帧和震动的权重往往更大。
5. 横版卷轴游戏避坑:摄像机、碰撞体与动画播出的 5 个排查现场
5.1 摄像机跟随与战斗锁定:Cinemachine 或手写 LateUpdate 平滑
2D 街机横版最影响战斗体验的是摄像机跟随,它有两个常见反面教材:镜头把角色死死钉在屏幕中央,敌人在屏幕外偷袭玩家完全看不见;或者镜头跟得太紧,小兵一移动镜头就甩,角色出招时画面画外忽然跳出来另一个敌人。街机游戏的镜头通常要给玩家的行进方向留出“前视区域”,角色偏向画面左侧时,右侧要多露出一段空间,让玩家有反应时间。
用 Cinemachine 2D 做最简单:给主相机挂 Cinemachine Brain,创建 Virtual Camera,Body 选 Framing Transposer,把 Look Ahead 参数设在 0.5 到 1 之间。Look Ahead 会让镜头根据角色的速度方向提前移动一小段,手感刚好是街机那种“镜头追着人走,但又不会被拖住”的感觉。如果不想引入插件,手写平滑跟随也很简单:
using UnityEngine; public class CameraFollow : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private float lookAheadX = 2f; [SerializeField] private float smoothTime = 0.3f; private Vector3 velocity = Vector3.zero; private void LateUpdate() { // 用 localScale.x 判断面朝方向,简单但不适合翻滚动作 float dir = target.localScale.x >= 0f ? 1f : -1f; Vector3 targetPos = target.position + new Vector3(dir * lookAheadX, 0.5f, -10f); transform.position = Vector3.SmoothDamp( transform.position, targetPos, ref velocity, smoothTime); } }目标坐标的 z 轴固定为 -10,因为 2D 正交相机视野中心在 z=0,相机要退后到 -10 才不会挡住物体渲染。lookAheadX 是前视偏移量,2 到 3 比较舒服,角色在屏幕三分之一处而不是正中间;smoothTime 是 0.25 到 0.35,太大会让镜头像拖着一根绳子,太小又会晕。用 localScale.x 判方向的方案在蹲下和跳跃时没问题,但角色做背后翻滚这种翻转动作时会误判,如果想做得更精细,用 rb.velocity.x 的方向作为判断依据。
摄像机还有另一个常见诉求是“让 UI 不随镜头放大缩小”。2D 游戏里 UI 用 Screen Space - Overlay 的 Canvas,并挂 Canvas Scaler 设置参考分辨率 1280x720,这样 UI 会按屏幕比例缩放,和镜头正交尺寸完全无关,从 iPhone 到 Android 平板都不会被镜头拉伸。像素风格角色想要清晰的锯齿边缘,主相机上再挂 Pixel Perfect Camera 组件,让渲染分辨率按整数倍缩放,避免素材出现半个像素的模糊边。
5.2 排查现场:动画中断、攻击失效与紫红色材质的 5 个高频坑
坑一:攻击动画打一半闪回 Idle。现象是角色刚挥刀到一半,动画突然跳回待机,连段永远连不上。原因是 Animator 过渡设置里 Has Exit Time 被勾选,攻击状态还没播完就切回 Idle,或者 StartAttack 在 Update 里被重复触发,把攻击状态刷掉了。解决方法是攻击相关过渡全部取消 Has Exit Time,Transition Duration 设为 0,并用上一章的 ResetAttack 事件方式控制退出,而不是在逻辑里反复 SetBool。
坑二:攻击框明明碰到敌人但 OnTriggerEnter2D 没触发。现象是攻击动画正常播放,伤害却为零。原因大概率出在碰撞矩阵:两个物体的 Layer 在 Collision Matrix 里没开碰撞;或者攻击框子物体本身没有 Rigidbody2D,而父级角色因为某些操作把 Rigidbody2D 关了;再或者碰撞体没勾 Is Trigger。解决方法是先在 OnTriggerEnter2D 第一行加 Debug.Log(other.name),确认事件有没有真的进来,再逐层检查碰撞矩阵和 Is Trigger,这个排查顺序几乎能定位所有回调不触发的问题。
坑三:素材导入场景后显示紫红色。现象是一张张正常的 Sprite 拖进场景变成紫红色块,放回其他工程又正常。原因是项目的 Graphics Settings 把 Sprite/Default Shader 裁剪掉了,常见于 URP 项目迁移或者打包时 Shader 没有被引用。解决方法是 Edit -> Project Settings -> Graphics,在 Always Included Shaders 里加入 Sprite/Default;如果用的 URP,把材质改为 URP/Sprite-Unlit-Default。还有可能是图集被打包后引用丢失,重新生成 SpriteAtlas 也能修好。
坑四:像素角色跑动时边缘发虚、整体抖动。现象是静止清晰,一动起来整个角色像糊了一层,左右横跳时最明显。原因是摄像机的正交尺寸和画面高度不是整数倍,像素画面无法对齐到物理像素网格;或者素材 Filter Mode 用了 Bilinear 导致边缘被插值。解决方法是 Filter Mode 全部切 Point,主相机加 Pixel Perfect Camera 组件,并把移动逻辑里可能出现小数坐标的位置做平移校准。这条是最典型的“看着瘆人但修起来很快”的问题,我做过一次项目,角色美术画了一个月,最后发现是它。
坑五:顿帧之后 UI 按钮全部失灵。现象是战斗中顿帧正常,打完一波敌人点存档或菜单按钮没反应。原因是顿帧用了 Time.timeScale = 0,且协程里用了 WaitForSeconds,导致 timeScale 永远没有被恢复;Unity 的 Unscaled Time 和 EventSystem 在这种状态下没有同步,UI 动画全被锁住了。解决方法是顿帧用 timeScale = 0.02 配合 WaitForSecondsRealtime,并在打开菜单、切换场景前强制把 Time.timeScale 重置为 1。这个坑的特点是,第一次出现时会让人怀疑是 UI 组件的问题,会查很久,实际上只是顿帧实现方式埋下的雷。
5.3 排查顺序与应急手段
连招跨度大的 2D 动作游戏最容易出现“动画、判定、反馈不同步”的组合问题,我的排查顺序是固定的:先开 Animator 窗口暂停游戏,看当前处在哪个状态;再打开 Physics2D 的 Collider 可视化,看攻击框是否真的覆盖到敌人;最后打一行 Debug.Log 验证 Damageable 有没有收到回调。三步走完,八成问题都能定位到具体环节。如果暂时查不出来,我还会在击中瞬间加一个 ScreenShake 或者音效作为旁证——画面反馈已经出现但血量没变化,那问题一定出在 Damageable 之前;画面完全没有反馈,那问题出在碰撞或动画事件里。
6. 验证手感和包体瘦身:从 Editor 到真机的最后路程
6.1 真机手感验证清单
编辑器里跑通不等于真机可行,尤其动作游戏对帧率敏感。Build 到 Android 或 iOS 后,我按这份清单逐项过:一测输入延迟,手指按下到角色出招不超过两帧,延迟明显就先检查真机是否开了省电模式;二测帧率,用 Profiler 看有无单帧超过 16ms,像素游戏最怕粒子特效和动画蒙皮卡顿;三测顿帧,命中瞬间 UI 血量正常跳动,画面短暂停顿但不出现整个 UI 冻结;四测连段窗口,在训练场景里打印 comboCount 重置的时间戳,确认搓招节奏和意图一致;五测低端机,用一台两年前的手机跑最低画质,能稳定 30 帧以上再考虑发布。这套清单照做一遍,手感问题基本不会流到商店版本里。
6.2 包体优化与发布参数:像素风也可以把 AAB 做到很小
包体优化对于像素 2D 游戏主要是控制图集和 Shader。先建 SpriteAtlas 把角色动画、UI 素材分别打图集,避免每张 Sprite 一个 DrawCall;图片压缩 Android 用 ETC2 或 ASTC,iOS 用 ASTC 6x6,像素风素材压缩比高,但不建议过度压缩到出现色块。Project Settings 里关闭 Player 设置中用不到的 3D 物理系统,打开 IL2CPP 加 Strip Engine Code,配合代码裁剪能把可执行文件体积压掉很多。发布 Android 上商店时直接出 AAB 包,Play Console 会自动分发适配资源;如果做微信小游戏,需要走官方适配插件,并注意节点数量控制在千级以内。
包体优化再补充一个细节:主场景里如果挂了不需要的 Post Processing 或全局光照,直接移除,2D 像素游戏往往一张调好饱和度的图集比后处理更耐用。上架前还可以开代码混淆保护热更逻辑,Unity 的宏定义配合平台分发也能省一笔冗余代码,例如用 #if UNITY_ANDROID 和 UNITY_IOS 区分手柄输入和触屏输入。Assets Store 里那些带完整美术的横版模板,我一般只作参考,模板本身的图集和脚本很大,换皮成本高,不如自己这套干净。
做这个方向最后悔的一件小事,是第一周没有坚持给相机挂 Pixel Perfect,后期换完场景才发现角色素材大量重切。现在我每个 2D 项目开箱第一件事就是建“手感验证场景”,只放一个方块敌人、一个攻击动作和一套顿帧参数,先把节奏调对了再堆内容。这套方法帮我后面少返工很多,希望帮到你。
本文还有配套的精品资源,点击获取