
简介聚焦 Unity 2D 街机游戏开发以经典题材“三国战纪”为案例面向 Unity 初学者、独立游戏开发者以及对横版动作玩法感兴趣的读者。内容覆盖场景构建、精灵渲染、动画状态机、2D 碰撞与刚体、输入监听、C# 游戏逻辑、音频与 UI 集成、多脚本协同、性能优化及多平台发布等核心环节适合作为从零入门到完整复刻的参考工程。压缩包内共有 443 个文件整体仅 4.5MB包含 90 个 C# 脚本用于角色移动、攻击判定、技能释放与敌人 AI30 张 PNG 素材涵盖角色、背景与界面24 个动画剪辑覆盖待机、奔跑、攻击、技能等动作22 个资源配置与预制体、动画控制器另有着色器、材质、动态库等辅助资源共同构成完整项目结构。目前已有 337 人学习下载。通过这套工程可看清从精灵素材到动画状态切换、从输入响应到技能判定、从物理碰撞到界面反馈的完整实现路径也能直接参考现成脚本与目录组织方式快速搭出自己的横版街机游戏原型适合作为课程设计或入门练手的素材库。1. 用 Unity 复刻《三国战纪》这门手艺最难的不是美术是让玩家觉得打起来带感很多人第一次接触Unity开发2d街机游戏三国战纪这个选题第一反应是担心美术资源从哪来、关羽张飞怎么画。但作为做过清版动作游戏的人我会先泼一盆冷水美术是可以买的、代码是可以抄的唯独打击手感是个黑匣子——同样的攻击动画、同样的碰撞体调参不同玩家会觉得你在打棉花还是刀刀到肉。《三国战纪》这类beat em up游戏清版过关的核心体验就三件事2D横版卷轴场景、多人同屏乱战、连招与道具带来的爽感。这篇笔记就从这三个点往下拆从空工程一路讲到帧数据驱动。这篇文章适合两类人。一类是会用 Unity 基本操作、想从会移动的小方块跳到能玩的完整关卡的开发者另一类是已经做过一些 Demo但发现手感总是不对、不知道怎么量化打击感的熟手。我会把坐标系、动画状态机、判定框、顿帧、摄像机跟随、对象池这些系统串成一个可复现的最小框架并把排查过的翻车现场放在最后一章里。这里不会出现什么商业机密全部是 Unity 官方路径上最常见的工程做法。2. 搭建 2D 横版动作游戏的场景与角色骨架从空工程到角色可以跑起来2.1 为什么用正交摄像机而不是透视像素比例与卷轴概念要做 2D 街机第一步先端正一个观念Unity 里做 2D 横版过关摄像机一定用正交投影Orthographic。透视摄像机在 3D 场景里表现纵深没问题但在横版过关里会让角色在不同 x 位置时视觉大小发生变化这对判定框必须和视觉一致的清版游戏来说就是灾难。新建工程时直接选 2D 模板或者在建好的 3D 工程里把 Camera 组件的 Projection 切到 Orthographic并把 Size 设为 5 左右。这里的 Size 含义是屏幕半高对应的世界单位数举例Size 5意味着屏幕在竖直方向能看到 10 个单位的距离而水平方向能看到多少取决于屏幕宽高比。街机比例一般是 3:4竖屏或 4:3横屏我建议先在 Game 视图把 Aspect 设为 4:3 来做开发基准避免后面在 16:9 和 4:3 之间反复横跳。接着设置游戏画面的像素比例。很多 2D 素材把单位和像素混在一起这里给一个最稳的做法素材导入时把 Pixels Per Unit 设为 16——也就是说 16 像素的 Sprite 占据 1 个世界单位。这个数值不必纠结是不是标准关键是全工程统一。我见过一个团队一半素材 16 一半素材 32最后角色碰撞体全部错位审查时排查了三天才抓到。这种玄学问题是最浪费时间的。// 挂在摄像机上的一个最小脚本用于约束摄像机活动范围 using UnityEngine; public class StageCamera : MonoBehaviour { public Transform target; // 跟随对象玩家 public Vector2 minBound; // 关卡左/下边界 public Vector2 maxBound; // 关卡右/上边界 void LateUpdate() { if (target null) return; float x Mathf.Clamp(target.position.x, minBound.x, maxBound.x); float y Mathf.Clamp(target.position.y, minBound.y, maxBound.y); transform.position new Vector3(x, y, -10f); } }逻辑说明LateUpdate里做跟随是因为如果角色在Update里移动、摄像机在LateUpdate里跟随这个帧内次序就不会产生摄像机先走、角色后走的抖动。Mathf.Clamp把摄像机坐标限制在关卡边界内——这是横版卷轴最关键的一个动作。参数说明z -10f是为了让摄像机处于所有 2D 物件后面相机渲染深度默认从近到远z 越小越远离场景。minBound和maxBound按关卡设计填例如一个宽 100 单位的关卡填(0, -5)和(100, 5)。注意你填的是摄像机自身位置的边界这和屏幕边缘能看到多大范围是两回事需要按Size再换算。另一个常见误用是直接跟随玩家位置导致镜头撞到关卡边缘时角色跑到屏幕中央偏外的位置通常还要加一个半屏偏移量逻辑这里先给最小版本。2.2 角色动画的两条路线帧动画逐帧播放还是 2D 骨骼绑定《三国战纪》这个品类的关键动作站立、走路、攻击、受击、跳跃、必杀在老式街机里全是帧动画——说白了就是序列帧图片一张张播放。在 Unity 里做 2D 帧动画有两条路线一是把美术导出的长条形 PNG 用 Sprite Editor 按格切开然后用 Animator 配合 Animation Clip 播放二是用 2D 骨骼Unity 的 2D Animation 包给一张立绘绑定骨骼再摆动作。我的建议很明确如果你是单人开发或者小团队做清版过关游戏用帧动画不要盲目上骨骼。原因有二。第一骨骼绑定适合轻量、动作数量少、角色多但共用骨架的项目横版动作游戏一个角色至少有 8 个动作方向、每个动作 6-12 帧骨骼方案做好一套要花的时间远超逐帧动画。第二骨骼动画的碰撞体跟随需要额外标定在攻击判定帧的精确性上远不如序列帧直观。这不是说骨骼不能用市面上用 Unity 2D 骨骼做的动作游戏多了去了但它们的美术团队通常有成建制的技术美术资源。帧动画的落地方式是把动作序列做成 Sprite 数组用一个简单的脚本驱动。// 一个极简的帧动画播放器适合不需要 Animator 复杂状态机的小型项目 using UnityEngine; public class SimpleFrameAnimator : MonoBehaviour { public Sprite[] frames; // 按播放顺序拖入的序列帧 public float fps 12f; // 每秒钟播放帧数 public bool loop true; private SpriteRenderer sr; private float timer; void Awake() { sr GetComponentSpriteRenderer(); if (frames null || frames.Length 0) enabled false; } void Update() { timer Time.deltaTime; int index Mathf.FloorToInt(timer * fps); if (loop) { index index % frames.Length; } else if (index frames.Length) { sr.sprite frames[frames.Length - 1]; enabled false; return; } sr.sprite frames[index]; } }逻辑说明把一组攻击动作的帧拖进frames数组设置fps这个脚本在 Update 里按时间索引当前帧。loop false的用途是攻击动作播放完就停在最后一帧方便接后续状态切换。参数说明fps是手感的核心参数。街机清版游戏出招感觉利落攻击动作播放速度通常不低于 10fps如果一套攻击动作有 8 帧、12fps 播放那整个攻击动作耗时约 0.66 秒——太慢了。具体数值我一般在 12-18fps 之间根据动作幅度调整。这里必须提醒一个新手常踩的坑帧动画的 fps 不要全局用一个固定值攻击、受击、行走各有各的最佳范围。受击帧要短8-10fps让被打反馈迅速出招前摇稍微长一点10-12fps给对手反应空间。一定要给角色挂上 Sorting Layer 并做分层。场景里地面在最底层Background角色在中层Character特效和 UI 在最上层Effect/UI。不这样设置的话粒子特效可能会被关卡里的门框挡住看不见看着像出了 bug其实只是排序没配好。3. 打击感工程判定框、顿帧、击退与连招输入状态机3.1 命中判定的正确做法用触发碰撞体而不是物理碰撞在 2D 横版游戏里做攻击判定最直接的想法是给拳头加一个 Rigidbody2D 和 Collider2D让它去撞敌人。但这个思路在动作游戏里是错的——物理引擎的碰撞是为了模拟真实力学它会产生反弹、旋转、穿透而动作游戏需要的是确定性。我一般用的是独立判定框方案在角色身上挂一个空的子物体命名attackBox上面放 BoxCollider2D勾选 IsTrigger。攻击动画播到指定帧时手动启用这个攻击框攻击结束或动画播完时禁用。攻击框不去碰物理计算而是在发生触发的回调里自己决定命中逻辑。using UnityEngine; public class AttackBox : MonoBehaviour { public float damage 10f; // 伤害值 public Vector2 knockbackForce new Vector2(3f, 2f); // 击退方向和力度 public float activeTime 0.1f; // 判定框存续时长 private float timer; void OnEnable() { timer activeTime; } void Update() { timer - Time.deltaTime; if (timer 0f) { gameObject.SetActive(false); } } void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag(Enemy)) { // 通知敌人扣血和击退具体逻辑写在敌人自己的脚本里 other.GetComponentEnemyHealth().TakeHit(damage, transform.right * knockbackForce); } } }逻辑说明OnTriggerEnter2D是 Unity 触发器回调条件是攻击框和敌人身体上至少有一个带 Rigidbody2D且两者都有 Collider2D。这里故意不写物理碰撞处理因为触发器只负责通知不产生物理力。takeHit方法把伤害和击退方向传给敌人脚本由敌人自行处理受击动画、扣血血条和位移。参数说明activeTime决定判定框保持多少帧。这是个调手感的重要参数太快会漏判视觉上打中了但没伤害太慢会出现隔着空气吸附的玄学问题。我在项目里一般 0.08-0.15 秒之间调整具体取决于攻击动画的帧数和 fps。有一个细节必须提前交代在Update里停用一个物体是安全的但要注意它身上挂的协程可能被中断。如果你的攻击框里有出名伤害的协程不要用 SetActive 来停止它否则协程会被强制结束。更稳的做法是让攻击框常驻用一个 bool 控制是否参与命中而不是频繁 SetActive。3.2 顿帧与击退只用 Time.timeScale 会连带 UI 一起卡住打击感里最核心的一个技巧是顿帧Hit Stop命中瞬间让时间流速极慢或暂停几十毫秒让玩家看见这一拳打中了然后恢复节奏。原理很简单但第一次做的人十有八九会在Time.timeScale上翻车。// 顿帧工具类挂在游戏管理器上 using System.Collections; using UnityEngine; public class HitStopManager : MonoBehaviour { public static HitStopManager Instance; void Awake() { Instance this; } public void Stop(float duration, float timescale 0f) { StartCoroutine(DoStop(duration, timescale)); } IEnumerator DoStop(float duration, float timescale) { Time.timeScale timescale; yield return new WaitForSecondsRealtime(duration); Time.timeScale 1f; } }逻辑说明Time.timeScale 0会让所有依赖Time.deltaTime的 Update 逻辑暂停包括刚体模拟、动画播放、粒子系统。但WaitForSecondsRealtime用的是真实时间所以协程能在停驻期间苏醒并恢复时间流速——这是一个后悔药的小技巧。但这里有一个熟手常踩的坑Time.timeScale是全局的如果你用它实现顿帧UI 的按钮动画、掉落物飘动、摄像机缓动会全部卡住。我给射击游戏做过一次这个逻辑测试后才发现玩家死亡时的挣扎动画也停住了最后改用自定义时间缩放字段// 每个需要独立局部时间的物体挂这个接口 public interface ILocalTimed { float LocalDeltaTime { set; } void OnTimeScaleChanged(float newScale); }此时对每个受顿帧影响的逻辑使用LocalDeltaTime代替Time.deltaTime通过一个管理类向所有实现ILocalTimed的对象广播时间缩放值。这取消了全局时间缩放带来的误伤代价是代码量变多。如果你做的是小 DemoTime.timeScale就够了但发布前一定要检查所有 UI 界面是否有依赖速度的动画。击退的实现同样建议用协程而不是直接改velocity。击退要的是以固定速度后退一段距离并逐渐衰减而不是持续匀速直接改速度值会有一种被皮筋拉走的怪感。using System.Collections; using UnityEngine; public class KnockbackFeedback : MonoBehaviour { public IEnumerator Apply(Rigidbody2D rb, Vector2 force, float duration) { float timer 0f; while (timer duration) { rb.velocity Vector2.Lerp(force, Vector2.zero, timer / duration); timer Time.deltaTime; yield return null; } rb.velocity Vector2.zero; } }逻辑说明Lerp从作用力向量向零插值模拟受力后逐渐停下的物理效果。相比直接给AddForce它的轨迹是可预期的也方便对不同类型的敌人配置不同的击退距离。参数说明force向量的 x 方向必须按攻击方向取反比如攻击逻辑里写transform.right * (character.isFacingRight ? 1 : -1)。另一个注意点是击退期间必须取消敌人 AI 对自己速度的控制否则 AI 每帧把速度重置回巡逻值击退效果就看不出来了。我在敌人控制器里加了一个stunned标签击退期间updateMovement直接 return。3.3 连招输入输入缓冲与状态机的最小实现三国战纪的招式系统本质是在正确的时机输入上下左右加攻击键。Unity 想要实现这个需要两层一层是输入收集一层是状态机。早期项目里最容易犯的错误是用if (Input.GetKeyDown(KeyCode.J))直接触发招式这带来两个问题——玩家快速连按的输入丢失以及一个招式未结束就打断。正确做法是输入缓冲把按键按下的时间点记下来状态机空闲时立刻消费最近一次输入如果状态机正忙就把输入留在队列里等下一个可用帧执行。using System.Collections.Generic; using UnityEngine; public class InputBuffer : MonoBehaviour { public enum Command { Attack, Jump, Skill, Special } private QueueCommand buffer new QueueCommand(); public float bufferTime 0.15f; // 输入保留窗口秒 void Update() { if (Input.GetKeyDown(KeyCode.J)) Enqueue(Command.Attack); if (Input.GetKeyDown(KeyCode.K)) Enqueue(Command.Jump); } void Enqueue(Command cmd) { buffer.Enqueue(cmd); StartCoroutine(Expire(cmd)); } IEnumerator Expire(Command cmd) { yield return new WaitForSeconds(bufferTime); // 从队列中删除指定命令这里用了一个临时队列 var temp new QueueCommand(buffer); buffer.Clear(); while (temp.Count 0) { var c temp.Dequeue(); if (!c.Equals(cmd) || !temp.Contains(cmd)) buffer.Enqueue(c); } } public bool TryConsume(Command cmd) { if (buffer.Count 0) return false; if (buffer.Peek().Equals(cmd)) { buffer.Dequeue(); return true; } return false; } }逻辑说明Enqueue把命令放进队列并启动一个协程:bufferTime秒后从队列里移除——也就是说按下攻击键的 0.15 秒内攻击指令都有效。TryConsume由角色状态机调用空闲时它弹出队首命令判断是否执行对应动作忙碌时命令继续留在队列里等下一次调用。参数说明bufferTime是手感玄学放大器。太大玩家感觉我按了没反应过一会儿他突然出手太小玩家快速连打时中间分帧会漏输入。我一般从 0.12f 起步在手机上测试时会调到 0.2f——因为手机屏幕触控的物理延迟比键鼠高。另外只对队首命令做响应很关键不然一条连招里快速按出的多个指令会被全部消费导致角色连续做出两个动作看起来像指令冲突。4. 从单角色到完整关卡敌人 AI、摄像机卷动与对象池4.1 敌人行为树不必复杂近战、远程、出场三大逻辑街机清版游戏的敌人 AI 并不需要多聪明——它们存在的意义是让玩家打出连招和制造压迫感。一个走位风骚、懂得后撤步的 AI 反而会毁掉游戏体验因为玩家追不上就没法连招。我通常只给敌人三种状态接近Walk、攻击Attack、受击僵直Stunned。// 一个近战敌人的最小 AI 控制 using UnityEngine; public class MeleeEnemyAI : MonoBehaviour { public Transform player; public float moveSpeed 2f; public float attackRange 1.2f; public float attackCooldown 1.5f; private Rigidbody2D rb; private bool isStunned; private float lastAttackTime; void Awake() { rb GetComponentRigidbody2D(); // 这里简化了查找玩家的逻辑真实项目里可以通过管理器注册获得 if (player null) player GameObject.FindWithTag(Player).transform; } void Update() { if (isStunned) return; float dist Vector2.Distance(player.position, transform.position); if (dist attackRange) { if (Time.time - lastAttackTime attackCooldown) { lastAttackTime Time.time; Attack(); } rb.velocity Vector2.zero; } else { Vector2 dir (player.position - transform.position).normalized; rb.velocity new Vector2(dir.x * moveSpeed, rb.velocity.y); } // 翻转朝向以匹配攻击/行走动画 if (player.position.x transform.position.x) transform.localScale new Vector3(Mathf.Abs(transform.localScale.x), transform.localScale.y, transform.localScale.z); else transform.localScale new Vector3(-Mathf.Abs(transform.localScale.x), transform.localScale.y, transform.localScale.z); } void Attack() { // 调用攻击动画和攻击判定框 GetComponentAnimator().SetTrigger(Attack); GetComponentInChildrenEnemyAttackBox().Activate(); } public void SetStunned(bool value) { isStunned value; rb.velocity Vector2.zero; } }逻辑说明这个 AI 每帧计算和玩家的距离在攻击范围外移动接近、在范围内停下并等冷却后攻击。isStunned配合上文提到的击退逻辑——敌人被命中后置为true击退结束恢复 false这样 AI 就不会和击退力打架。一套出场-进攻-受伤-死亡的完整敌人逻辑其实只是在Update里多挂几个状态。没用状态机来做是因为敌人数量多、状态单薄用枚举加 if 足够清晰反而比抽象成状态机类更省事。等有一天你的某个敌人出现了远程、跳跃、追踪、狂暴复合状态再迁移到状态机不迟。4.2 出场与掉落事件触发的门和刷怪点横版过关里最常见的敌人从门里走出来效果本质上是一个延迟生成逻辑。先在地图上摆一扇门播放一段门打开动画然后在动画的特定帧生成敌人对象。这个时点控制用动画事件Animation Event最可靠。// 挂在门节点上的脚本 using UnityEngine; public class SpawnTrigger : MonoBehaviour { public GameObject enemyPrefab; public Transform spawnPoint; // 由门动画的事件帧调用 public void SpawnEnemy() { GameObject enemy Instantiate(enemyPrefab, spawnPoint.position, Quaternion.identity); // 新生成的敌人归入当前关卡对象池 enemy.transform.SetParent(GameManager.Instance.currentStageRoot); } }动画事件的做法是选中门的 Animation Clip在敌人走出来的那一帧右键添加事件Function填SpawnEnemy。这个方案的优点在于精确到帧且时间点可以直接在动画预览窗口调整不需要在代码里硬编码延迟秒数——我之前用Invoke(SpawnEnemy, 1.2f)配合动画播放只要动画 fps 改延迟就不准了很狼狈。掉落物同理老虎机一样的三国战纪道具掉落在敌人死亡时选一个掉落点在掉落物上做一个简单的抛物线运动。这里不建议用 Rigidbody2D 的物理模拟来做抛物线——物理引擎的微小随机会让同一道具每次掉落的落点不一样而动作游戏里玩家对掉落点的预判很重要。用手写协程做固定轨迹看起来更游戏。4.3 对象池从敌人子弹到掉落物别让 GC 拖垮帧率怪物死亡要生成掉落物角色攻击要生成特效玩家放必杀要生成一大片光效——如果没有对象池Unity 的 GC垃圾回收会在战斗最激烈的时候突然给你来一个明显的大卡顿。这就是很多粒子特效内存泄露现场的根本原因频繁Instantiate和Destroy会让堆碎片越来越多。对象池在 2D 清版游戏里最常用的两个地方子弹/特效、敌人本身也可复用。// 一个年代久远但好用的简易对象池 using System.Collections.Generic; using UnityEngine; public class SimplePool : MonoBehaviour { public GameObject prefab; public int preloadCount 20; private QueueGameObject pool new QueueGameObject(); void Awake() { for (int i 0; i preloadCount; i) { var obj CreateInstance(); obj.SetActive(false); pool.Enqueue(obj); } } GameObject CreateInstance() { var obj Instantiate(prefab); obj.transform.SetParent(transform); return obj; } public GameObject Get(Vector3 position, Quaternion rotation) { if (pool.Count 0) { var extra CreateInstance(); pool.Enqueue(extra); } var go pool.Dequeue(); go.transform.SetPositionAndRotation(position, rotation); go.SetActive(true); return go; } public void Return(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }逻辑说明物体在池里都是休眠状态用的时候从队列取、设置位置启用、用完之后还回队列。预热数量preloadCount决定了一次性扣的初始量我一般按同屏最大敌人数 x 1.5来配。这里必须强调一个直达本质的坑对象池里的对象不能挂依赖OnDestroy重置状态的脚本。比如某个怪物的血条 UI 是在OnDestroy里关闭的池化之后物体销毁不存在了血条系统就要写专门的与场景释放挂钩逻辑。另一个常见翻车点是对象出池时忘了重置动画控制器里的状态敌人以死亡动画播到一半的样子出场。最佳做法是把重置逻辑收敛在一个独立的ResetState()方法里出池时由池对象调用。5. 避坑手册粒子内存泄漏、画面拉伸、阴影错位与手柄输入5.1 粒子特效越来越慢、内存只升不降可能是你在频繁 Instantiate 特效现象游戏运行越久放技能越卡任务管理器里内存稳步上升甚至场景切走后回来卡一下。原因排查典型现场就是每次攻击都Instantiate(attackEffect)播放完直接Destroy。这是粒子特效内存泄露最常见的制造方式——粒子系统播放时分配的网格、材质、纹理不会随Destroy立刻归还而且Destroy的时机是当前帧结束但粒子子物体上的协程还在跑。结果就是反复创建销毁几百个粒子实例内存碎片堆成山。解决特效走对象池上文SimplePool同时在粒子系统上做两个设置一是StopAction设为Disable让粒子播放完自动回到池里二是禁用粒子的Loop勾选。不要依赖ParticleSystem.Stop()手动停止它在某些 Unity 版本里搭配对象池会漏处理子发射器。5.2 打包到手机画面拉伸Canvas Scaler 和适配策略不统一现象编辑器里 4:3 好好的打包到手机 19.5:9 屏幕上UI 挤到屏幕边缘外面或者整个画面像被拉宽了。原因Panel 或 UI 元素用了绝对像素定位适配模式没设置也可能是摄像机Size固定 宽屏显示器导致水平方向可见范围变大游戏画面看起来扁了。解决UI 部分用 Canvas Scaler 的Scale With Screen Size参考分辨率按开发基准写 1280x720Match值设 0.5同时考虑宽和高的权重。游戏画面部分这里有一个取舍如果让摄像机Size随手屏幕高度缩放宽屏时左右就会露出更多场景看到本该隐藏的敌人和边界如果让Size随手屏幕宽度缩放窄屏时上下就会裁掉画面。做 2D 横版游戏我推荐固定高度、左右修剪或左右让边界稍微宽出理由是角色在竖直方向的动作范围不能裁而水平方向多露出一点反而让玩家更早看到敌人。具体实现是把背景画到一个比屏幕大一圈的宽画布上或者加一层自适应延伸的纯色渐变。5.3 动画帧和判定框错位Sprite 边框没裁剪干净现象角色动画播放时手臂超前于判定框玩家明明躲开了还是被打中——这种打空气问题最容易收到玩家投诉。原因美术导出的 PNG 没有裁掉透明边缘或者不同帧的锚点Pivot不在同一位置。Sprite 尺寸不一碰撞体跟着飘。解决导入素材后统一到 Sprite Editor 里裁好每一帧锚点全部设为 Bottom Center角色脚底中心。然后打开 Animation 窗口把你拖进去的每个关键帧都检查一遍透明边和锚点不一致的帧是手感劣化的最大隐藏黑手。这个检查在项目开始时就做不要等角色做了一半再做——中途改锚点会导致所有动画位移、所有碰撞体位置资料作废。5.4 手柄右摇杆负责转向右扳机被误设成攻击键Input System 的映射细节现象玩家用手柄测试时按下右扳机没反应或者右摇杆推一下人物就乱转。原因用 Input System 时默认的 Gamepad 映射把右扳机RightTrigger绑定到了某个动作但你没有在 Action 资产里区分扳机的浮点值和按键的按下事件。右摇杆RightStick默认是轴值如果你把它绑定到一个按钮类型的 Action就会出现完全不符合直觉的指令。解决明确三类手柄输入的类型——左摇杆是 2D 矢量Vector2Action右扳机是浮点AxisAction右肩键是按钮ButtonAction。打断动作类技能不要绑在扳机上因为扳机的按键阈值press point在部分手柄上默认 0.5手劲轻的玩家可能按不到底。如果必须用扳机做技能把 Action 的Trigger Behavior改为Pressed而不是Value或者直接把技能放到右肩键。5.5 移动时角色原地滑步Rigidbody2D 的模拟模式与动画没对齐现象角色走路动画在动但位移比动画慢了一拍像在地上滑冰。原因角色用了 Rigidbody2D 的Dynamic类型移动时直接改rb.velocity但动画播放速度是按Update的Time.deltaTime走的。物理模拟的FixedUpdate频率默认 50Hz0.02 秒如果你的帧率是 60fps动画每帧更新物理却每 1.2 帧更新一次位移看起来就比动画慢。解决一是把 Rigidbody2D 的Interpolate设置为Interpolate让渲染层平滑插值二是移动速度的数值要和动画的每帧期望位移对得上——用Rigidbody2D.velocity获取当前速度做校验然后在动画事件里对位移和步频做一次匹配。还有一个更粗暴但常见的方案不用物理移动角色改用transform.position dir * speed * Time.deltaTime物理只用于受击击退。横版过关的玩家角色不需要物理模拟没有跳跃动量、惯性之类用纯 Transform 移动最可控。6. 把打击感从手感变成数据用帧数据表驱动角色动作前面几章我们不断在调参FPS、判定框延迟、击退距离、顿帧时长。一个动作游戏项目做到后期最大的痛点已经不是某一招不好用而是无数个参数在交互影响忘记哪个改动导致手感突然变了。这个阶段我会把角色所有的进攻动作收编成一张张帧数据表Frame Data用统一的 ScriptableObject 来承载。所谓帧数据表就是把一个动作拆成前摇帧、发生帧、后摇帧、伤害、击退、无敌帧若干列每列都对应真实的动画帧时间。比如普通攻击动作前摇 3 帧、判定发生 2 帧第 3-4 帧出现判定框、后摇 5 帧总计 10 帧。做成 SO 资产后代码里通过一个通用组件读取它这样你调整平衡性时根本不用打开 Unity 编辑器去改动画直接在 SO 的 Inspector 里改数字即可。// 攻击动作的帧数据资产 using UnityEngine; [CreateAssetMenu(fileName AttackData, menuName Game/AttackData)] public class AttackData : ScriptableObject { public string attackName; public int startupFrames; // 前摇无法取消、无判定 public int activeFramesStart; // 判定开始帧从动作第1帧起算 public int activeFramesEnd; // 判定结束帧 public int recoveryFrames; // 后摇可以接其他动作 public float damage; public Vector2 knockbackForce; public float hitStopDuration; // 命中顿帧时长秒 public bool hasSuperArmor; // 霸体受击不硬直 }逻辑说明startupFrames和recoveryFrames的配置直接决定了攻击的重量感。前摇长出招变慢但伤害可以调高后摇长破绽大就适合远程攻击。而hitStopDuration这一项我认为是一套连招爽不爽的最强杠杆——数值在 0.03 到 0.06 之间变化玩家体感差异巨大。我踩过的坑是把这个值从 0.05 调到 0.08 想增强打击感结果玩家反馈动作一顿一顿像网络卡所以顿帧绝对不是越大越爽它的上限取决于动画节奏。有了帧数据表剩下要做的是在攻击时把动画播放和攻击框启停绑定到这些数字上。方法是动画剪辑的每一帧都是可控的在角色状态机里维护一个当前攻击帧计数frame count每个Update加一然后和AttackData里的startupFrames / activeFramesStart / activeFramesEnd / recoveryFrames比对在对应区间打开或关闭子物体上的攻击框。把手感做成数据后验证方法就很简单了给角色加一个调试面板实时显示当前处于哪个帧区间、是否处于判定帧。我以前调连招时是开Gizmos画出攻击框的矩形设成Always可见然后暂停游戏逐帧步进检查每一帧的判定框位置和伤害输出是否和帧数据表一致。这是动作游戏必做的一步宁可花一小时画调试线也别靠感觉差不多发布。最后聊一个我自己的习惯每个动作做完我会录一段 5 秒的视频只录角色出招的那几帧反复暂停回放用慢动作确认视觉上拳头的边缘和物理上判定框的边缘偏差不超过两三个像素。这个习惯救过我好几次——有一次动画资源替换后美术把角色的武器描边加粗了一圈判定框视觉上看已经穿过敌人了实际上还没碰触如果不是慢动作回放根本发现不了。希望这个验证流程也能帮到你毕竟2D动作游戏这条路手感是一拳一拳调出来的没有任何捷径。本文还有配套的精品资源点击获取