Unity横版格斗游戏源码解析:战斗系统、关卡设计与广告接入
发布时间:2026/9/15 15:40:34 作者:尧图编辑部 阅读量:1,286

简介面向Unity开发者的火柴人横版格斗游戏完整项目源码基于Unity 2018.4.18f1及以上版本构建涵盖战斗生存玩法、技能解锁、物品升级、影子Boss挑战及黑市交易等游戏系统。项目共2000个文件以C#脚本、Unity资源文件.asset/.mat/.prefab/.shader、图片音效.png/.wav/.ogg为主同时包含Android构建所需的广告SDK.aar/.jar与文档说明压缩包约581MB。已有494人学习或下载适合希望研究横版格斗游戏架构、技能战斗设计或Unity资源管理的开发者参考。通过源码可快速了解超级赛亚人变身、忍者龙卷风等技能效果、双星球八Boss的关卡递进、百级难度曲线以及商店经济系统的具体实现借助现成预制体与素材搭建自己的动作游戏原型。1. 横版格斗源码的工程化拆解Stickman Warriors 里的Unity战斗原型拿到一份完整可跑的游戏源码直接打开场景点Play是不够的。Stickman Warriors - Super Dragon 这个火柴人横版格斗项目源码工程用 Unity 2018.4.18f1 组织包含战斗系统、技能升级、100 多个关卡和双子星球Boss流程这正是 Unity 横版动作游戏最常见的功能骨架。对刚入行的开发者它是理解角色状态机、动画控制和技能层级的活教材对工作三五年以上的工程师更多看点是它的依赖治理和商业化SDK接入方式。material 里能直接看到 play-services-ads 19.5.0 那一组 .aar 的引用方式这说明项目的广告模块是模块化挂载的而不是把代码写死在场景物体上。后面几章我会沿着战斗核心、关卡数值和 Android 构建这条线把这个工程真正拆开。2. 战斗核心拆解Animator状态机、连击判定的C#实现逻辑2.1 火柴人角色的Animator层级与状态参数设计横版格斗手感好坏一半取决于 Animator 的状态切换延迟。Stickman Warriors 的角色控制器不是直接把攻击动画放进一个巨大的状态机里而是用基础层处理移动、跳跃、落地用另一个独立层处理攻击和技能。这样设计的好处是跑步和出拳互不打断状态机之间通过Base Layer和Action Layer的权重叠加实现。打开角色预制体查看 Animator Controller你会发现核心参数集中在几个int和float上参数名类型作用区间说明Stateint0-50Idle, 1Run, 2Jump, 3Attack1, 4Attack2, 5HitSpeedfloat0.0-6.0控制奔跑动画混合isGroundedbooltrue/false控制地面与空中状态切换OnAttackTrigger一次性触发连击段数前进值得注意的是连击的实现方式。一般新手会把三段攻击做成三个独立触发参数而这个项目里是用comboStep这个代码字段记录当前是第几段每次点击攻击按钮时读取它然后通过animator.SetInteger(State, 3 comboStep)切换动画。// ComboSystem.cs 片段管理连击段数的推进与重置 public int comboStep 0; private float comboTimer 0f; public float comboTimerMax 1.2f; // 超过1.2秒没按攻击则重置段数 void Update() { if (comboStep 0) { comboTimer Time.deltaTime; if (comboTimer comboTimerMax) { comboStep 0; comboTimer 0f; } } } public void OnAttackPressed() { if (!isGrounded) return; // 空中不允许起手攻击 comboTimer 0f; if (comboStep 3) comboStep; else comboStep 1; animator.SetInteger(State, 2 comboStep); // State3,4,5 对应三段攻击 animator.SetTrigger(OnAttack); }这段代码需要配合动画事件的时机来理解第一段攻击动画播放到 0.1 秒时动画事件回调EnableHitBox()开启攻击碰撞体到 0.25 秒时DisableHitBox()关闭这样每次攻击的有效判定窗口只有 150 毫秒左右。格斗游戏中这个窗口控制直接决定手感爽快还是绵软。2.2 攻击判定采用触发器而非射线检测的原因这个项目里敌人受击判断用的是子物体AttackRange挂载BoxCollider2D并勾选IsTrigger玩家攻击时通过SetActive(true)开启动画事件再关闭。为什么不用Physics2D.Raycast检测因为火柴人的攻击范围是一个扇形区域一次横扫要覆盖多个敌人射线需要逐根发射且容易漏判。触发器是进入即检测只要敌方的Collider2D在触发范围内就能通过OnTriggerEnter2D捕获到。这是横版格斗最常见的受击方案。// AttackBox.cs 片段攻击触发器检测并应用伤害 void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag(Enemy)) { HealthSystem enemyHealth other.GetComponentHealthSystem(); if (enemyHealth ! null) { float finalDamage ownerAttackPower * Random.Range(0.9f, 1.1f); bool isCritical Random.value 0.15f; if (isCritical) finalDamage * 2f; enemyHealth.TakeDamage(finalDamage); // 产生击退给敌人刚体一个方向向量 Vector2 knockbackDir (other.transform.position - transform.position).normalized; other.GetComponentRigidbody2D().AddForce(knockbackDir * 350f); } } }有一个细节新手经常忽略攻击触发器的Physics2D碰撞矩阵如果没配好玩家攻击会同时打中自己和友方单位。这个工程在 Project Settings 的 Physics2D 设置里把 Player 和 Enemy 分在不同 Layer碰撞矩阵只让 Player 攻击层与 Enemy 层产生交互。拿到源码后第一件事就是去检查 Layer 的命名和碰撞矩阵配置否则你改动之后会发现攻击判定全乱。2.3 空中连击与技能释放的通用模式超级赛亚人变身状态解析这个源码里最亮眼的技能系统是超级赛亚人愤怒转换它的实现本质是一个带持续时间的全局 Buff 叠加变身期间攻击力 ×2、移动速度 30%、攻击动画替换为更快的动作剪裁。这个设计在代码上可以抽象为角色身上的SkillController持有当前 Buff 列表每次技能释放时往列表里 Push 一条带持续时间的记录在Update里遍历倒计时。// SkillController.cs 片段Buff 系统的轮询与移除 public class ActiveBuff { public float attackMultiplier; public float speedMultiplier; public float remainTime; public GameObject visualEffect; } void Update() { for (int i activeBuffs.Count - 1; i 0; i--) { activeBuffs[i].remainTime - Time.deltaTime; if (activeBuffs[i].remainTime 0f) { RemoveBuff(activeBuffs[i]); activeBuffs.RemoveAt(i); } } } public void ActivateSuperSaiyan(float duration) { ActiveBuff newBuff new ActiveBuff(); newBuff.attackMultiplier 2f; newBuff.speedMultiplier 1.3f; newBuff.remainTime duration; newBuff.visualEffect Instantiate(transform.Find(SaiyanAura).gameObject); activeBuffs.Add(newBuff); }这种 Buff 模式的扩展性在后续开发中很强想加冰冻、中毒、加速直接往ActiveBuff里加字段就行不用为每个技能写一个单独的 Update。龙卷风跳跃这个动作本质上是在跳跃状态里叠加了一个水平方向的加速度和旋转动画实现位置在PlayerController的跳跃分支里如果你打算复刻一个类似的空中技能从这套 Buff 体系延展会是最高效的路径。3. 关卡难度曲线与敌人AI从100个关卡的生成逻辑看数值平衡3.1 关卡自动变长的实现机制程序化难度控制而非手摆100个场景大多数新手写多关卡项目就是复制十几个场景然后手动摆放敌人。Stickman Warriors 的场景数量非常大100 关以上但它并不是在 Build Settings 里挂 100 个独立场景而是通过关卡数据表动态生成敌人数量和位置。场景只有一个BattleArena它的LevelManager会根据当前是第几关来决定要实例化多少敌人、敌人血量乘区是多少。这些数据挂在Resources/LevelConfig/LevelData_XX.asset文件里每个 ScriptableObject 记录敌人种类数组、出现概率和数量。这样做关卡更新的成本极低美术和策划不需要碰场景改数值文件就能配出一关新的。关卡区间敌人数量范围血量乘区敌人 AI 类型1-102-41.0-1.2基础近战兵11-303-61.2-1.8近战远程混合31-604-81.5-2.5引入影武者带闪现AI61-805-92.0-3.0高频率格挡AI81-1006-102.5-4.0全类型Boss前置兵LevelManager的生成流程不是全部一次Instantiate它使用按波次分批产出的方式第一波只生成 30% 的敌人玩家清掉一部分后再触发下一波。这个机制配合ResourceManager的对象池避免了手机上同屏过多物理体导致的卡顿。// LevelManager.cs 片段根据关卡数读取ScriptableObject并生成敌人 public void InitLevel(int levelIndex) { LevelConfig config Resources.LoadLevelConfig(LevelConfig/LevelData_ levelIndex); currentWave 0; totalWaves config.waveConfigs.Count; foreach (WaveConfig wave in config.waveConfigs) { StartCoroutine(SpawnWave(wave, wave.spawnDelay)); } } IEnumerator SpawnWave(WaveConfig wave, float delay) { yield return new WaitForSeconds(delay); for (int i 0; i wave.enemyCount; i) { GameObject enemy objectPool.GetEnemy(wave.enemyType); enemy.transform.position spawnPoints[Random.Range(0, spawnPoints.Length)].position; enemy.GetComponentEnemyController().SetDifficulty(config.difficultyMultiplier); yield return new WaitForSeconds(wave.spawnInterval); } }3.2 影武者Boss的AI状态机闪现、格挡与追击这个项目里有 8 个最终恶魔 Boss它们不是单纯的高血量高伤害而是各自有独立的 AI 行为逻辑。拿第一个星球关底的影武者来说它的行为树是玩家距离大于 6 米时进入Chase状态距离 2-6 米时随机切换Attack和Block血量低于 30% 后解锁ShadowDash闪现到玩家背后技能。追踪类敌人的 AI 在源码里以状态切换的方式实现在EnemyController的Update里读者最容易犯错的地方是在Chase状态中直接写死朝玩家方向移动而没有处理障碍物遮挡。这个源码给的答案很直接场地是定死的矩形闪避靠玩家二段跳敌人不做寻路只做朝向追踪。这是性能和实现成本的取舍做原型演示完全够用。// EnemyController.cs 片段影武者按血量百分比切换行为 void Update() { float distance Vector2.Distance(transform.position, player.position); if (currentHp / maxHp 0.3f skillReady) { state EnemyState.ShadowDash; StartCoroutine(ShadowDashCooldown(3f)); } else if (distance 6f) { state EnemyState.Chase; transform.position Vector2.MoveTowards( transform.position, player.position, moveSpeed * Time.deltaTime ); } else if (distance 2f) { state Random.value 0.7f ? EnemyState.Block : EnemyState.Attack; } else { state EnemyState.Attack; } }当配置 100 个关卡的时候还需要做一件事将难度乘区与敌人的 AI 行为解耦。血量乘区只在SetDifficulty里修改maxHp和moveSpeed不改变state切换的条件这样保证敌人动作节奏的一致性和可预见性。否则你会在第 40 关看到一个移动速度飞快的 Boss 完全贴脸无前摇攻击玩家没有操作窗口。3.3 魔法材料收集与黑市交易物品栏和掉落概率的工程方案收集魔法材料黑市交易稀有物品这个功能点在源码里的实现没有用第三方 Inventory 插件而是自己用Dictionarystring, int配合PlayerPrefs做持久化。玩家击杀敌人后从掉落表中按权重随机生成物品拾取后走ItemPickup.OnTriggerEnter2D进入背包。掉落表的权重配置值得学习它是一个ListDropEntry每个条目里存ItemID和Weight总权重求和后Random.Range(0, totalWeight)决定掉什么。这种方案比连续 if-else 判断概率要优雅得多也便于策划后续扩展。// LootSystem.cs 片段按权重随机选择掉落物 public void RollDrop(Vector3 dropPosition) { float roll Random.Range(0f, totalWeight); float cumulative 0f; foreach (DropEntry entry in dropTable) { cumulative entry.weight; if (roll cumulative) { GameObject drop Instantiate( entry.itemPrefab, dropPosition Vector3.up * 0.5f, Quaternion.identity ); break; } } }黑市购买商品的界面是一个独立的CanvasGroup淡入淡出弹窗核心逻辑不复杂但里面有DataBinding的思路商品价格文本通过OnItemSelected事件刷新而不是每帧轮询。要改成金币购买还是内购只需替换掉PayButton.onClick里的方法引用即可。4. 商业化模块接入Google Play 广告 SDK 与 Unity 项目依赖管理实战4.1 源码里的Android AAR依赖是怎么管理的Stickman Warriors 的 Plugins/Android 目录下有一组 .aar 文件这就是广告 SDK 及相关支持库。Unity 2018.4 这个时期还没有开放 Unity Package Manager 的深度集成支持所以插件作者采用手动放置 .aar 并在AndroidManifest.xml里声明 Activity 的方案来接入 Google Play 广告。这个项目里出现的依赖关系很典型play-services-ads-19.5.0.aar是广告主库它依赖play-services-ads-lite、play-services-gass和play-services-basementbasement是 Google Play 服务的基础能力模块提供通用安全和网络能力measurement-base则服务于广告效果分析。这些 AAR 文件的版本匹配至关重要如果混搭不同版本的 play-services 系列Gradle 构建时会出Dependency conflict或者运行时直接ClassNotFoundException。AAR 文件作用版本兼容备注play-services-ads-19.5.0AdMob 广告主库依赖 basementplay-services-ads-lite-19.5.0精简版广告运行库版本必须与 ads 一致play-services-basement-17.3.0基础服务库低版本可能导致崩溃androidx.core-1.2.0AndroidX 核心库与 AndroidX 迁移有关work-runtime-2.1.0后台任务调度广告预加载常用Unity 2018.4 工程里如果同时存在旧版support-library和androidx的 AAR会出现duplicate class的编译错误。这个源码的治理方式是全部统一走 AndroidX并且在 Assets 目录里建了一个link.xml文件防止代码裁剪把反射调用的类给裁掉。这个细节很值得注意如果你在做自己的商业项目在接入 SDK 时遇到ClassNotFoundException先去检查 link.xml 的type preserveall配置比盲目更新 SDK 版本有效得多。4.2 广告初始化和插屏/激励视频的代码位置工程里广告模块封装在AdManager.cs挂在入口场景的GameManager物体上。初始化函数在游戏启动后第一个场景切换时调用原因是避免启动即拉广告导致首包加载变慢。插屏广告的加载时机放在玩家死亡返回主菜单时而激励视频放在黑市购买稀有材料时这个设计直接影响广告展示率和玩家停留时长。// AdManager.cs 片段广告初始化与激励视频回调重点看回调线程安全 void Start() { MobileAds.Initialize(initStatus { }); rewardedAd new RewardedAd(rewardedAdUnitId); AdRequest request new AdRequest.Builder().Build(); rewardedAd.LoadAd(request); rewardedAd.OnUserEarnedReward (sender, args) { // 回到主线程发放奖励这个回调在Android Java层触发 UnityMainThreadDispatcher.Instance.Enqueue(() { GameManager.Instance.AddCurrency(500); UIManager.Instance.ShowToast(奖励已到账); }); }; }注意这里用了UnityMainThreadDispatcher把回调操作排队到主线程这个工具类是自己封装的其原理是在Update里消费一个ConcurrentQueueAction。如果不用这个机制而直接在 Java 回调里调用GameManager.Instance操作 UnityEngine API在 Android 上经常会报Unity requires the main thread的异常或者表现为偶发性的黑屏和闪退。4.3 从 com.google.android.gms 依赖冲突看 Unity 2018 构建的坑Unity 2018.4 的 Gradle 构建系统对 .aar 的解析顺序有历史包袱它在mainTemplate.gradle里会把Plugins/Android下的所有 .aar 都当成本地依赖导入如果同一个库在dependencies里也写了一遍远程依赖如com.google.android.gms:play-services-ads:19.5.0就会重复。这个源码的处理方式是AAR 文件放本地但build.gradle的dependencies里不写任何com.google.android.gms的远程引用避免重复。当你在新 Unity 版本里打开这个源码时大概率会遇到Manifest merger failed错误。这时候的排查路径一般是先看报错里的冲突库名称然后在 Assets/Plugins/Android/AndroidManifest.xml 里检查tools:replace属性配置最后才考虑删掉某个 AAR 换远程依赖。如果直接删掉本地 AAR 改用远程依赖要保证 Unity 生成的gradleTemplate.properties里启用了 AndroidXandroid.useAndroidXtrue。// mainTemplate.gradle 关键片段以Unity 2018.4生成版为参考 allprojects { repositories { google() jcenter() maven { url https://artifacts.amazonaws.com/maven } // Amazon AppStore SDK } } dependencies { implementation fileTree(dir: libs, include: [*.jar]) implementation com.android.support:multidex:1.0.3 // 注意这里不重复引用 play-services-ads }AmazonAppStore.aar 的存在说明这个工程有多渠道打包的需求SamsungApps.aar 则是三星应用商店的内购SDK。不出意外的话源码某个IAPManager里应该有按渠道切换初始化逻辑的宏定义比如#if AMAZON、#if SAMSUNG。想适配国内安卓市场的时候最省力的做法是把这个渠道分支整体摘除然后接入自己独有的支付 SDK。5. 格斗手感调优技巧动画事件与固定时间步长的协同5.1 攻击动画的事件帧设置决定打击感拿到这个源码实际运行你会发现打击感的核心不在伤害数字而在动画事件的时序配合。第一段攻击动画在第 5 帧开启攻击判定、第 12 帧关闭判定同时开启HitStop受击停顿60 毫秒第二段攻击在第 3 帧就开启判定明显更快第三段重击则延迟到第 10 帧开启给玩家一个蓄力视觉预兆。这个差异是通过在Attack_01.anim、Attack_02.anim、Attack_03.anim三个动画剪辑的不同关键帧挂载AnimationEvent实现的。// AnimationEventReceiver.cs 片段挂在角色根物体上的动画事件接收器 public void EnableHitBox() { attackBox.SetActive(true); } public void DisableHitBox() { attackBox.SetActive(false); } public void SpawnSlashEffect() { GameObject slash objectPool.GetFromPool(SlashEffect); slash.transform.position attackBox.transform.position; slash.transform.rotation transform.rotation; slash.SetActive(true); } public void OnAttackEnd() { comboStep 0; }动画事件回调的函数名必须和动画窗口里输入的名字严格一致否则运行时代码会静默失败而不报错。排查这类问题的方法是打开 Window - Animation - Events检查每个关键帧右下的函数下拉框是否匹配到MonoBehaviour方法名匹配成功会显示绿色图标失败是黄色警告。5.2 固定时间步长Fixed Timestep对格斗判定的影响Project Settings - Time 里的Fixed Timestep默认是 0.02 秒即 50Hz。攻击判定如果放在Update里在不同的帧率下判定的持续时间不一致60帧运行时每帧 16ms30帧运行时每帧 33msOnTriggerEnter2D的碰撞检测在低帧率下会漏掉快速攻击的碰撞事件。这个源码把Rigidbody2D的移动放在FixedUpdate把攻击判定开启放在动画事件里整套逻辑与物理引擎的频率保持一致。调试格斗手感时建议把Time.timeScale临时改成 0.2 慢放观察攻击盒子出现和消失的时机与动画的匹配度。只改动画事件时间而不改攻击盒子的Offset会导致视觉上刀光还没碰到敌人但伤害已经打出的情况这在格斗游戏里是最致命的失真。对比来看这个源码的攻击盒子位置放在角色手臂骨骼的子节点上跟随骨骼旋转比固定在角色根物体前方要准确得多。5.3 击退和受击闪白的统一处理入口HealthSystem.TakeDamage这个函数里除了currentHp - damage还承担了帧闪白、击退方向、伤害数字弹出的汇总功能。源码里的敌人受击闪白是通过SpriteRenderer.material.SetColor(_Color, Color.red)实现的在WaitForSeconds(0.05f)后恢复。注意这里操作的是material而非sharedMaterial前者会产生实例材质在一个场景里敌人较多时会导致 draw call 上升。优化的办法是改用SpriteRenderer.flip或者 Shader Graph 里做 Flash 节点避免实例化材质。// HealthSystem.cs 片段受击闪白与击退 public void TakeDamage(float damage) { currentHp - damage; StartCoroutine(FlashRed()); // 击退逻辑用统一的接口 knockbackHandler.AddImpulse(-(transform.position - attackerPos).normalized * knockbackForce); if (currentHp 0f) { Die(); } } IEnumerator FlashRed() { sr.material.SetColor(_Color, Color.red); yield return new WaitForSeconds(0.05f); sr.material.SetColor(_Color, Color.white); }一个可以继续往深里做的优化点把 Flash 的协程替换为带时间戳的MaterialPropertyBlock这样在 100 个敌人同时受击时不会因为协程过载导致闪白延迟。同样的优化逻辑可以扩展到玩家技能的特效回合SaiyanAura粒子如果持续挂载而不延时回收多技能叠加时 ParticleSystem 的 CPU 开销会非常明显建议在技能结束后主动停掉 Emission 并延时回收到对象池而不是直接Destroy。本文还有配套的精品资源点击获取