简介:面向Unity开发者的火柴人3格斗游戏完整C#项目,支持Unity 2018.2.20f1及以上版本。游戏设定为竞技场生存挑战,动作节奏快,视觉表现出色;开发者可直接研究其关卡设计、战斗逻辑和源代码组织,适合需要上手格斗类游戏项目的初中级开发者。压缩包共2004个文件,包含165个C#脚本、373个anim动画资源、96个prefab预制体、27个controller状态机、25个aar Android依赖库、24个wav音频,以及大量meta、psd、unity等辅助资源,包体约501.92MB,结构较为完整。目前已有243人学习/下载。项目内置200个关卡,集成AdMob插页式与奖励视频广告,支持IL2CPP构建和Android/iOS双端发布;源代码干净无错误,可直接用于学习格斗游戏核心机制、广告变现和跨平台发布流程,还可移植或扩展出新的火柴人战斗玩法。
1. 一份带200个关卡和Admob广告的Unity格斗源码,值得怎么读
拿到《God Of Stickman 3》这套源码包,我最先确认的并不是画面或手感,而是它的代码组织方式。Unity 2018.2.20f1及以上版本,带200个关卡、Admob插页式/奖励视频、IL2CPP双平台构建,同时代码还保持着“干净无错误”的状态——这在市面上的Unity游戏源码里属于少数。之前很多号称“完整游戏”的项目,要么把逻辑全挂在Update里,要么广告SDK的路径和PlayServices版本混乱到根本跑不起来。这个项目至少从Assets/Plugins/Android下那组aar和libEasyMobile.a来看,广告和渠道相关的工程化处理是下了功夫的。这篇文章会根据源码实际包含的内容,从前端战斗状态机讲到广告回调、再到IL2CPP构建的保留链,尽量还原一个用Unity 2018做商业级格斗游戏的真实技术栈。对想读明白“Unity整包项目怎么组织”的开发者,这一个案例比零散的功能Demo有用得多。
2. 战斗循环的C#骨架:状态机与Hitbox的协作方式
2.1 先理清Unity项目的Plugin边界
打开项目管理器时,不要急着看场景,先看Assets/Plugins/Android和Assets/Plugins/iOS里放了什么。项目正文里那串文件名就是双平台构建的关键依赖。我做了个简表,方便对照:
| 文件/目录 | 作用 | 构建阶段 |
|---|---|---|
| libEasyMobile.a | iOS端EasyMobile广告静态库 | Xcode链接 |
| UnityChannel.aar | 国内Unity渠道SDK | Gradle依赖 |
| com.google.android.gms.play-services-ads-11.8.0.aar | Admob官方广告SDK | Gradle依赖 |
| com.android.support.support-compat-25.2.0.aar | Android兼容支持库 | Gradle依赖 |
这里有一个容易踩的坑:Unity 2018.2.20f1自带的Gradle会把Assets/Plugins/Android下所有.aar自动编译进工程,但如果你后续升级Unity版本,Gradle插件版本和Android Plugin版本可能发生冲突。那种情况下,Gradle Console会明确告诉你support-compat重复或play-services版本不一致。我的建议是:这组依赖能不动就不动。它里面的play-services-ads-11.8.0虽然旧,但和EasyMobile的封装是匹配过的,强行升级到20+反而会拉出一堆“方法数超限”和“库类型冲突”的问题。
再往上看C#脚本,项目的战斗逻辑并没有使用Animator Controller里大量的条件跳转,而是用一个普通的MonoBehaviour来驱动状态机。这个设计让后续加AI、加网络同步,或者做回放功能时,主循环只有一条清晰的入口。
2.2 战斗状态机:用枚举而不是字符串做状态流转
格斗游戏最核心的是“当前角色处于什么状态、下一步能不能被切换”。很多初学者习惯直接在Animator里写bool参数,但状态一多,bool组合就爆炸。这个源码里的做法是把它抽象成一个枚举和单独的状态机类,核心模式如下:
public enum StickmanState { Idle, Run, Attack, Block, Hit, Death } public class PlayerStateMachine : MonoBehaviour { private StickmanState _current; private Animator _animator; private StickmanStats _stats; public void ChangeState(StickmanState newState) { _current = newState; _animator.CrossFade("Base." + newState, 0.1f); _animator.SetFloat("MoveSpeed", 0f); } }逻辑说明:CrossFade使用"Base." + newState,这要求Animator里所有状态名和枚举值完全一致,才能用字符串拼接方式定位。这样做的收益是:新增一个状态时,只需要改枚举和Animator加了同名的Clip,不会出现“动画已经播放,但逻辑里还不知道”的错位。_stats是一个独立的角色数值类,攻击、防御、硬直时间都放到里面,避免在Inspector上散落一堆没有归属的float字段。
在状态机切换时,还要注意“当前状态可否被打断”。一个防御中的角色不应该被普通攻击打断,而受击硬直又必须优先覆盖攻击。常见做法是增加一个状态合法性矩阵,比如:
public bool CanSwitch(StickmanState from, StickmanState to) { var valid = new Dictionary<StickmanState, StickmanState[]> { { StickmanState.Idle, new[] { StickmanState.Run, StickmanState.Attack, StickmanState.Block, StickmanState.Hit, StickmanState.Death } }, { StickmanState.Attack, new[] { StickmanState.Block, StickmanState.Hit, StickmanState.Death } }, }; return valid[from].Contains(to); }这个矩阵虽然形式简单,但比在Animation Event里面黑盒处理要可靠得多。
2.3 攻击判定:Hitbox与动画事件的协作
火柴人游戏的打击感,很大程度上取决于“判定窗口”是否和动画播到“刀光接触”那一帧吻合。源码里通常的做法是在攻击动画上挂两个关键帧事件:一个开启Hitbox,一个关闭Hitbox。对应代码是:
public void OnAnimatorEvent_EnableHitbox() { hitboxCollider.enabled = true; } public void OnAnimatorEvent_DisableHitbox() { hitboxCollider.enabled = false; }为什么不用两个OnTriggerEnter常开碰撞体?因为如果碰撞体从第一帧到最后一帧都生效,就会导致“出拳前摇就已经把人打中”的错误反馈。用动画事件精确控制,可以让攻击手感更“跟手”。
命中后的伤害逻辑通常会写在一个名叫IDamageable的接口上,而不是直接指向某个具体敌人类型。这样做的好处是:玩家、敌人、甚至场景中的可破坏墙体都能走同一个TakeDamage流程,后续加一个木桩练习模式,也不需要改额外代码。接口大致长这样:
public interface IDamageable { void TakeDamage(int damage); }然后在Hitbox的OnTriggerEnter里调用接口而非具体类。参数说明:这里的碰撞体推荐放在角色子物体上,并设置成一个只用于判定的小Box/Sphere。如果使用BoxCollider2D或3D,记得把isTrigger设为true,否则物理碰撞会和角色控制器的肢体碰撞冲突。检测时通过CompareTag("Enemy")过滤Tag,既保留了灵活性,又不需要依赖碰撞层矩阵。
3. Admob广告与Android/iOS双平台构建链路
3.1 用EasyMobile统一封装插页式与奖励视频
这套源码没有直接调用GoogleMobileAds.Api,而是走EasyMobile的静态接口。原因很直接:EasyMobile把Android和iOS的差量封装在底层,C#层只需要关心“广告是否准备就绪”和“展示回调”。这样在场景里任意脚本都能按需弹出广告,而不需要持有一个全局单例。
核心广告管理器可以收敛成这样的接口:
public class AdManager : MonoBehaviour { private const string InterstitialAdId = "ca-app-pub-3940256099942544/1033173712"; private const string RewardedAdId = "ca-app-pub-3940256099942544/5224354917"; public void ShowInterstitial() { if (EM_Advertising.IsInterstitialReady()) { EM_Advertising.ShowInterstitial(); } } public void ShowRewardedVideo() { if (EM_Advertising.IsRewardedVideoReady()) { EM_Advertising.ShowRewardedVideo(); } } }逻辑说明:EM_Advertising是EasyMobile的顶层广告入口,IsInterstitialReady()和IsRewardedVideoReady()会同时检查广告是否加载成功以及是否已经展示过一轮。这里使用的ID是Google官方测试ID,替换成你Admob后台真实ID时需要重新生成一次并且等待几小时生效。
广告的回调注册要放在OnEnable里,不要放在构造函数里,否则对象还在预加载阶段就可能丢失事件。一个容易被忽略的细节是,奖励视频回调里要判断用户是否“完整观看”,否则不能发奖。EasyMobile的RewardedVideoCompleted事件比较可靠,但如果你拿到的是老版本EasyMobile,最好在当前播放位置超过95%之后再发奖励,这样防止用户滑进度条和提前关闭。
3.2 Android Gradle依赖:那串aar到底怎么被编译
这一节我们回到项目正文里那串文件。你看到的这些com.android.support.support-compat-25.2.0.aar和play-services-ads-11.8.0.aar,在Unity 2018.2.20f1导出Android工程时,会被Unity自动解压进Gradle的依赖项中。具体来说,Unity会把你放置在Assets/Plugins/Android下的所有.aar和.jar识别为依赖库,并在生成build.gradle时自动加入compile或implementation条目。
如果你修改了Unity版本,比如升级到Unity 2020+,很可能出现两个问题:第一,Gradle版本升级后,对旧AAR的ASM字节码处理方式不同;第二,Support Library版本和AndroidX冲突。我一般会保留一个Android导出工程做交叉验证,用下面命令排查依赖树:
cd android_project ./gradlew :unityLibrary:dependenciesdependencies任务会列出所有dependency路径,这时候重点看是否有重复的com.android.support包或play-services包。一旦出现android.support.v4.app和androidx.appcompat同时存在的错误,就说明有人把新版本SDK也加进来了。源码里这套组合已经经过验证,尽量不要混改。
再谈IL2CPP。项目摘要明确支持IL2CPP,这意味着脚本编译后会转成C++再编译成Android so库。好处是代码难以被直接反编译,坏处是构建时间会多出3到5分钟。Player Settings中Scripting Backend选IL2CPP,Target Architecture尽量只勾选ARMv7和ARM64,避免带上x86导致安装包过大。
3.3 iOS端处理libEasyMobile.a与Bitcode
iOS构建时,Unity会生成Xcode工程,libEasyMobile.a会作为静态库自动链接进目标。这里有一个老坑:旧版Unity 2018默认开启Bitcode,而Google Mobile Ads SDK较新版本已经不支持Bitcode 全量符号表,链接阶段就会报错。我通常直接在Xcode里关闭:
- 打开Unity导出的Xcode工程,选中项目Target。
- Build Settings 搜索
Enable Bitcode。 - 将值设置为
NO。
原因很简单:你的Admob广告库、EasyMobile静态库和Unity的IL2CPP产物已经编译成arm64,Bitcode只是提交给App Store审核用的中间层,对独立游戏而言只增加编译和上传时长。关闭Bitcode后,需要确保官方广告SDK的framework既支持真机arm64,也支持模拟器x86_64,否则真机联调的时候会有系统库缺失。如果你在真机上遇到GoogleMobileAds.framework找不到,需要重新从源码包里的Assets/Plugins/iOS目录下检查是否有.framework文件。
4. 200个关卡的数据驱动与移动端优化细节
4.1 关卡配置化:ScriptableObject比写死硬编码更接近正确
看到“200个关卡”时,第一反应是关卡配置是怎么存的。如果每一关都在场景里放一个不同的Enemy配置,那项目管理会是一场灾难。这个源码的合理做法是把关卡参数做成ScriptableObject资产,在Unity项目窗口右键创建,然后由GameManager统一加载。
关键代码可以抽象为:
[CreateAssetMenu(fileName = "LevelConfig", menuName = "Stickman/LevelConfig")] public class LevelConfig : ScriptableObject { public int levelId; public int enemyCount; public int enemyWaveCount; public float enemyHpMultiplier; public float timeLimit; public Sprite backgroundSprite; }把这些字段定义好之后,在Resources/Levels目录下按Level_1、Level_2编号创建200个资产。加载时用统一入口:
public LevelConfig GetLevelConfig(int levelIndex) { return Resources.Load<LevelConfig>("Levels/Level_" + levelIndex); }逻辑说明:levelId是配置的唯一标识,不要用它来做数组索引,因为一旦在中间插入新关卡,后续所有引用都要改。加载时用Resources.Load,对Unity 2018的现有的简单项目足够,如果你后续升级Unity并增加Addressables,可以只把GetLevelConfig改成异步接口,其他逻辑不变。
参数说明:enemyHpMultiplier更适合做难度曲线,而不是直接把敌人血量写死在每个配置里。例如前50关数值为1.0,51~100关为1.35,101~150关为1.7,这样调整一档难度只需要批量修改这个公共倍率,而不是改动200个配置资产。
4.2 对象池:让Hit特效和敌人尸体不再产生GC压力
格斗游戏每场对局都有大量重复创建和销毁的对象:打击特效、碎屑、血花、倒地尸体。如果不做对象池,高频Instantiate和Destroy会导致GC Spike,卡帧往往就在打击瞬间出现。源码里的常见实现是提供一个通用对象池组件,挂在管理器物体上。
public class ObjectPool : MonoBehaviour { private readonly Stack<GameObject> _pool = new Stack<GameObject>(); public GameObject Pop(GameObject prefab, Vector3 pos) { GameObject go; if (_pool.Count > 0) { go = _pool.Pop(); go.transform.SetPositionAndRotation(pos, Quaternion.identity); go.SetActive(true); } else { go = Instantiate(prefab, pos, Quaternion.identity, transform); } return go; } public void Push(GameObject go) { go.SetActive(false); _pool.Push(go); } }逻辑说明:Stack的Push和Pop在频繁压入弹出时比List的Add/Remove高效,且不会产生顺序遍历开销。Push建议在OnParticleSystemStopped事件中调用,比如以下粒子组件回调:
public class EffectParticle : MonoBehaviour { private ObjectPool _pool; public void Init(ObjectPool pool) { _pool = pool; } private void OnParticleSystemStopped() { _pool.Push(gameObject); } }这样每个特效在播完之后自动回到池中,不需要一个每帧扫描全场景的Timer管理器。注意,粒子系统需要设置为StopAction = None并关闭Destroy On Trigger,否则无法复用于同一对象。
4.3 摄像机跟随、阴影距离与WebGL的边界提醒
火柴人游戏在高强度战斗中,摄像机如果是“直接绑定”玩家,画面会明显抖动。比较稳的方案是加上死区(DeadZone)和轻量化插值:
public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset; public float deadZoneRadius = 0.5f; private void LateUpdate() { Vector3 delta = target.position - transform.position; delta.y = 0; if (delta.magnitude > deadZoneRadius) { transform.position = Vector3.Lerp(transform.position, target.position + offset, 0.2f); } } }说明:deadZoneRadius为0意味着摄像机永远紧盯目标,稍微有一点运动变化都会传递到相机;0.5~1.0 的小死区可以滤掉微小抖动,同时保持跟随灵敏度。注意这里LateUpdate先执行逻辑后执行相机,保证角色的位置更新完后再移动相机,避免一帧内出现瞬移。
移动端阴影性能问题,和这个源码的Android/iOS目标强相关。默认实时阴影在低端机上非常耗电,常见做法是:
- 关闭整个场景的动态阴影,只用一张假阴影贴图(Blob Shadow)。
- 或者只在玩家和当前敌人附近设置一个有限范围的Shadow Distance。
在Quality Settings里把Shadow Distance调整到5~10米,就足以覆盖大部分格斗场景,同时避免远处一堆火柴人在阴影计算中白耗性能。
如果你的目标平台日后扩展到WebGL,还需要注意Unity WebGL 的IDBFS写入失败问题。源码本身只支持Android/iOS,但如果沿用这套存档代码,会默认使用FileStream写本地文件。WebGL环境中这个操作会被浏览器安全策略拦截,所以扩展平台前就要把存档改为PlayerPrefs或后端接口,不要等到Idbfs报了Error: Failed to write to IDBFS才改。
5. 接手源码后先改的三个地方:链接保留、编辑器工具与调试习惯
5.1 用编辑器脚本批量检查200个关卡配置
200个关卡,你不可能逐个进入场景去验证配置是否正确。在Unity里写一个MenuItem工具,可以一键扫描所有LevelConfig并输出非法数据:
[MenuItem("Tools/Check All LevelConfigs")] private static void CheckAllLevels() { var configs = Resources.LoadAll<LevelConfig>("Levels"); int errorCount = 0; foreach (var config in configs) { if (config.enemyCount <= 0 || config.timeLimit <= 0) { Debug.LogError($"Level_{config.levelId} 配置异常", config); errorCount++; } } Debug.Log($"检查完毕,共 {configs.Length} 个关卡,失败 {errorCount} 个"); }使用办法:在Unity顶部菜单点击Tools/Check All LevelConfigs,Console窗口会直接列出所有非法资产的引用。参数说明:Resources.LoadAll会加载Resources/Levels下的所有ScriptableObject资产,注意它不会加载嵌套子目录。如果你在后续版本里把关卡配置拆到了子文件夹,需要把参数换成完整的子路径。
为什么会想到先加这个工具?因为游戏在构建前最容易出的问题不是代码bug,而是某关的敌人数量被误改成0,导致循环进入Boss脚本时一直等待0号敌人结束,玩家卡在通道里。
5.2 往link.xml里补保留规则
IL2CPP构建时,Unity会剪裁掉未使用的代码。如果广告SDK内部使用反射来注册回调函数,这些方法可能会被错误剪裁。我通常会在Assets根目录放一个link.xml,保底写法如下:
<linker> <assembly fullname="EasyMobile"> <type fullname="EasyMobile.EM_Advertising" preserve="all" /> </assembly> <assembly fullname="GoogleMobileAds"> <type fullname="GoogleMobileAds.Api.*" preserve="all" /> </assembly> </linker>这样设置后,EasyMobile和GoogleMobileAds的相关类型在IL2CPP裁剪时会被保留,广告的初始化回调不会变成“静默失败”。需要留意的是,如果源码里已经带了一份link.xml,不要强行覆盖,优先合并相同节点。检查方式是在编辑器里导出工程,然后用文本编辑器打开生成的link.xml查看是否包含了上述条目。
5.3 在IL2CPP断点上少花时间,多依赖日志
很多人会拿Unity的Mono调试器去打设备的断点,然后遇到“当前不会命中断点,源代码与原始版本不同”。这个提示在IL2CPP构建里几乎是必然的——因为托管PBD信息转成C++后,断点映射已经失效。所以不要纠结在真机上单步调,不如在C#代码关键路径上打自定义日志。比如广告初始化回调里这样写:
EM_Advertising.RewardedVideoCompleted += () => { Debug.Log("REWARD_VIDEO_COMPLETED"); };然后观察LogCat或Xcode控制台输出。只要看到日志,就能确定这一条路径活着。我在接手这类源码时,会优先把广告回调、关卡加载、角色状态切换三条链路上的Debug.Log补齐,再进行实机测试;否则一次构建几分钟,断点又不可靠,排错效率会非常低。
以上三个改动加起来不到二十行代码,但能在你真正修改游戏数值和玩法之前,帮你建立一道“数据合法、引用保留、路径可见”的基础防线。接下来再去改连击节奏、敌人AI和关卡曲线,改起来就稳了。
本文还有配套的精品资源,点击获取