简介:这套实战源码围绕Unity引擎5.x版本与C#语言展开跨平台游戏开发,面向希望掌握组件化架构、脚本编写及多平台发布流程的初中级开发者。项目通过实际场景演示了变换组件、网格渲染器与自定义脚本的协同方式,并说明了脚本基类中Awake、Start、Update等生命周期方法的调用时机,便于读者理解C#面向对象特性与引擎机制的结合。
资源包为7z压缩格式,大小约130MB,内含场景文件、C#脚本、预设体、纹理、音频、着色器与材质等工程资源,同时附有开发笔记和代码注释。从场景环境搭建、预设体复用到脚本逻辑管理、多平台导出配置,能够支撑一条完整的学习与动手路线。目前已有490人学习浏览,既可充当课程项目的补充素材,也能作为独立实践的作品基础;通过修改脚本参数、调整着色器与材质设置,读者还可直观感受不同平台适配、性能优化及渲染效果的变化。 做了这么多年Unity项目,最值得复盘的反而不是那些引擎高级特性,而是Unity5 + C#这套组合如何把一个游戏稳稳妥妥地搬到多个平台。我之前用Unity5开发过一个轻量级2D横版闯关游戏,同时要出Windows、Android、WebGL三个版本。项目代码规模不大,但输入、UI、存档、资源加载这些常规模块一个都躲不掉,最有价值的不是某个功能多华丽,而是把所有平台差异封在了一层代码里,后续不管构建到哪个平台,只用改配置,不用动玩法逻辑。这篇文章就围绕着这份项目源码,聊一聊多平台开发里的设计思路、关键实现和那些真正踩过的坑。
写这篇内容不是想炫技,而是给正在用Unity做小游戏的开发者一个可复制的工程骨架。如果你手头正好有Unity5项目要同步发布到多个平台,或者想看看C#代码在跨平台时怎么组织最省心,又或者只是对“源码怎么拆才不烂”这件事感兴趣,这篇都值得往下看。
1. 项目定位与整体设计思路
1.1 为什么选Unity5 + C#而不是其他方案
当年做这个项目时,可选择的技术栈其实不少:Cocos2d-x、原生Java、H5游戏引擎都能做2D闯关。但最终选了Unity5,核心原因是它的构建管线对多平台太友好了。我只需要维护一份C#逻辑代码,其余渲染、音频、资源管理等底层问题全部交给引擎处理,这在当时是性价比最高的方案。
C#语言本身也帮了大忙。对比C++,C#的GC机制减少了手动管理内存的负担,写UI逻辑、状态机、数据解析这类业务代码效率很高。而且Unity社区里大量插件、源码示例都是用C#写的,遇到问题可以直接看反编译出来的IL或者官方脚本API,不用在文档和论坛之间来回折腾。对于一个小团队或者个人开发者来说,能快速出包、快速验证玩法,比追求极致性能更重要。
另外要特别说一句:Unity5已经支持IL2CPP脚本后端,Android平台可以告别Mono时代的一些兼容性问题,代码在正式发包前能被提前编译成C++再生成二进制,运行效率和安全性都有提升。这个特性对多平台发布非常关键,后面的构建部分我会再细说。
1.2 目标平台选定:不是越广越好
不少新手拿到多平台需求后的第一反应是“能发布的所有平台都勾上”。我自己一开始也这么干过,结果就是UI适配、输入差异、音频格式、加载策略全都要重新处理一遍,项目进度被拖慢了一倍。
所以这个项目一开始就定了三个主目标:Windows、Android、WebGL。Windows负责桌面体验,Android覆盖移动端,WebGL用于快速分享Demo。iOS当时没排进来,是因为团队没有苹果电脑和测试机,签证书、上架这些流程没法闭环。做技术选型时一定要想清楚:目标用户在哪,哪些平台是必须的,哪些只是“如果顺手就发”。平台每增加一个,维护成本不是线性增长,而是成倍增长。
这三个平台的差异也很有代表性。Windows用键鼠输入,Android靠触摸和物理返回键,WebGL藏在浏览器里,既没有完整的文件系统体验,又对音频和纹理格式有严格限制。正因为差异够大,做适配方案时才能把问题暴露得足够充分。如果这个项目只发Windows,我后续根本不会有意识去抽象输入层和加载层。
1.3 源码结构规划:先把目录立起来
很多Unity项目最后变得不可维护,不是代码写得烂,而是一开始目录结构就没规划。我在这个项目里强制把脚本放进了几个固定目录,后续加功能、修Bug都很快找到位置。
Assets/ Scripts/ Core/ # 游戏入口、主循环、全局管理器 Input/ # 所有输入相关封装 UI/ # 界面控制与组件逻辑 Data/ # 存档、配置、序列化模型 Platform/ # 平台差异代码与条件编译 Utils/ # 扩展方法、工具类 Prefabs/ Scenes/ Resources/ # 运行时动态加载的资源 Texture/ Audio/这套结构不是灵光一现,而是吃过亏之后才定的。以前我喜欢把脚本按场景分,比如MainScene、GameScene各建一个文件夹,结果多个场景共用的逻辑被到处复制。现在改成按模块分,场景只是组装层,脚本通过生命周期和事件驱动,项目后期改起来轻松很多。如果你在Unity5里建新项目,我强烈建议先搭这套骨架,不要等代码堆到两万行再重构。
2. 核心玩法与关键模块的实现
2.1 输入系统抽象:把“按键”从平台里剥出来
2D横版闯关最核心的操作是左右移动、跳跃、攻击。一开始为了省事,我直接在逻辑代码里写Input.GetAxis("Horizontal")和Input.GetKeyDown(KeyCode.Space),Windows跑得很欢。但打包到Android后问题就来了,手机上根本没有键盘,触摸按键区域又和UI混在一起,甚至一个不小心点到了屏幕边缘,角色就开始抽搐。
后来我把输入全部收进了一个静态类,由它统一判断当前运行平台并返回逻辑操作。玩法代码只关心“玩家是否按下跳”,不关心是键盘空格、屏幕虚拟按键还是手柄X键。
public static class InputAdapter { public static bool GetJumpDown() { #if UNITY_ANDROID || UNITY_IOS return VirtualButton.GetJumpDown(); #else return Input.GetKeyDown(KeyCode.Space); #endif } public static float GetAxis() { #if UNITY_ANDROID || UNITY_IOS return VirtualJoystick.GetAxis(); #else return Input.GetAxis("Horizontal"); #endif } }这个抽象层看起来简单,但价值极大。之后加入手柄支持、键盘重映射,都不用改玩法逻辑。我甚至发现以前写死的Input调用散落在十几个脚本里,改输入方式要全项目搜索,现在只需要改InputAdapter这一个类。
2.2 UGUI适配:不同分辨率下的Canvas设置
多平台开发里最让人头疼的往往是UI,而不是游戏逻辑。同一个界面,在Windows的16:9显示器上显示正常,到了Android的全面屏上可能按钮被挖孔遮挡,或者底部多出一截黑边。Unity5时代的UGUI已经比NGUI好用很多,但前提是Canvas设置正确。
我的做法是:所有核心界面都放在一个Canvas下,Canvas Scaler模式设为Scale With Screen Size,参考分辨率用1280x720,Screen Match Mode选择MatchWidthOrHeight,并把比例调到0.5。这样在接近16:9的设备上不会出现明显拉伸,在带鱼屏或平板这类极端比例下也能保持基本可读。
// UI加安全区适配时,我会在根节点上挂一个SafeAreaAdapter RectTransform rect = GetComponent<RectTransform>(); rect.anchorMin = new Vector2(0, Screen.safeArea.yMin / Screen.height); rect.anchorMax = new Vector2(1, Screen.safeArea.yMax / Screen.height); rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero;代码里的Screen.safeArea是在引擎里读取系统安全区信息,适配屏幕挖孔和圆角非常好使。不过Unity5早期部分版本对safeArea的支持不够完善,需要先用平台宏判断,再在Android上通过调用原生代码获取,否则就退回默认Rect。这也算是跨平台坑的一个典型:不要假设每个引擎版本都能帮你把所有细节兜住。
2.3 存档与数据处理:别在PlayerPrefs里放结构体
很多小项目喜欢把玩家的金币、关卡进度直接塞进PlayerPrefs,字符串拼一拼就完事。这个项目早期也是这样,但后来发现两个问题:一是PlayerPrefs在部分平台上编辑不方便,测试人员看不到完整存档;二是多处读写的键名一旦拼错,数据就乱了,还很难排查。
我后来把存档统一成一份可序列化的存档模型,用JsonUtility序列化成字符串再写入文件。存档类长这样:
[System.Serializable] public class SaveData { public int gold; public int maxLevel; public bool isMusicOn; public string playerName; } public static class SaveManager { private static SaveData data = new SaveData(); public static void Save() { string json = JsonUtility.ToJson(data); string path = Path.Combine(Application.persistentDataPath, "save.json"); File.WriteAllText(path, json); } public static void Load() { string path = Path.Combine(Application.persistentDataPath, "save.json"); if (File.Exists(path)) { string json = File.ReadAllText(path); data = JsonUtility.FromJson<SaveData>(json); } } }Unity5的JsonUtility不能直接序列化Dictionary和部分复杂类型,遇到这种需求我会用数组或嵌套类绕过去。写存档时记得先备份旧文件,再写入新文件,避免中途断电导致整个存档损坏。后来我又给Save类加了版本号字段,以后字段结构变化时可以做兼容迁移。这个经验是从线上玩家反馈“更新版本后金币清零”学来的,别看存档简单,做不好一样翻车。
2.4 资源与场景加载:保持简单的加载策略
小体量游戏其实用不上复杂的AssetBundle方案,Resources.Load足够撑起大部分需求。我当时的做法是:UI图标、音效、敌人预制体都丢在Resources目录下,游戏启动时预加载必要资源,战斗过程中按需加载。
var enemyPrefab = Resources.Load<GameObject>("Prefabs/Enemies/NormalEnemy"); Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);Resources.Load的缺点是资源会无脑打进包体,而且不容易做增量更新。Unity5时代做多平台时,我记得不同平台对Resources目录中的资源压缩规则还不完全一样,所以图集和纹理格式我都尽量统一,避免“Android构建正常,WebGL却花了十分钟加载”这种情况。
后来随着包体变大,我引入了AssetBundle来拆分平台资源和热更内容,但那时候把核心逻辑稳定下来更重要。建议新手别一上来就折腾AssetBundle,先把Resources这条简单的路走通,再考虑热更新性能优化,否则很容易陷入加载框架的泥潭里出不来。
3. 多平台构建与代码层适配
3.1 构建前必须检查的Player Settings
很多人写代码写得很欢,一到出包就卡在设置上。Unity5的Player Settings面板里,有几个选项直接影响多平台构建是否成功,我每次出包前都会逐项确认。
- Company Name和Product Name:不能带中文和特殊字符,Android包名建议用com.company.product这种格式。
- Default Icon:每个平台最好都手动指定图标,否则某些平台会用Unity默认图标,看起来非常业余。
- Scripting Backend:Android平台我用IL2CPP,Windows则用Mono,这样兼容性和迭代速度都比较好。
- Orientation:Android和iOS要注意屏幕方向,横版游戏就锁定Landscape,千万别留AutoRotation。
- API Compatibility Level:如果引用了.NET Framework特性,在Unity5里要选.NET 2.0或4.x,否则编译直接报错。
这些设置之所以要重点检查,是因为它们不参与代码编译,报错通常不会第一时间出现在Console里。有一次我WebGL构建失败,原因只是Player Settings里的WebGL模板被设置成了空模板,加载界面直接白屏,查了半个多小时。
3.2 Windows、Android、WebGL的真实差异
用表格看这三个平台的差异最直观,这也是我在项目里总结出来的:
| 比较项 | Windows | Android | WebGL |
|---|---|---|---|
| 输入方式 | 键鼠、手柄 | 触摸、重力、返回键 | 键鼠、触摸屏浏览器 |
| 持久化 | Application.persistentDataPath可写 | 应用私有目录可写 | 受限,建议用PlayerPrefs或后端 |
| 文件访问 | 支持同步IO | 支持同步IO,但低端机可能卡顿 | 不直接支持文件流 |
| 音频格式 | WAV/MP3/Ogg | MP3/Ogg效率要好 | Ogg为首选,MP3兼容性差 |
| 纹理格式 | 常用DXT | ETC/ASTC | WebGL对尺寸有严格限制 |
| 构建体积 | 相对较小 | 需带IL2CPP库,包体变大 | 压缩后较小,但要考虑网络加载 |
这段表格我建议截图存下来。每次换平台构建时,先对照一遍,比盯着Console报错一个个试靠谱得多。比如WebGL不能直接读写本地文件,我就把存档策略从文件改成了PlayerPrefs,再加个云端同步接口,彻底解决了跨设备问题。
3.3 条件编译:一份代码兼容所有平台
Unity的C#编译器会根据当前构建目标自动设置宏,比如UNITY_ANDROID、UNITY_WEBGL、UNITY_STANDALONE。合理利用条件编译,可以把平台差异代码放在同样的逻辑流程里,而不是写几套重复脚本。
public class PlatformEventHandler : MonoBehaviour { void OnApplicationFocus(bool hasFocus) { #if UNITY_ANDROID // Android切后台时保存存档 SaveManager.Save(); Debug.Log("Android focus lost, save data."); #elif UNITY_WEBGL // WebGL页面隐藏时暂停游戏 PauseGame(); #else if (!hasFocus) SaveManager.Save(); #endif } }用条件编译要克制,不能把整个游戏逻辑都包进去。我的原则是:只放平台API调用,不放业务逻辑。比如Android返回键处理、iOS震动画、WebGL浏览器关闭事件,这些必须用宏区分;但角色属性、敌人AI这些和平台无关的逻辑,必须保持完全一致,否则两个平台玩起来手感都不一样。代码里宏如果超过三处嵌套,我就会把相关逻辑抽到一个独立方法里,防止可读性崩掉。
3.4 真机调试与日志查看
编辑器里跑不出来的Bug,往往一到真机就露馅。Android平台最常用的是Unity的Development Build加Android Logcat窗口。我习惯在每次打包时勾上Development Build和Script Debugging,然后在真机上复现问题,把Logcat里的异常栈拉出来看。
WebGL平台的调试比Android更隐蔽,因为Unity脚本是编译成WebAssembly跑的,异常信息经常丢帧。我在JavaScript层挂了一个错误捕获,把Unity的Application.logMessageReceived事件输出到浏览器控制台,这样查起问题来和编辑器里差不多。
void Awake() { Application.logMessageReceived += (condition, stackTrace, type) => { // 浏览器控制台里会显示Unity日志 Debug.LogWarning($"UnityLog: {type} {condition}\n{stackTrace}"); }; }真机调试时还有一个容易被忽略的问题:构建机器的显卡驱动和Shader效果会和开发机不一样。我在Windows上跑得很正常的描边效果,到Android真机上莫名其妙出现了黑边,最后发现是Shader里用了半精度浮点导致。遇到这种渲染差异,优先检查是否用了高精度变量、贴图格式以及压缩纹理的采样方式。
4. 实践中踩过的坑与排查技巧
4.1 中文乱码:老项目最容易翻车的地方
Unity5项目里的中文乱码通常是两个原因:脚本文件编码不是UTF-8,或者运行时文本编码与平台默认编码不匹配。Windows上旧版Mono默认使用GBK读取C#文件,如果脚本被保存成了带BOM的UTF-8,某些情况下字符串里的中文会变成“锟斤拷”字体。
我的处理办法很简单:所有C#脚本统一用UTF-8 with BOM保存,并且不要在脚本里硬编码用户可见文案。提示文本全部放在Resources下的TextAsset或ScriptableObject配置里,运行时加载。
private string GetText(string key) { TextAsset config = Resources.Load<TextAsset>("Lang/zh-CN"); return ParseJsonString(config.text, key); }这样处理同时给后面做多语言版本留了后路。Unity5的JsonUtility对中文转义处理得没有后来的Newtonsoft.Json顺手,我那时候直接用正则去解析,问题也不大。总之,记住一句话:不要让中文裸奔在逻辑代码里。
4.2 纹理内存和加载卡顿
多平台发布最常见的内存杀手就是纹理。一张2048x2048的PNG在Windows上看着不大,但打包到Android后如果用了RGBA32非压缩格式,显存占用直接翻好几倍。之前我的角色立绘占用内存超过200MB,低端机打开就闪退。
后来我在项目里给所有UI纹理设置了合理的Max Size和Format,Android用ETC2、Windows用DXT5。WebGL平台对纹理尺寸还有额外限制,个别旧移动浏览器根本不支持NPOT(非2的幂次)纹理,所以我在导入设置里把All Platforms的Non-Power of 2处理设成了ToNearest。测试阶段最好用真机资源分析工具,看看运行时到底加载了哪些大图,而不是凭感觉压缩纹理。配置纹理格式是导入设置里的小事,但直接决定你的游戏能不能在目标平台平稳运行。
4.3 Android返回键、焦点丢失和生命周期
桌面上玩家点关闭按钮,游戏就能退出,Android却有一套完整的生命周期。按下Home键、切到后台、来电打断,这些情况都会触发OnApplicationPause或OnApplicationFocus。我在早期版本中直接把状态保存写在Update里,频率高又乱,后来统一改成在生命周期回调里集中处理。
Android返回键在Unity里也有自己的一套:如果忽略它,游戏会直接退出;如果处理不当,又会和UI的关闭逻辑冲突。我给每个界面做了一个返回键拦截接口,优先让UI决定是否消费事件,只有所有UI都不拦截时,才执行退出确认逻辑。这样桌面版的Esc键和Android的返回键可以共用一套逻辑,避免平台差异导致操作割裂。
void Update() { #if UNITY_ANDROID if (Input.GetKeyDown(KeyCode.Escape)) { if (!UIManager.Instance.HandleBackKey()) { ShowQuitDialog(); } } #endif }4.4 源码阅读:从项目源码和UGUI源码里学东西
多平台项目做到后面,瓶颈往往不在功能,而在代码维护效率。我养成了一个习惯:每三天读一遍自己最近写的核心代码,站在下一个人接手的角度去看能不能少踩几个坑。注释不用多,但是每个平台差异宏所在的位置必须写清楚为什么。源码不是写完就完的,它是后期所有修复和扩展的地基。
另外,Unity5的UGUI源码在官方仓库里能看到,编译器也会把引擎生成的源码临时缓存到安装目录。想搞明白ScrollRect为什么会跳动、Mask为什么裁剪不干净,直接看源码比看博客猜原因快得多。有些朋友觉得读引擎源码门槛高,其实不一定要全懂,重点找自己项目中卡住过的类和方法,比如LayoutGroup、GraphicRaycaster,把关键逻辑读透,后续查UI问题会豁然开朗。
踩过几次坑之后,我现在接手的任何Unity项目都会先花半天把脚本目录和平台宏分布翻一遍。旧项目里往往藏着很多人为约定,新功能如果不理解这些约定,改一处坏三处。多平台开发更是这样,代码结构清楚,构建流程稳定,比多写两个华丽功能更能支撑一个项目走到最后。
如果你正准备把Unity5老项目搬到新平台,建议先从小范围试点开始:挑一个简单界面,完整走一遍“代码拆分—适配—构建—真机验证”的流程,再全面铺开。我当初没有这么做,结果一边改输入系统一边处理UI适配,两头都顾不上。后来把平台差异拆成独立模块,整个项目的节奏才变得可控。C#的多平台编码能力再强,也需要你提前把边界画清楚,这大概是我在整个项目里收获最大的一点。
本文还有配套的精品资源,点击获取