GameFramework场景切换、UI与事件联动:最小闭环实现与踩坑记录
2026/9/7 5:26:04 网站建设 项目流程

简介:一套基于GameFramework的Unity3D样例工程,围绕场景切换、UI构建与事件通信三大核心模块展开,适合已经接触过框架基础、希望了解实际项目用法的开发者。压缩包共2000个文件,以324个C#脚本、440个Markdown文档、141个TXT说明、80个JSON配置为主体,同时包含33个Unity场景、37个Asset资源、Shader、Prefab、材质和字体等,整体约95.24MB,目录结构完整,便于按功能模块查阅。场景部分演示了加载顺序与预加载机制;UI部分包含布局文件和动态更新示例;事件部分覆盖订阅与发布流程,可帮助理解异步通信在复杂逻辑中的应用。目前已有571人学习下载,适合用作功能验证、代码参考或二次开发基础。 在团队里给新人整理 GameFramework(GF)入门样例时,“场景切换、UI、事件”这三个词几乎每次都会被同时点到。单看官方 StarForce 的演示工程,每个模块都跑得很顺;可一旦要自己动手写一个最小工程,把“主菜单场景切到战斗场景、同时打开战斗 HUD 界面、再让战斗逻辑通过事件刷新 UI”这几件事串起来,你就会发现细节全是坑。这篇文章把样例的核心代码和踩坑记录一起贴出来,适合刚接触 GF、想快速做出第一个可跑通工程的开发者参考。

所谓“GameFramework 场景切换、UI、事件样例”,本质上就是把 GF 中最常用的三个组件——SceneComponent(场景)、UIComponent(界面)、EventComponent(事件)——组合成一个最小闭环。场景负责切换环境,UI 负责呈现操作界面,事件则负责让这两个模块之间不用互相引用也能通信。接下来我会按实际搭建顺序,把这套最小样例的代码、调用链和常见问题逐段讲明白。

1. 项目思路拆解:场景、UI、事件为什么要一起讲

1.1 三个组件在 GF 里的分工关系

很多新手拿到 GF 后的第一反应是:场景切换不就是SceneManager.LoadScene吗?UI 不就是Instantiate一个 Prefab 吗?事件不就是 C# 的委托吗?但 GF 之所以要单独做一套,是因为它把这三件事都统一到了资源加载框架和模块生命周期里。

场景切换时,GF 会通过 ResourceComponent 加载场景资源、统计加载进度、派发加载成功或失败事件;UI 打开时,也要走资源加载流程,同时挂到指定的 UIGroup 上,由 UIManager 统一控制显示深度和关闭策略;事件系统则作为全局的消息总线,让场景层和 UI 层之间不用互相持有引用。三者在一起,才是一个完整可用的游戏启动流程。

打个比方:场景是舞台,UI 是舞台上的道具和提词器,事件是幕后对讲机。导演(场景逻辑)不需要扯着嗓子对道具组喊话,只需要通过对讲机(事件)发一条消息,UI 自然知道什么时候该闪、什么时候该退。这套设计让代码耦合度大幅下降,也方便不同人各自负责一块。

1.2 样例工程的最小目录结构

我建议样例工程不要一开始就参考 StarForce 那种完整商业级结构,而是先建一个最精简的目录,把注意力集中在模块之间的调用关系上:

Assets/ GameMain/ Scenes/ MainMenu.unity BattleScene.unity UI/ UIMenuForm.cs UIBattleHUD.cs Events/ BattleStartEventArgs.cs BattleEndEventArgs.cs GameEntry.cs

GameEntry 是自定义的静态入口类,用来集中暴露 GF 的各组件实例。样例里的场景脚本和 UI 脚本都通过它调用框架方法,避免到处找单例。目录组织不重要,关键是让每个脚本的职责单一:场景脚本管场景加载,UI 脚本管界面交互,事件参数只管携带数据。

2. 场景切换模块的实现:完整调用链

2.1 场景组件与资源加载的前置条件

在写场景切换代码前,必须确认两件事。第一,封装好的 GameEntry 里能拿到 SceneComponent 实例;第二,场景资源本身已经可以被 ResourceComponent 识别和加载。项目里如果用的是 ResourceCollection 模式,需要把要切的场景资源加到资源集合里,并勾选对应的加载方式;如果用的是 AssetBundle,则要确保 bundle 已经构建并配置好。

这里有个容易忽略的点:GF 的场景加载走的是自己的 ResourceManager,不是 Unity 的SceneManager。所以你直接写SceneManager.LoadScene虽然也能切场景,但绕过了 GF 的场景事件、加载进度和资源引用计数,工程里的部分功能会变成“孤儿逻辑”,后续维护时容易出问题。

2.2 一段最朴素的场景切换代码

样例里我先实现一个最小的场景切换器,负责从主菜单切入战斗场景:

using GameFramework.Scene; using UnityEngine; public class SceneChanger { public void GoToBattleScene() { GameEntry.Scene.LoadScene( "Assets/GameMain/Scenes/BattleScene.unity", this); } public void GoToMainMenu() { GameEntry.Scene.LoadScene( "Assets/GameMain/Scenes/MainMenu.unity", this); } }

这里传入的this是 userData,也就是用户自定义数据。当场景加载成功或失败时,回调会原样带回这个 userData,用来区分某次加载来自哪个调用者。工程里如果同时有多处切换场景,这个参数可以帮你快速判断回调归属,避免逻辑串线。

不过只调用 LoadScene 不够,还需要订阅加载结果事件,否则你根本不知道场景到底加载完没有。GF 提供了一组场景事件参数,常用的有LoadSceneSuccessEventArgsLoadSceneFailureEventArgsLoadSceneUpdateEventArgs。订阅方式如下:

using GameFramework.Event; public class SceneChanger { public SceneChanger() { GameEntry.Event.Subscribe(LoadSceneSuccessEventArgs.EventId, OnLoadSceneSuccess); GameEntry.Event.Subscribe(LoadSceneFailureEventArgs.EventId, OnLoadSceneFailure); } private void OnLoadSceneSuccess(object sender, GameEventArgs e) { LoadSceneSuccessEventArgs args = (LoadSceneSuccessEventArgs)e; if (args.UserData != this) return; Debug.Log($"场景加载成功:{args.SceneAssetName}"); // 场景成功到达后,可以通知其他模块做初始化 } private void OnLoadSceneFailure(object sender, GameEventArgs e) { LoadSceneFailureEventArgs args = (LoadSceneFailureEventArgs)e; if (args.UserData != this) return; Debug.LogError($"场景加载失败:{args.SceneAssetName},错误信息:{args.ErrorMessage}"); } }

订阅事件要在构造或初始化时完成,因为加载是异步的,不能指望调用 LoadScene 之后立刻拿到结果。这个“先订阅、再加载”的习惯,在整个 GF 工程里都要贯彻。

2.3 加载进度与资源引用计数

如果战斗场景较大,最好给玩家一个加载进度条。GF 的LoadSceneUpdateEventArgs会持续派发进度数据,样例里可以简单把数值转给 UI 更新:

private void OnLoadSceneUpdate(object sender, GameEventArgs e) { LoadSceneUpdateEventArgs args = (LoadSceneUpdateEventArgs)e; if (args.UserData != this) return; Debug.Log($"场景加载进度:{args.Progress:P1}"); GameEntry.UI.UpdateProgress(args.Progress); }

需要注意的是,场景加载完成后,GF 并不会自动卸载旧场景。多数情况下你应该在 LoadSceneSuccess 回调里显式卸载上一个场景。我习惯用事件参数里的SceneAssetName判断当前加载的是哪个场景,再决定要卸载哪个旧场景。如果连这层逻辑都省了,场景重叠会让画面里出现两个主相机,新手排查起来相当痛苦。

3. UI 界面管理的接入:从 Form 到 UIGroup

3.1 UIFormLogic 的生命周期方法

GF 里的 UI 不是普通 Prefab,而是继承UIFormLogic的组件脚本。打开一个 UI 时,GF 会执行它的生命周期方法:OnInitOnOpenOnCloseOnRecycleOnDestroy

  • OnInit:UI 创建时执行一次,适合缓存 Transform 和组件引用。
  • OnOpen:每次打开 UI 都会执行,适合刷新显示数据、订阅事件。
  • OnClose:每次关闭 UI 都会执行,适合取消订阅事件、清理临时状态。
  • OnRecycle:UI 被回收进对象池时执行,适合释放实例引用。

这个生命周期模型提醒我们:不要在OnInit里做依赖外部数据的操作,因为此时 UI 还没有进入显示流程;也不要在OnClose里忘记退订事件,否则下次打开时同一事件可能被回调多次。

3.2 定义一个最简单的 UI 界面

样例里我创建一个战斗 HUD 界面,用来显示敌人数量,并监听战斗开始事件。完整代码如下:

using GameFramework.UI; using UnityEngine; using UnityEngine.UI; public class UIBattleHUD : UIFormLogic { private Text _enemyCountText; private GameObject _pauseButton; protected override void OnInit(object userData) { base.OnInit(userData); _enemyCountText = transform.Find("InfoPanel/EnemyCountText").GetComponent<Text>(); _pauseButton = transform.Find("BottomBar/PauseButton").gameObject; } protected override void OnOpen(object userData) { base.OnOpen(userData); GameEntry.Event.Subscribe(BattleStartEventArgs.EventId, OnBattleStart); _pauseButton.GetComponent<Button>().onClick.AddListener(OnPauseClicked); } protected override void OnClose(object userData) { GameEntry.Event.Unsubscribe(BattleStartEventArgs.EventId, OnBattleStart); _pauseButton.GetComponent<Button>().onClick.RemoveListener(OnPauseClicked); base.OnClose(userData); } private void OnBattleStart(object sender, GameEventArgs e) { BattleStartEventArgs args = (BattleStartEventArgs)e; _enemyCountText.text = $"敌人数量:{args.EnemyCount}"; } private void OnPauseClicked() { // 打开暂停面板 GameEntry.UI.OpenUIForm("Assets/GameMain/UI/UIPauseForm.prefab", "Popup", this); } }

这段代码可以看出 GF UI 的核心思路:打开和关闭逻辑都在 UIFormLogic 子类里自洽,外部调用方只需要一条OpenUIFormCloseUIForm,剩下的生命周期由框架托管。

3.3 打开 UI 界面与 UIGroup 的设置

打开一个 UI 前,GF 要求先把界面分组好。样例里我约定两个组:DefaultPopup,前者放常驻 HUD,后者放弹窗类界面。在 GameEntry 初始化时,需要提前创建这些组,否则打开 UI 会报“找不到 UIGroup”一类错误。

// 在 GameEntry 初始化阶段调用 GameEntry.UI.CreateUIGroup("Default", 1); GameEntry.UI.CreateUIGroup("Popup", 2);

第二个参数是 depth 深度。数字越大,显示时越靠前。这里要理解一点:GF 的 UIGroup 深度只决定组之间的大层级,同一组内多个 UI 则按打开顺序叠加。我的经验是,把“全屏界面”“弹窗”“飘字提示”等不同交互层级拆到独立组里,人一多才不会互相遮挡乱套。

打开界面时,传入 UI Asset 路径、组名和 userData:

GameEntry.UI.OpenUIForm( "Assets/GameMain/UI/UIBattleHUD.prefab", "Default", this);

返回的是 UI 序列化 ID,之后要关闭这个界面时,尽量用这个 ID 精准关闭,避免误关同组里的其他界面。但样例里为了方便,我也经常直接使用OpenUIForm时的 userData 标记,让回调里能拿到这次打开的请求来源。

3.4 UI 层的事件订阅对称性

如果你仔细看上面的代码,会发现OnOpen里订阅了什么,OnClose里就一定退订什么,顺序完全对称。UI 是事件系统里最典型的“订阅者”,只要有一次打开时订阅了事件、关闭时忘记退订,事件缓存里就会积累一个无效回调。严重时还会导致“已关闭页面收到事件后继续更新”,界面看起来就像闹鬼一样,其实是回调触发了已销毁的 GameObject。

我习惯在写每个 UIFormLogic 时先列一张“订阅清单”,把事件 ID 和对应处理方法写在一起,最后在 OnClose 里按清单逐项退订。这个习惯帮我省了很多次半夜排查线上 UI 灵异事件的精力。

4. 事件系统的接入与联动样例

4.1 自定义事件参数的写法

GF 的事件参数统一继承GameEventArgs,并且要从对象池获取和释放。因为事件系统会在派发时高频创建参数对象,如果每次都 new,GC 压力会非常明显。正确写法如下:

using GameFramework; using GameFramework.Event; public class BattleStartEventArgs : GameEventArgs { public static readonly int EventId = typeof(BattleStartEventArgs).GetHashCode(); public override int Id => EventId; public int EnemyCount { get; private set; } public static BattleStartEventArgs Create(int enemyCount) { BattleStartEventArgs args = ReferencePool.Acquire<BattleStartEventArgs>(); args.EnemyCount = enemyCount; return args; } public override void Clear() { EnemyCount = 0; } }

EventId用类型名 GetHashCode 生成,可以看作这个事件类的唯一标识。Create从引用池取实例并初始化字段,Clear在事件处理完后被框架调用,用于清空字段、归还引用池。

派发事件时,调用GameEntry.Event.Fire

BattleStartEventArgs args = BattleStartEventArgs.Create(12); GameEntry.Event.Fire(this, args);

Fire 的 sender 参数一般传当前触发者,比如敌人波次管理器。框架会把参数对象派发给所有订阅者,处理完成后自动调用 Clear 并回收到引用池。

4.2 场景、UI、事件联动的完整调用链

把前面三块串起来,样例的完整流程是:

  1. 玩家在主菜单点击“开始战斗”按钮。
  2. 按钮处理器调用SceneChanger.GoToBattleScene()
  3. SceneComponent 开始异步加载 BattleScene.unity,加载成功后派发 LoadSceneSuccess 事件。
  4. 场景脚本在 LoadSceneSuccess 回调里创建波次管理器,并调用UIBattleHUD打开。
  5. 波次管理器生成第一波敌人,派发 BattleStart 事件。
  6. UIBattleHUD 收到 BattleStart 事件,刷新敌人数量文本。

这个链路里,场景脚本、UI、波次管理器之间没有直接引用对方。场景脚本不知道 UI 怎么显示,UI 也不知道敌人从哪里生成,全靠事件把消息解耦开。对整个团队来说,每个人可以并行开发自己负责的模块,只要把事件参数约定清楚就行。

5. 常见问题与排查实录

5.1 场景切换后 UI 消失不见

现象:从主菜单切到战斗场景,刚打开的 UIBattleHUD 能显示,但切回主菜单后,旧 UI 全部消失。

原因:多数情况是因为 UI Prefab 被做成了场景中的普通对象,而不是通过 UIForm 打开。场景一切换,Unity 会把旧场景里的对象销毁,UI 自然没了。

解决方法:UI 必须走UIComponent.OpenUIForm方式打开。GF 的 UIManager 会把 UI 实例挂在独立节点下管理,用引用计数和对象池控制生命周期,不随场景销毁。UI 的显示与关闭,只应该由CloseUIForm或框架的暂停/恢复机制控制。

5.2 事件回调被触发多次

现象:第一次打开 UIBattleHUD 后,往 UI 里订阅了事件,关闭后再打开,发现事件触发了两次,有时三次。

原因:这是典型的 UI 关闭时没有退订事件。GF 的事件系统不会因为 UI 被 Close 就自动清理订阅,回调会一直保留在事件注册表中。多次开合之后,同一处理方法被注册了多次。

解决方法:严格保持订阅和退订对称。OnOpen 订阅,OnClose 退订;OnInit 注册,OnDestroy 注销。如果界面可能被强制销毁,还要在 OnRecycle 里兜底退订一次,防止对象池重新使用时残留脏回调。

5.3 异步回调后事件参数被清空

现象:在某个事件回调里启动了一个协程或异步操作,协程里读取事件参数的字段,发现值变成默认值了。

原因:GF 的事件参数对象在事件派发完成后,会立刻执行 Clear 并归还引用池。如果你把它的引用保存下来,在异步逻辑里再读取,读到的已经是归还池后可能被复用的对象,内容不可靠。

解决方法:不要保存事件参数的引用。需要跨帧使用数据时,在回调里立刻把字段拷贝到局部变量或自己的数据结构中。样例里如果要在协程里延迟刷新战斗文本,也应该先把 EnemyCount 存到 UI 自己的字段里,再等协程结束使用。

5.4 UI 点击穿透问题

现象:打开一个弹窗后,点击弹窗的空白区域,下层场景里的按钮也被点到了。

原因:GF 的 UIGroup 只负责层级,不负责点击事件阻断。Unity UI 的事件穿透取决于 GraphicRaycaster 和遮罩处理,常见的CanvasGroup.blocksRaycasts属性如果没设置,下层 UI 依然可点。

解决方法:在弹窗类界面的根节点上加一个全屏半透明遮罩 Image,并设置raycastTarget = true;或者维护一套“当前顶层 UI 组”的规则,在非顶层组上关闭GraphicRaycaster。这套逻辑可以放在自定义 UI 基类里统一处理,避免每个弹窗手动配。

5.5 问题速查表

问题现象可能原因解决方案
场景切换后 UI 消失UI 挂在场景对象上,未通过 UIForm 管理全部界面走 OpenUIForm 打开
事件回调被触发多次UI 关闭时未退订事件OnOpen 与 OnClose 保持订阅/退订对称
异步逻辑里事件参数为默认值事件参数已被清除归还对象池回调内立刻拷贝字段,不要保存参数引用
弹窗点击穿透到底层 UI缺少遮罩或 GraphicRaycaster 未管理弹窗加全屏遮罩,按 UI 组关闭射线检测
调用 LoadScene 后无任何回调忘记订阅加载事件或 userData 不匹配先订阅事件,再传入固定 userData 作为标识
UI 打开时报找不到 UIGroup未提前创建对应分组初始化阶段创建默认组和弹窗组,并设置 depth

6. 后续可以继续扩展的方向

样例跑通只是第一步。我个人在实际项目里,通常还会在这个基础上加三样东西:一是把事件参数统一收敛到一个“事件类型静态类”里管理 EventId,避免每个模块随便定义;二是给 UI 基类封装通用打开/关闭动画,让所有界面自动播放淡入淡出;三是把场景切换包装成“加载场景 + 显示 loading UI + 初始化场景数据”的标准化流程,这样后续新增场景时,不用复制粘贴一堆代码。

另外再分享一个小技巧:调试 GF 工程时,打开 Unity 的 Console 面板,把 Log 过滤到 GameFramework 相关标签,能明显加快问题定位。如果场景加载失败,框架错误日志里通常带着资源路径和失败原因,比自己去猜快很多。

这套场景切换、UI、事件的样例代码,本质上是 GF 的“最小可运行骨架”。把骨架理解透了,再看 StarForce 那种大型工程,就不会觉得满天飞的事件和 UI 难以追踪。希望这份实操记录能帮你少踩几个坑,顺利跑通自己的第一个 GF 工程。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询