
简介这是一份围绕Unity 2D街机游戏开发、以“三国战纪”题材为示例的工程资源适合刚接触Unity或想系统梳理2D游戏制作流程的开发者。资源覆盖场景搭建、精灵渲染、动画状态机、2D物理碰撞、C#游戏逻辑、音频与UI等核心环节能作为从零搭建横版动作游戏的参考骨架。压缩包共443个文件、大小约4.5MB主要包含90个C#脚本、24个动画片段、22个资产配置、30张PNG素材、预制体与控制器等脚本与动画资源配合清晰便于对照学习角色移动、攻击、受击、跳跃等常见状态切换。目前已有336人学习下载。对照工程可以直观看到MonoBehaviour生命周期、OnCollisionEnter事件驱动、Animator状态机以及Draw Call优化等知识点在真实项目中的落地方式同时目录按功能模块组织适合按需查阅和二次修改对想要快速上手2D动作类游戏开发的人很有参考价值。1. Unity开发2d街机游戏《三国战纪》这类清版动作难的不是引擎是第一拳的“手感”想用Unity开发2d街机游戏《三国战纪》这类清版动作beat em up最先劝退人的不是引擎而是自己的误判。场景、敌人、必杀技拼时间都能拼出来真正让你日夜翻车的是打击手感——出招判定帧差一帧、受击动画慢半拍、镜头跟着角色乱抖玩家第一拳打出去就觉得不对。这篇笔记面向动手做过Unity小项目、想把街机手感做出来的人按一条能走通的主线讲状态机控制、判定帧、镜头与UI、发布避坑最后落在一套可复现的手感参数上。2. 先搭“能走能打”的基座场景碰撞与角色状态机2.1 用Tilemap铺横版场景碰撞面这样合并不卡三国战纪这类游戏场景大多数是静态横版卷轴地面用Tilemap是Unity里最省事的方案。创建TilemapGameObject → 2D Object → Tilemap后挂上Tilemap Collider 2D和Rigidbody2D类型Static。这里有个关键如果每个Tile都独立碰撞几百个碰撞体挤在一起性能会很差尤其在低端Android机上能明显感到卡顿。常见做法是再挂一个Composite Collider 2D并打开Tilemap Collider 2D的“Used By Composite”让Unity把所有Tile的碰撞面自动合并成几个多边形。地面上那些凹凸边缘会自动变成一条连续折线调试时在Scene窗口看一眼别让整个场景布满密密麻麻的绿色线框。舞台上的桌案、木栏这类可破坏物件不建议放Tilemap里因为它们要做血量、掉落和销毁动画。我的习惯是做一个DestroyableProp预制体内部放Collider和Sprite砸碎时替换破碎Sprite再加一个粒子。具体组件参数如下组件设置说明Tilemap Collider 2DUsed By Composite 勾选与其他Tile合并碰撞Composite Collider 2DGeometry Type Polygons合并后的多边形容积Tilemap RendererMode Individual保留Tile单独渲染控制如果需要程序化铺地面可以写一个编辑器扩展小工具来做场景质检尤其是从别人手里接半成品项目时你根本不知道哪块地面其实是空缺的// Editor/LevelChecker.cs using UnityEngine; using UnityEngine.Tilemaps; public class LevelChecker : MonoBehaviour { Tilemap map; [ContextMenu(Print Missing Cells)] void PrintMissing() { map GetComponentTilemap(); for (int x -50; x 50; x) { var tile map.GetTile(new Vector3Int(x, 0, 0)); if (tile null) Debug.Log($ground missing at {x}); } } }这段代码就是典型的Unity扩展类编辑器脚本挂在Grid上后在Inspector右键调用PrintMissing把y0这一层里没铺到Tile的列全部打印出来。参数说明x范围写的-50到50按关卡长度改tile为null说明该格是空的。它不影响游戏逻辑但能节省大量回头补地面碰撞的时间。2.2 Animator状态机与移动受击硬直必须能打断当前动画动作游戏的角色动画管理千万不要在Update里用Play(Attack)满天飞。Animator Controller才是正确归属——状态设为Idle、Walk、Attack、Skill、Hit、Dead切换条件用bool和Trigger两类就够isMoving管Idle/Walk互切attackTrigger管攻击hitTrigger管受击。受击硬直最关键的设定是Hit状态要能无条件打断Attack/Skill。在Animator里实现这个要把“攻击状态→受击状态”这条Transition的Has Exit Time取消勾选Transition Duration设0并把Interruption Source设成Next State或Current State。很多新手默认不点这个结果受击时要等出拳动画播完才硬直打起来像两个慢动作演员。我一般这么控制移动和攻击的入口// PlayerAction.cs using UnityEngine; [RequireComponent(typeof(Animator))] public class PlayerAction : MonoBehaviour { Animator anim; float speed 4f; static readonly int IsMoving Animator.StringToHash(isMoving); static readonly int Attack Animator.StringToHash(attackTrigger); void Awake() anim GetComponentAnimator(); void Update() { float h Input.GetAxisRaw(Horizontal); transform.position Vector3.right * (h * speed * Time.deltaTime); anim.SetBool(IsMoving, Mathf.Abs(h) 0.1f); if (Input.GetButtonDown(Fire1)) anim.SetTrigger(Attack); } }参数说明speed4是基础移速老清版动作每秒移动2~5米跑动速度会到6以上。攻击不写在Update里而是交给Animator状态机动画播完自动回Idle。如果你发现按攻击没反应先看Animator里trigger参数名是否一致大小写和拼写差一个字母状态机就会静默忽略。关于角色外观三国战纪那种赛璐璐立绘后期可以用NPR/卡渲shader做轮廓和描边但建议先把动作和判定跑通再碰shader否则你会在材质上耗掉大把时间。2.3 输入层统一“街机键位”和触屏映射输入这块Unity有两套老Input Manager和新Input System Package。如果你在Unity 2018入门老写法GetAxisRaw/GetButtonDown还能用但新项目我建议直接用Input System原因就一个一套InputAction资产可以把键盘、手柄、触屏事件统一映射发布移动端时不用重写业务代码。三国战纪这种游戏出招得记方向序列比如下前攻击如果输入层不统一后面接必杀指令会写在好几个不同文件里改一次键位改到吐血。下面是最小可跑的输入封装放在任意MonoBehaviour里// InputReader.cs using UnityEngine; using UnityEngine.InputSystem; public class InputReader : MonoBehaviour { InputAction move, attack; void Awake() { move new InputAction(move, binding: Keyboard/leftArrow|Keyboard/rightArrow); attack new InputAction(attack, binding: Keyboard/j); move.Enable(); attack.Enable(); } public Vector2 Move move.ReadValueVector2(); public bool AttackPressed attack.WasPressedThisFrame(); }逻辑说明bind字符串里用“|”把多个键绑在同一个action上实现方向键和A/D两套按键同时可用。WasPressedThisFrame只在一瞬间返回true适合连招输入不要用IsPressed那会导致攻击判定一直触发。参数说明要支持手柄把binding改成“ /dpad/right”这类路径把Attack绑到“ /buttonSouth”Unity会自动处理多端映射。调用处变成if (reader.AttackPressed) anim.SetTrigger(Attack);这样游戏逻辑完全不知道按钮来自键盘还是触屏后续做必杀出招表也只需要在InputReader里加“方向序列检测”方法。注意Input System和旧输入可以同时开但别在项目里混用否则会收到一堆重复执行警告。3. 打击判定与连击手感把“打到了”变成“打爽了”3.1 判定帧用动画事件触发别在Update里挥手新手做攻击最容易写成这样播放攻击动画的同时在Update里一直检测OverlapBox。这样确实“能打到人”但问题是动画第5帧才出拳第3帧就判定命中视觉和打击直接错位。老清版游戏的判定帧是按帧设计的比如拳伸到最长的位置就那一两帧给伤害。Unity里的做法是给Animation Clip加Event在出拳那帧调用回调。// AttackController.cs using UnityEngine; public class AttackController : MonoBehaviour { [SerializeField] float range 0.55f; [SerializeField] float forwardOffset 0.25f; [SerializeField] LayerMask enemyMask; void OnHitFrame() // 由动画事件调用 { Vector2 origin transform.position Vector3.right * forwardOffset; Collider2D[] hits Physics2D.OverlapCircleAll(origin, range, enemyMask); foreach (Collider2D c in hits) { if (c.TryGetComponentIDamageable(out var target)) target.TakeDamage(15); } } }逻辑说明OnHitFrame只在动画事件指定的那一帧执行所以拳头伸出去才出伤害。origin取角色前方0.25米半径0.55米范围宁可小一点玩家能看清被打得冤不冤。LayerMask过滤掉玩家自己和场景碰撞防止自己打到自己。参数说明range和forwardOffset这两个值要在Scene窗口里调调用OnDrawGizmos画出攻击范围否则对着数字猜半径纯属玄学void OnDrawGizmosSelected() { Gizmos.color Color.yellow; Vector2 origin transform.position Vector3.right * forwardOffset; Gizmos.DrawWireSphere(origin, range); }画完Gizmos你会很直观地看出判定圈有多大。在Animation窗口里选中Animation Clip把播放头拖到出拳顶点那一帧右键Add Animation Event函数名写OnHitFrame。函数名必须和代码严格一致大小写错了不会报错只是你这一拳永远没伤害这是最常见的翻车原因之一。3.2 受击反馈停帧、闪白、飘字一个都不能少判定打到人只是“数学上打到了”玩家感到“打爽了”靠的是反馈。三个通用反馈按重要性排停帧hit stop、闪白、伤害飘字。停帧的做法是短暂把Time.timeScale调到接近0让动画和位移冻结几帧突出命中的“停顿感”。注意完全设成0会连UI动画也停所以很多项目用0.02到0.05。代码用协程// HitStop.cs using System.Collections; using UnityEngine; public class HitStop : MonoBehaviour { public void Stop(float seconds) { StopAllCoroutines(); StartCoroutine(DoStop(seconds)); } IEnumerator DoStop(float seconds) { Time.timeScale 0.03f; yield return new WaitForSecondsRealtime(seconds); Time.timeScale 1f; } }逻辑说明WaitForSecondsRealtime走真实时间不受timeScale影响所以即使timeScale接近0协程也能醒过来恢复时间。参数说明轻击stop0.03秒重击0.06到0.08秒必杀可以到0.12秒但别超过0.15超过就变成幻灯片。还有另一种偏硬核的局部暂停方案但清版动作里全场景冻住本来就是老街机传统timeScale方案够用。闪白最简单被击中的敌人SpriteRenderer上加一个WhiteFlash脚本把材质临时换成纯白或用协程在2~3帧内把颜色淡回原样。飘字用TextMeshPro SpriteAsset做图文混排把伤害数字和“会心”图标排在同一段文本里这是伤害数字里最不显脏的做法void SpawnPopup(Vector3 pos, int dmg, bool crit) { var text Instantiate(popupPrefab, pos, Quaternion.identity, canvasRoot); text.text crit ? $sprite namecrit color#FFD34E-{dmg}/color : $-{dmg}; }这是Unity图文混排最直接的用法sprite namecrit引用SpriteAsset里名为crit的图片和文字同排。把会心图标和伤害数字拼在一起做出来就是一套很自然的UI框架风格。飘字Prefab里再挂一个FloatingText脚本Update里向上移动并改alpha。敌人死亡和掉落物淡出也能复用同一个渐变逻辑// Fader.cs using System.Collections; using UnityEngine; public class Fader : MonoBehaviour { SpriteRenderer spr; public void FadeOut(float duration) StartCoroutine(Fade(duration)); IEnumerator Fade(float duration) { float elapsed 0; spr GetComponentSpriteRenderer(); while (elapsed duration) { spr.color new Color(1, 1, 1, 1 - elapsed / duration); elapsed Time.deltaTime; yield return null; } Destroy(gameObject); } }参数说明duration取0.4到0.6秒太快像被蒸发太慢挡画面。这段正好也是“脚本控制物体逐渐消失”的通用写法不需要每个物体各写一套。3.3 连击数与数值增长让“把敌人连到死”变得有回报三国战纪这类游戏单个敌人打完就结束了真正上瘾的是连击。这里的数值增长很简单连击计数决定倍率倍率曲线不该是直线。我的经验是5连击前都是1倍10连击1.5倍20连击2倍之后开始衰减防止玩家一套连招吃到底。实现时只要一个计数器加一个超时窗口// ComboCounter.cs using UnityEngine; public class ComboCounter : MonoBehaviour { public int combo; float lastHitTime; public float window 0.5f; public void RegisterHit() { if (Time.time - lastHitTime window) combo 1; else combo; lastHitTime Time.time; } public int Rank() combo switch { 15 20, 8 15, 4 10, _ 1, }; }参数说明window等于0.5指两次攻击间隔超过0.5秒后combo清零。Rank按combo返回倍率你也可以用AnimationCurve把倍率曲线做成Editor可调手感直接在编辑器里调不用改代码。注意连击窗口没法一刀切短兵器0.35到0.5枪这类长兵器0.6到0.7因为长兵器动作本身慢。做出来之后让两个不同手感的人各玩10分钟再定。4. 卷轴镜头与场景切换摄像机决定游戏节奏4.1 带死区的跟随镜头别跟着角色的呼吸抖Unity摄像机跟随最常见的是烂大街写法transform.position target.position offset;把角色放在画面正中央一走路镜头就跟着平移微小的抖动全被放大这在2D街机里很不好看。老街机的横版卷轴镜头是有死区的角色在画面中间一块区域里移动时镜头不动碰到死区边界才跟着推。按这个思路写CameraFollow// CameraFollow.cs using UnityEngine; public class CameraFollow : MonoBehaviour { [SerializeField] Transform target; [SerializeField] Rect deadZone new Rect(-2f, -1f, 4f, 2f); [SerializeField] float lerpSpeed 6f; void LateUpdate() { if (!target) return; Vector3 p transform.position; float dx target.position.x - p.x; float dy target.position.y - p.y; if (dx deadZone.xMax || dx deadZone.xMin) p.x Mathf.Lerp(p.x, target.position.x, lerpSpeed * Time.deltaTime); if (dy deadZone.yMax || dy deadZone.yMin) p.y Mathf.Lerp(p.y, target.position.y, lerpSpeed * Time.deltaTime); transform.position p; } }逻辑说明只有当角色横向位移超过deadZone右边界或左边界时才跟随否则镜头停在原地。纵向同理。参数说明deadZone的x范围设成正负2米对应角色在屏幕中央约1/3宽度内活动时镜头不动lerpSpeed等于6决定镜头追上角色的速度太大会失去滞后感太小玩家会觉得镜头拖沓。场景卷轴的边界也要用Clamp把最终位置夹住防止镜头飞出关卡float clampedX Mathf.Clamp(p.x, leftBound, rightBound); float clampedY Mathf.Clamp(p.y, bottomBound, topBound); transform.position new Vector3(clampedX, clampedY, p.z);4.2 震屏与过场Boss出场要的是运镜不是乱抖受击震屏是打击感的第二大来源。震屏别用单帧随机位移直接用噪声生成抖动// CameraShake.cs using System.Collections; using UnityEngine; public class CameraShake : MonoBehaviour { [SerializeField] float amplitude 0.08f; [SerializeField] float duration 0.18f; public void Shake(float amp, float dur) { amplitude amp; duration dur; StopAllCoroutines(); StartCoroutine(DoShake()); } IEnumerator DoShake() { float t 0; Vector3 origin transform.localPosition; while (t duration) { Vector3 noise new Vector3( Mathf.PerlinNoise(Time.time * 50f, 0f), Mathf.PerlinNoise(0f, Time.time * 50f), 0f) * 2 - Vector3.one; transform.localPosition origin noise * amplitude; t Time.deltaTime; yield return null; } transform.localPosition origin; } }参数说明amplitude按事件给轻击0.02到0.04重击0.06Boss出场可以到0.1但时长不超过0.3秒。PerlinNoise生成连续噪声比Random.Range的抖动量更平滑不容易晕。注意因为震动会移动摄像机位置这个脚本要和CameraFollow分开挂DoShake结束后还原localPosition否则两个脚本互相抢坐标画面会发飘。过场动画则别用震屏解决。Boss出场的拍法是短时间内镜头从Boss背后推出、停在场景中央、玩家角色被镜头扫到、再回到死区跟随。实现上我会先停用CameraFollow再用一个DollyMover组件让目标点随时间改变最后在到达时重启CameraFollow。这段逻辑量不大但调试全靠慢动作预览把Inspector里Time Scale调到0.3看运镜路径是否顺滑。4.3 UI不随镜头放大缩小Overlay与第二相机的选择血条、连击数、必杀槽这些HUD要求“不随镜头动”。Unity里最简单直接的是把Canvas的Render Mode设为Screen Space - Overlay它不参与相机投影天然不随镜头放大缩小也不受震屏影响。“让物体不随镜头放大缩小”这个问题如果你只有一个Canvas或者血条挂在敌人头顶世界空间敌人离镜头远时血条会变小这是正常透视。不想让它变小就换成Overlay或Screen Space - Camera并单独给UI相机。具体配置经验Canvas Render Mode适用途坑Screen Space - OverlayHUD血条、连击数、商店界面无法与3D场景遮挡Screen Space - Camera需要受Canvas相机影响的UI要单独指定UI CameraWorld Space敌人头顶血条、场景告示牌透视缩放、要每帧Billboard连击飘字这类短暂特效我用Screen Space - Camera挂到第二相机好处是特效可以处于世界坐标周围又不随场景物体放大缩小。伤害飘字如果挂在世界空间里数字忽大忽小玩家根本读不出数值。所以我最终把伤害飘字放在Overlay Canvas用世界坐标转屏幕坐标定位Vector2 screenPoint cam.WorldToScreenPoint(worldPos); popup.transform.position screenPoint;这样飘字永远不缩放、不出界只保留“从哪来”的视觉关系。5. 避坑与发布注意紫红材质、动画位移、AAB与包体5.1 材质紫红Shader丢失或URP不匹配现象某些Sprite或特效打开是紫红色magenta完全不是原图。 原因Material的Shader找不到。最常见是项目从内置渲染管线切到URP或接手旧项目里用的Standard Shader不匹配当前管线其次是Shader改名或资源丢失。 解决先看Inspector里Material的Shader下拉框是否显示异常。如果是点下拉框换到Sprites/Default或Universal Render Pipeline/2D/Sprite-Lit紫红立即消失。如果全部资源都紫检查Project Settings里的URP Asset是否没分配给Graphics Settings。带Shader文件的项目确认Shader文件本身在Assets里且文件名没被改改完需要重新生成一次Asset Bundle。这条我吃过亏接手项目看到资源紫红第一反应是重装Unity其实只是URP Asset没指派两分钟解决。5.2 攻击动画播完人“漂移”Root Motion的坑现象角色攻击时明明没按方向位置却往前挪或归位看起来像滑冰。 原因Animator勾了Apply Root Motion攻击动画里的骨骼位移被当成了角色位移于是每次播放模型动画自带的那一两像素挪动被放大成几厘米的漂移。 解决在Animator组件上取消勾选Apply Root Motion位移全部交给代码。如果是拿来的模型动画资源里可能夹带位移帧打开Animation面板剔除位移关键帧。2D项目用Sprite动画基本不会主动开Root Motion但复制组件时容易带过来检查一遍就行。5.3 键盘输入失灵中文输入法抢键位现象游戏切后台再切回按方向键没反应或按一下J弹出了全角标点。 原因Windows中文输入法在部分Unity版本里会抢占键位特别是旧Input Manager输入法弹窗时把按键事件吃掉导致Input.GetKeyDown漏帧。 解决发布Windows包时在设置菜单提示玩家切换英文/美式键盘。如果用了Input System把受限键位重新绑定并保存为配置文件这是按键重映射的标准路径InputAction绑定完成后用ChangeBinding换绑再把绑定字符串存到本地下次启动自动读取。手柄摇杆没有这个问题所以街机框体和主机目标优先用手柄。5.4 高速冲刺直接穿过敌人碰撞检测模式的问题现象角色冲刺或向下重击速度很快时直接穿过敌人和墙壁。 原因Rigidbody2D的Collision Detection默认Discrete检测只在固定时间步内判断速度快到两步之间越过碰撞体就不会产生碰撞。 解决把角色的Rigidbody2D的Collision Detection改成Continuous。敌人是受击方检测在Hitbox里做不需要开Continuous。仍然穿透的把移动改成射线分段检测或在冲撞方向上多推一次OverlapBox。Continuous对性能有轻微消耗角色数量控制在几十个以内没压力不要给场景里几百个碎片都开Continuous。5.5 包体发布AAB、IL2CPP和数字字体现象构建出的APK大到和场景资源一样Google Play还要求上传AAB。 原因默认构建带了一堆用不到的Unity内置模块图片资源没有压缩字体把整个字形集都塞进包。 解决发布Google Play用Build Settings勾选Build App Bundle产出.aab文件平台会按设备下发拆分包。想本地安装用bundletool把aab转成APK再装这段建议写进CI避免每次手动点错。包体裁剪按这三步走Player Settings里Managed Stripping Level调Medium打开Strip Engine Code配合IL2CPP纹理压缩按目标平台设Android用ASTC删除用不到的模块比如3D物理没用到就取消勾选包体经常能减几十MB。字体要用支持中文的字体子集只留游戏里出现过的文字否则中文字形会把包体撑爆。代码混淆是发行前的事不是包体优化的解项目初期不用碰。打包脚本做成一个统一入口后面发布就没人手点错// Editor/AndroidBuilder.cs using UnityEditor; using UnityEditor.Build.Reporting; public static class AndroidBuilder { [MenuItem(Build/Android AAB)] public static void BuildAAB() { var options new BuildPlayerOptions { scenes new[] { Assets/Scenes/Stage1.unity }, target BuildTarget.Android, locationPathName build/Game.aab }; BuildReport report BuildPipeline.BuildPlayer(options); Debug.Log($build result: {report.summary.result}); } }参数说明scenes数组按你的场景顺序填BuildTarget.Android默认生成APK要AAB就在Build Settings里勾上Build App Bundle。需要压缩输出时给BuildPlayerOptions加BuildOptions.CompressWithLz4。这样发布流程变成一个菜单点击每次打出来的包都是一个配置。6. 调手感的最后一步把打击感参数收敛到ScriptableObject里手感不是玄学是参数。每次调整光改代码不记录项目放两周再打开调出来的东西全忘了。我从这类清版动作里学到的经验是把停帧时长、震屏幅度、闪白强度、连击窗口全部收敛到一个ScriptableObject配置里项目里放一个HitFeelConfig资产// HitFeelConfig.cs using UnityEngine; [CreateAssetMenu(menuName Game/HitFeel)] public class HitFeelConfig : ScriptableObject { public float lightHitStop 0.03f; public float heavyHitStop 0.07f; public float shakeAmplitude 0.04f; public float shakeDuration 0.15f; public float flashDuration 0.08f; public AnimationCurve comboMultiplier AnimationCurve.Linear(0, 1, 30, 2); }然后HitStop、CameraShake、WhiteFlash全从这份配置里读值。改的时候只动资产不碰代码同一个配置复制三份就是“轻快手感”“厚重手感”“Boss战手感”三个变体随时切换对比。验证方法我习惯分三步。第一步粗调把Time.timeScale调成0.3慢放逐帧看Hitbox是否和拳头重合这一步筛掉所有手位不搭的翻车。第二步盲测把项目给一个没玩过的人让他在没有提示的情况下打10分钟观察他第几次出招才能顺利连上普攻到重击。第三步量化开一个Debug OnGUI面板实时显示平均连击数和伤害输出数值上升说明手感在正反馈。击中反馈有个底线任何导致玩家在半秒内失去操控的操作只允许出现在受击方身上。玩家攻击后的停帧建议不超过0.06秒否则一旦连发玩家会觉得自己的手柄坏了。我做这类项目时还会在场景里放一只手感测试木桩无AI、站桩给你打所有攻击参数调整都对着木桩做等木桩数据满意了再放真敌人。这套流程跑下来手感就是一个可调的资产不是运气。希望这个方案能帮到你祝你把第一拳调出“有劲”的感觉。本文还有配套的精品资源点击获取