ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity 2D街机游戏开发:从输入缓冲到打击感的手感构建方案

Unity 2D街机游戏开发:从输入缓冲到打击感的手感构建方案 简介面向Unity开发者的经典街机题材2D游戏《三国战纪》资源包覆盖场景搭建、精灵动画、物理碰撞、C#逻辑、UI和音频等开发关键环节。包内共443个文件压缩包约4.5MB以脚本、动画、贴图与配置文件为主90个cs脚本实现角色移动、攻击、技能释放等核心玩法24个anim动画配合2个controller完成角色动作状态机30个png提供人物与场景贴图另有enemyarea、config等配置辅助关卡和敌人AI区域设定。目前已有335人学习下载。通过查看脚本、动画器和预制体结构可理解街机格斗游戏的对象管理、碰撞检测、输入响应与动画切换思路适合正在学习Unity 2D开发、希望参考完整项目组织方式或补充历史题材素材的读者。资源目录分层清晰脚本、动画、资源各归其位能直接导入工程检查效果也可作为二次开发的基础。1. 三国战纪式的2D街机游戏Unity里最该先做的是“手感”做 Unity 开发 2D 街机游戏三国战纪很多人一上来就铺角色、铺技能、铺关卡结果跑起来才发现角色像在踩棉花。街机清版动作游戏最核心的壁垒不是美术量而是“输入到命中”这条链路的确定性按键下去了角色必须在这一帧或下一帧给反馈攻击判定必须落在动画对应的准确位置连招窗口必须给足够的容错。这篇文章要解决的问题就是如何用 Unity 把这套街机手感从零搭出来覆盖输入、判定、连招、敌人调度、性能与常见翻车点。适合想从教程 Demo 走向可玩版本的开发者。2. 战斗底盘三件事输入缓冲、手动判定与摄像机跟随2.1 输入缓冲连招搓不上先加 12 帧“后悔药”街机玩家最敏感的体验是按键被吞。角色还在出轻拳第一段的时候玩家已经把第二段按下了如果你只在攻击状态里轮询按键这一按就会被丢弃。三国战纪这类游戏允许连招的按键提前输入做法就是给每个按键一个缓冲窗口。我一般会在工程里放一个全局的InputBuffer它只记录“哪一帧按了哪个键”不直接触发动作。真正的动作系统在需要读输入时调用Consume只要当前帧和按下帧的间隔在缓冲帧内就算有效输入。using UnityEngine; using System.Collections.Generic; public class InputBuffer : MonoBehaviour { [SerializeField, Range(1, 30)] private int bufferFrames 12; private readonly Dictionarystring, int _lastPressedFrame new Dictionarystring, int(); private void Update() { if (Input.GetButtonDown(LightAttack)) _lastPressedFrame[LightAttack] Time.frameCount; if (Input.GetButtonDown(HeavyAttack)) _lastPressedFrame[HeavyAttack] Time.frameCount; if (Input.GetButtonDown(Skill1)) _lastPressedFrame[Skill1] Time.frameCount; } // 动作系统调用成功消费返回 true并清除记录 public bool Consume(string buttonName) { if (!_lastPressedFrame.TryGetValue(buttonName, out int pressedFrame)) return false; _lastPressedFrame.Remove(buttonName); // 按下到消费的时间差不能超过 bufferFrames return Time.frameCount - pressedFrame bufferFrames; } }这段代码的逻辑是Update 里只记录 GetButtonDown 的帧号不直接触发攻击。当动画或状态机代码需要判断“玩家这轮有没有按过”时再去对比帧差。这样即使玩家在轻拳第 3 帧就按了重拳重拳的输入也能在第 8 帧被完整消费。参数说明里最值得调的是bufferFrames。按 60 帧算12 帧就是 200ms这是清版动作游戏比较舒服的提前量追求硬核手感可以压到 8 帧但新手会明显觉得“招式接不上”。大于 20 帧就会出现按一下出两招的问题因为玩家已经进行了一次有效连段缓冲里还残留着旧输入。同一帧里多个按键被按下时按字典顺序逐个消费这就是指令优先级的基础。2.2 2D碰撞检测用 OverlapBox 手动采样而不是 TriggerUnity 2D碰撞检测有两个派系挂 Collider2D 等 OnTriggerEnter2D 回调或者主动调 Physics2D.OverlapBox 采样。做街机清版动作我几乎不用 Trigger 做攻击命中原因是 Trigger 的回调发生在物理步里而物理步又和 Update 不同步。你在这帧开启了攻击框最早也要下一个物理步才能检测到碰撞等碰撞回来了动画已经过了打击帧表现上就是“动作打到一半伤害才跳出来”。我的做法是每个攻击段配一个ManualHitSampler由动画事件在指定的判定帧调用SampleHitOnce()当场用 OverlapBoxAll 采样一次。using UnityEngine; public class ManualHitSampler : MonoBehaviour { [SerializeField] private Vector2 hitOffset new Vector2(0.8f, 0f); [SerializeField] private Vector2 hitSize new Vector2(0.5f, 0.7f); [SerializeField] private LayerMask hitMask; private readonly System.Collections.Generic.HashSetCollider2D _alreadyHit new System.Collections.Generic.HashSetCollider2D(); // 由动画事件在判定帧调用 public void SampleHitOnce() { _alreadyHit.Clear(); Vector2 center (Vector2)transform.position hitOffset; Collider2D[] hits Physics2D.OverlapBoxAll(center, hitSize, 0f, hitMask); foreach (Collider2D hit in hits) { if (_alreadyHit.Contains(hit)) continue; _alreadyHit.Add(hit); if (hit.TryGetComponentIDamageable(out var target)) { target.TakeDamage(5, transform.right * 2f); } } } }为什么这样写_alreadyHit这个集合保证同一段攻击不会对同一个目标重复结算hitMask用 LayerMask 过滤敌人、可破坏物各一层玩家攻击只打敌人层避免每次都在代码里判断标签。调用时机由动画事件控制精准落在打击帧上不用等物理步的异步回调。与 Trigger 方案的对比如下对比项Trigger OnTriggerEnter2DOverlapBox 手动采样检测时机下一物理步调用当前帧重复命中控制需要额外标志位HashSet 自己控制受碰撞矩阵影响是只受 LayerMask 影响能拿命中点信息困难可从 Collider 位置拿参数上hitOffset必须用角色朝向做转换不能写死一个向右的偏移。角色转向后要重新计算常见做法是transform.right * distance。hitSize不要一次给太大轻拳 0.5×0.7 这种量级差不多重击再放大 1.3 倍左右太大了会把身后敌人也打到玩家立刻觉得判定不公平。2.3 摄像机跟随死区怎么给Cinemachine 和自写怎么选三国战纪这类横版清版动作摄像机有一个明显特征节奏快画面不能一直缓慢漂移否则玩家操作会跟不上。这里推荐用带死区的跟随方式角色在屏幕中间一小块区域内自由移动摄像机不动只有角色超出死区边界摄像机才追过去。这样打斗时画面是稳定的快速冲刺时又能保证角色不跑出屏幕。Cinemachine 的 CM2D 相机自带 Dead Zone 和 Camera Confiner默认参数就已经很好用。但如果团队对包体敏感或者后续要适配微信小游戏这类轻量环境建议直接写一个几十行的跟随脚本。下面是一个带死区、边界限制和平滑阻尼的版本using UnityEngine; public class CameraFollow2D : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private Vector2 deadZone new Vector2(0.5f, 0.3f); [SerializeField] private Vector2 minPos new Vector2(-100f, -100f); [SerializeField] private Vector2 maxPos new Vector2(100f, 100f); [SerializeField] private float smoothTime 0.15f; private Vector2 _velocity; private void LateUpdate() { Vector3 pos transform.position; Vector2 desired pos; if (Mathf.Abs(target.position.x - pos.x) deadZone.x) desired.x target.position.x - pos.x - Mathf.Sign(target.position.x - pos.x) * deadZone.x; if (Mathf.Abs(target.position.y - pos.y) deadZone.y) desired.y target.position.y - pos.y - Mathf.Sign(target.position.y - pos.y) * deadZone.y; desired.x Mathf.Clamp(desired.x, minPos.x, maxPos.x); desired.y Mathf.Clamp(desired.y, minPos.y, maxPos.y); transform.position Vector2.SmoothDamp( pos, desired, ref _velocity, smoothTime); } }这段代码的关键在deadZonex 方向 0.5 个世界单位、y 方向 0.3 个世界单位是按正交相机 5.0 size、16:9 屏幕估算的大约等于屏幕宽度的 1/16。双人合作时死区要放大否则一个玩家在左一个在右相机来回抖。minPos和maxPos就是关卡边界手动拖动两个空物体取坐标填上即可如果用了 Cinemachine则直接用 Camera Confiner 2D 挂一个边界 Polygon。3. 连招状态机与打击感把“打到肉”的感觉参数化3.1 Animator 分层与参数连招、受击、技能别挤在一层连招系统的第一原则是“动作可被打断”。哪怕角色正在出第三段重拳只要敌人一刀砍过来就必须立刻切进受击动画不能等当前动作播完。Unity Animator 的多层机制在这里最有用基础层放待机、走、攻击受击层放被击和死亡技能层放必杀演出。受击层的权重高于基础层受击时上层动画直接覆盖下层角色表现为立刻被打断。角色控制器里我一般维护这几个 Animator 参数参数类型用途ComboStepint当前连招到第几段ComboNextbool是否打开接下一段的窗口IsGroundedbool是否在地面决定能不能起跳IsMovingbool是否在移动切换走/跑到待机Hurtbool受击状态IsDeadbool死亡状态攻击动画不要勾选 Apply Root Motion位移自己用代码控制。原因很简单Root Motion 的位移是曲线很难精确踩到判定帧而手动移动可以让“位移距离、攻击判定、动画播放”三者强制同步。3.2 用动画事件开闭“接续窗口”而不是读动画时长连招的接续窗口最好用动画事件控制不要用GetCurrentAnimatorStateInfo去比对时间。动画片段一旦重命名或改帧时长就变了代码里的 magin 时间点全部失效。更稳的方式是在每个攻击动画的时间轴上放两个事件一个在打击帧结束后 23 帧打开ComboNext一个在动画结束前一帧关闭。这样就形成一个明确的可接续窗口。using UnityEngine; public class ComboController : MonoBehaviour { [SerializeField] private Animator anim; private static readonly int ComboStep Animator.StringToHash(ComboStep); private static readonly int ComboNext Animator.StringToHash(ComboNext); private static readonly int AttackTrigger Animator.StringToHash(AttackTrigger); public void OnAttackButtonDown() { // 当前不在攻击动作起手第一段 if (!anim.GetCurrentAnimatorStateInfo(0).IsTag(Attack)) { anim.SetInteger(ComboStep, 0); anim.SetBool(ComboNext, false); anim.SetTrigger(AttackTrigger); return; } // 在攻击中且窗口打开进入下一段 if (anim.GetBool(ComboNext)) { int step anim.GetInteger(ComboStep) 1; anim.SetInteger(ComboStep, step); anim.SetBool(ComboNext, false); anim.SetTrigger(AttackTrigger); } } public void OpenComboWindow() anim.SetBool(ComboNext, true); public void CloseComboWindow() anim.SetBool(ComboNext, false); }这段代码配合前文的 InputBuffer 使用OnAttackButtonDown不是每帧轮询而是由输入系统在消费到一次“轻攻击”时调用一次。之所以不用 Trigger 而是用 bool 标记窗口是因为 Trigger 会残留连续两段攻击中同时触发两次 SetTrigger第二段会吞掉第一段的状态。bool 配合事件显式开关逻辑是可追踪的。窗口位置有两个注意点。第一窗口开太早会让玩家觉得“乱按就连上”开太晚则变成“背板游戏”我比较常用打击帧后 2 帧开窗动画结束前 1 帧关窗。第二动画事件不要放在动画剪辑的第一帧或最后一帧这两个位置在混合过渡时经常被跳过放中间最安全。3.3 命中停顿与屏幕震动打击感参数表打击感的核心参数就四个命中停顿、屏幕震动、闪白、击退。三国战纪这类街机游戏每一次普通打中都必须有轻微停顿重击和必杀停得更长。当玩家看到角色动作短暂凝滞大脑会把它解读为“这一击很重”。最常用的命中停顿实现是临时把Time.timeScale置零用WaitForSecondsRealtime等待真实时间后恢复using System.Collections; using UnityEngine; public static class HitStop { private static MonoBehaviour _runner; public static void Initialize(MonoBehaviour runner) _runner runner; public static void Play(float seconds) { if (_runner null) return; _runner.StartCoroutine(Run(seconds)); } private static IEnumerator Run(float seconds) { float prevTimeScale Time.timeScale; Time.timeScale 0f; yield return new WaitForSecondsRealtime(seconds); Time.timeScale prevTimeScale; } }Time.timeScale 0会冻结所有动画和基于 deltaTime 的移动但协程里的 WaitForSecondsRealtime 不受影响所以能精确恢复。这套方案最省事但要注意它会连 UI 动画和粒子一起冻结。如果后续要做联机或场景内大量特效就要换成局部时间缩放只冻结战场上的 Animator 和移动脚本不碰 UI 层。我常用的数值如下效果推荐值注意命中停顿轻攻击 0.03~0.05s重攻击 0.08~0.12s同帧多段命中只触发一次屏幕震动振幅 0.02~0.05 世界单位持续 0.08~0.15s正交相机 size 为 5 时约 3~8 像素命中闪白2~3 帧白色叠层SpriteRenderer 材质改成可叠加的 Sprite-Unlit击退轻攻击 2~3重攻击 6~8速度曲线衰减攻击者朝向的相反方向这些参数不要拍脑袋定建议先按表格给一套初始值录屏逐帧看。震动幅度超过 0.05 世界单位就会晕低于 0.02 则基本无感。难点是命中停顿和震屏同时发生时动画会被卡在一个很尴尬的位置所以命中停顿一般只挂在重伤或必杀上普通小兵轻击给 0.03s 以内就够。4. 敌人AI与单位调度一屏多怪不卡不掉帧的做法4.1 敌人AI状态机受击和死亡必须占最高优先级小兵 AI 不需要复杂行为树一个五状态的状态机完全够用待机、追击、攻击、受击、死亡。三国战纪里的小兵无非是“走近了打一下被打了怼回去”。真正的坑在状态切换的优先级受击和死亡必须压过追击和攻击否则会出现敌人一边播着受击动画一边往前滑步。using UnityEngine; public class EnemyAI : MonoBehaviour { private enum State { Idle, Chase, Attack, Hurt, Die } [SerializeField] private float detectRadius 4f; [SerializeField] private float attackRadius 1.2f; [SerializeField] private float attackCooldown 2f; [SerializeField] private Transform player; private State _state State.Idle; private float _cooldownTimer; private void Update() { if (_state State.Hurt || _state State.Die) return; _cooldownTimer - Time.deltaTime; switch (_state) { case State.Idle: if (Vector2.Distance(transform.position, player.position) detectRadius) _state State.Chase; break; case State.Chase: if (Vector2.Distance(transform.position, player.position) attackRadius _cooldownTimer 0f) { _state State.Attack; _cooldownTimer attackCooldown; } break; case State.Attack: // 攻击动画播完由动画事件在 AttackEnd 事件里回到 Chase break; } } public void OnHurt() { _state State.Hurt; // 受击动画播完后调用 OnHurtEnd 回到 Chase } public void OnHurtEnd() _state State.Chase; public void OnDeath() _state State.Die; }这一段有两个细节。第一Update 开头直接 return是防止受击状态下 AI 继续驱动位移如果连招把敌人打进无限受击那它就一直处于 Hurt直到硬直结束。第二attackCooldown必须放冷却逻辑否则敌人会像机关枪一样每帧都在攻击。参数上近战小兵detectRadius给 45 就好全图仇恨会让玩家被围到毫无操作空间attackCooldown给 1.82.5s数值过低小兵连续攻击玩家没机会反击数值过高则节奏拖沓。4.2 同屏上限与对象池清版游戏不要 Instantiate三国战纪这类游戏同屏敌人峰值通常是 915 个加上技能特效和掉落物。如果每个敌人出生都用Instantiate、死亡用Destroy移动端必定频繁触发 GC表现为攻击瞬间帧率掉一截。对象池是所有清版动作游戏的标配。using System.Collections.Generic; using UnityEngine; public class EnemyPool { private readonly StackEnemy _idlePool new StackEnemy(); private readonly Transform _poolRoot; private readonly Enemy _prefab; public EnemyPool(Enemy prefab, Transform poolRoot, int prewarmCount) { _prefab prefab; _poolRoot poolRoot; for (int i 0; i prewarmCount; i) { Enemy enemy Object.Instantiate(_prefab, poolRoot); enemy.gameObject.SetActive(false); _idlePool.Push(enemy); } } public Enemy Spawn(Vector2 position) { Enemy enemy _idlePool.Count 0 ? _idlePool.Pop() : Object.Instantiate(_prefab, _poolRoot); enemy.transform.position position; enemy.ResetState(); enemy.gameObject.SetActive(true); return enemy; } public void Despawn(Enemy enemy) { enemy.ResetState(); enemy.gameObject.SetActive(false); enemy.transform.SetParent(_poolRoot); _idlePool.Push(enemy); } }这个池子需要注意三个参数。预热数量直接决定首波战斗的顺滑度按同屏峰值 12 算预热 16 个比较稳扩容策略是一次性再补当前池的 20%不要单个单个地 Instantiate。ResetState很关键必须清空 Rigidbody2D 的 velocity、重置 Animator 的 bool 参数、复位生命值否则敌人从池里再出生时还带着上一轮的死亡动画状态。死亡的敌人不要当场 Despawn让死亡动画播完由动画事件回调 Despawn。4.3 关卡波次用 ScriptableObject 配表驱动别手工布点手工在场景里摆敌人看着直观但调平衡时每调一关都要改场景、存 prefab极容易把某个波次改坏。更可靠的做法是把波次信息放到配表里关卡逻辑只负责按顺序读表、按时间段生成。using UnityEngine; [CreateAssetMenu(fileName WaveConfig, menuName Battle/WaveConfig)] public class WaveConfig : ScriptableObject { public WaveGroup[] groups; } [System.Serializable] public class WaveGroup { public EnemyType enemyType; public int count; public float spawnInterval 1f; public Vector2 spawnCenter; public bool requireAllDeadToNext; }关卡 Director 每一帧检查波次条件当前存活敌人数量是否低于阈值、波次计时器是否到了spawnInterval、是否要等requireAllDeadToNext清空了才开下一波。这里有一个常用约束不要一次性把配表里的count全放出来而是每 0.81.2 秒出一只直到出的数量达到 count这样玩家不会面对一堵人墙。spawnCenter建议放在屏幕外一侧 12 个屏幕宽度的位置敌人从走路进入镜头而不是凭空在玩家脸上刷出。5. 避坑做2D街机动作最容易翻车的5个现场5.1 攻击判定打空了Transform 与 Rigidbody2D 的位置差半拍现象角色已经在动画里挥出一拳判定盒却跟在角色身后敌人站在脸前反而没吃到伤害。调试时打开 Gizmo 显示碰撞盒有时看到判定盒和角色模型错开一个身位。原因角色的移动用的是 Rigidbody2D但transform.position在物理系统还没同步前会停留在上一帧。攻击手动采样在 Update 里调OverlapBoxAll取到的transform.position是旧的判定盒整体落后。解决采样前先调一次Physics2D.SyncTransforms()让物理系统把所有 Transform 同步到最新位置更稳的做法是攻击框中心改用rigidbody2D.position offset不要用transform.position。这个问题在敌人身上同样存在敌人 AI 的追击判定也要用 Rigidbody2D 的位置。5.2 连招第二段永远按不出来输入被吞在窗口外侧现象角色能打出第一段但第二段死活接不上无论按键多快都没反应。原因连招窗口只在特定帧内打开而玩家从第一段起手到按第二段的按键记录可能发生在窗口打开前一帧被 InputBuffer 判定为过期输入直接丢弃。更常见的是动画事件没在正确帧触发ComboNext一直为 false。解决在ComboController里加一个 pending 标记玩家在攻击状态下、窗口没打开时按下的攻击键先暂存一帧下一帧窗口打开就直接消费这个 pending。同时检查动画事件的锚点位置不要在首尾帧放事件。5.3 敌人挤成一堵人墙刚体碰撞矩阵没配好现象一群敌人生成后互相推挤叠在一个点上演人塔玩家一个技能打过去只命中外面两只。原因所有敌人默认同一层Collider2D 互相碰撞物理系统一直在解算碰撞Gravity Scale 又设为 0结果就是大家卡在一起推来推去。解决在 Project Settings 的 Physics 2D 里打开 Layer Collision Matrix把 Enemy 层和 Enemy 层之间的碰撞勾掉。敌人之间不再物理互撞后再写一个简单的分离逻辑每个敌人检测半径 1 米内是否有其他敌人有就把自己往相反方向推 0.1 的偏移每帧只做一次廉价且稳定。最大化时注意这是 Unity 2D碰撞检测里最常见的失误项目一开始就要把 Player、Enemy、PlayerAttack、EnemyAttack、Obstacle 这几层先定义好。5.4 角色边缘发虚发白图集压缩和出血边没处理现象角色 Sprite 在移动时边缘出现白色或黑色细线缩放镜头越明显动态时还会闪。原因图集压缩格式选了带损压缩相邻 Sprite 的颜色渗过来了或者原画边缘本身就是半透明像素被采样后混入了背景。解决图片导入设置里把 Compression 改为 NoneFilter Mode 用 Point 或 Bilinear 看美术风格Sprite 面板里把 Mesh Type 改为 Full Rect然后在 Sprite Editor 里对每个 Sprite 设置 24 像素的 Extrude 出血边。像素风游戏一定要用 Point 采样并且不要让 Unity 自动生成 mipmap2D 场景里 mipmap 会让画面变糊。5.5 微信小游戏上掉帧FixedUpdate 和 GC 搅在一起现象编辑器里 60 帧稳定打包到微信小游戏之后一放技能就掉到 40 帧还伴随周期性卡顿。原因角色移动和敌人追击都写在 FixedUpdate 里手机上 FixedUpdate 的物理步被拖慢动画和位移对不上另一个问题是每次攻击都 new 数组或 ListGC 压力在小游戏环境被放大。解决移动、追击、跳转这些逻辑全部挪到 Update用Time.deltaTime驱动Rigidbody2D 只负责碰撞和受击击退不做位置主驱动。攻击判定不要再 new 数组直接复用固定大小的数组或用NonAlloc版本的查询。如果项目要打微信小游戏建议代码里用宏定义切分物理逻辑例如仅在编辑器或非小游戏平台启用高级碰撞特性小游戏平台关闭多余物理模拟。6. 用一局帧记录回放验证判定和输入对不对齐手感做到最后肉眼已经看不出问题就需要把输入和判定录下来回放。我的习惯是内置一个极简的输入录制器按时间戳记录每次按键然后以固定 60 帧步长回放对比两次运行里的伤害事件发生在第几帧。如果输入相同、角色状态跳转帧不同就说明某处存在时序抖动。录制端把按键换算成帧序号按 60 帧每秒记录Time.unscaledTime * 60取整。回放端用这个帧号驱动输入注入。using System.Collections.Generic; using UnityEngine; public class InputTraceRecorder : MonoBehaviour { private readonly ListKeyValuePairint, string _trace new ListKeyValuePairint, string(); private void Update() { if (Input.GetButtonDown(LightAttack)) _trace.Add(new KeyValuePairint, string(FrameIndex(), LightAttack)); } public static int FrameIndex() Mathf.RoundToInt(Time.unscaledTime * 60f); }回放时把Input.GetButtonDown替换成InputReplaySource.PollFrame(FrameIndex())每一帧从录制数据中取出该帧的输入再走一遍完整战斗逻辑。验证目标只有一个两次回放里同一招式的伤害事件、命中停顿、击退位移都要发生在同一帧。只要有一次偏移超过 1 帧就回到输入或物理步里去找异步来源最典型的元凶就是 FixedUpdate 里的物理采样和 Time.deltaTime 驱动的动画。这套回放不仅用来调手感也是后续加攻击指示器、技能特效、对象池复用后做回归验证的工具。我在每一轮大改之后都会先跑一遍回放再让策划去测手感。眼睛会骗人帧记录不会希望帮到你。本文还有配套的精品资源点击获取
返回列表