使用 PureMVC 构建游戏项目 Part IV 之场景管理
许多游戏引擎已经提供了原生的场景管理功能,例如 Cocos2d-x 的 CCDirector.replaceScene()
或者 Unity3d 的 Application.loadLevel()
。场景切换的本质是将原有的对象删除并从内存中释放,腾出空间然后加载新的场景资源。但这并不能满足当下的一些需求,简单的从将一个场景销毁,进入下一个场景,而想要点击返回的时候,回退到原来的界面,并不是简单重新加载一下原来的界面就可以了,还需要还原当时现场的一些关键状态(详细的需求见 Part I)。
所谓的关键状态就是,例如:
你在进入装备界面,默认显示所有装备(type = all),然后你点击界面上的分类按钮,按类型B(type = B)进行筛选。然后你从装备界面跳转到关卡界面(准备去相应的关卡打装备),打完后你想回到装备界面时,它还按类型 B 而不是默认 all 进行筛选。那么 type 就是一个关键状态。
在手机游戏中,场景指的是那些独占整个屏幕的模块;此外还有一些模块是浮动于场景之间的,类似于层,可以弹出和关闭。在 Part III 的 Mediator 部分,我描述了两种类型的视图组件(ViewComponent):一种是 Scene;另一种是 Layer。在 Part I 中,我将这两种类型的 View 区别开来:NestMediator 和 SubNestMediator。由于我觉得这些 Mediators 是一种嵌套关系(类似 CCNode),所以在做场景恢复的时候,我认为等 Mediator 创建并加载 ViewComponent 后遍历子节点,逐层恢复就可以了。但这导致 Part II 中提到的 Mediators 相互引用,并且有大量场景切换和还原的逻辑被放在 NestMediator 中。
后来我重新审视了这个设计,发现虽然可行,但有太多不合理的地方。现将新的方案整理如下:将状态和场景树转移到 Contexts 中储存,使用 Commands 来处理 Mediator 和 ViewComponent 的注册与销毁,而场景实际切换过程交给 SceneManager 处理。
¶0x00 Context & ContextProxy
¶Context
在 Part II 中我提出了使用 Context 来储存场景状态以及嵌套关系:Context 描述了一个场景或者层使用哪个 Mediator 和 ViewComponent 创建节点,包含哪些关键状态(data),并实现了简单的嵌套关系。
Context = class("Context")
function Context:ctor(data)
self.mediatorClass = data.mediatorClass
self.viewComponentClass = data.viewComponentClass
self.data = {}
self.parent = nil
self.children = {}
end
function Context:addChild(context)
assert(isa(context, Context), "should be an instance of Context")
assert(context.parent == nil, "context already has parent")
context.parent = self;
table.insert(self.children, context)
end
function Context:removeChild(context)
assert(isa(context, Context), "should be an instance of Context")
for i, v in ipairs(self.children) do
if v == context then
context.parent = nil
return table.remove(self.children, i)
end
end
return nil
end
用以下代码可以描述「打开一个主场景,带有一个弹出的邮件界面」:
local mainContext = Context.new({
mediatorClass = MainMediator,
viewComponentClass = MainScene
})
local mailboxContext = Context.new({
mediatorClass = MailboxMediator,
viewComponentClass = MailboxLayer
})
mainContext:addChild(mailboxContext)
game.facade:sendNotification(GAME.LOAD_SCENE, mainContext)
¶ContextProxy
每打开一个场景,就将场景树的根结点放到 ContextProxy 中,当点击返回按钮的时候,将当前场景 Context 出栈,再取出下一个 Context 重新加载,即可以实现返回场景。所以我们需要实现一个栈式结构的 Proxy 来维护场景栈。
ContextProxy = class('ContextProxy', Proxy)
function ContextProxy:getCurrentContext()
return self.data[#self.data]
end
function ContextProxy:pushContext(context)
table.insert(self.data, context)
end
function ContextProxy:popContext()
return table.remove(self.data)
end
function ContextProxy:cleanContext()
self.data = {}
end
function ContextProxy:getContextCount()
return #self.data
end
¶0x01 GameMediator
游戏启动后,会注册一个 GameMediator 作为场景的控制中枢,实际上它是一个工厂,根据场景的名字创建 Context 实例并把相应的 Mediator 与 ViewComponent 关联起来:
local GameMediator = class('GameMediator', Mediator)
function GameMediator:listNotificationInterests()
return {
GAME.GO_TO_SCENE
}
end
function GameMediator:handleNotification(note)
local name = note:getName()
local data = note:getBody()
local context = Context.new()
-- load context data
for k, v in pair(data)
context.data[k] = v
end
if name == GAME.GO_TO_SCENE then
local type = note:getType()
if type == SCENE.MAIN then
context.mediatorClass = MainMediator
context.viewComponentClass = MainScene
elseif type == SCENE.EQUIPMENT then
context.mediatorClass = EquipmentMediator
context.viewComponentClass = EquipmentScene
-- elseif type == ... then
-- context.mediatorClass = ...
-- context.viewComponentClass = ...
--
-- ...
end
end
self:sendNotification(GAME.LOAD_SCENE, context)
end
在需要切换场景的时候,广播以下消息即可:
game.facade:sendNotification(GAME.GO_TO_SCENE, {
type = "all"
}, SCENE.EQUIPMENT)
其中第二个参数会被合并到 context.data 中,作为状态被传递到 Mediator 使用,使用 context.data 在场景之间传递信息非常方便。在 EquipmentMediator 要使用 context.data 中的信息只要访问 self.contextData 即可。最后会广播并调用 LoadSceneCommand 来创建和切场景。
¶0x02 Commands
控制场景的命令主要有这几个:
- LoadSceneCommand - 清空当前场景树,并加载新场景树
- LoadLayersCommand - (递归)加载除根节点(场景)外的所有子节点(层)
- RemoveLayersCommand - (递归)移除根节点(场景)外的所有子节点(层)
- BackSceneCommand - 返回前一场景
与之对应的消息如下:
- GAME.LOAD_SCENE
- GAME.LOAD_LAYERS
- GAME.REMOVE_LAYERS
- GAME.GO_BACK
¶LoadSceneCommand
LoadSceneCommand 只处理 Mediators 和 ViewComponents 的创建和销毁。而把 ViewComponents 的切换过程交给 SceneManager 处理,并以异步的方式串连起来。切换场景的过程如下:
- 首先是取出当前场景 Context,按层序遍历场景树,然后逆序逐个删除,由于删除层结点的过程有可能存在动画效果,所以这个过程是异步的。这部分的代码由 RemoveLayersCommand 配合 SceneManager 实现,最终留下当前场景的根结点;
- 场景的切换可以有不同的方式(详见 SceneManager 部分),以其中一种方式载入下一个场景的根节点;
- 对新场景的根结点(context)进行层序遍历,逐一还原每一个层结点,由于加载层结点的过程可能存在动画效果,所以这个过程也是异步的。这部分的代码由 LoadLayersCommand 配合 SceneManager 实现;
- 切换场景过程完成。
LoadSceneCommand = class('LoadSceneCommand', SimpleCommand)
function LoadSceneCommand:execute(note)
local data = note:getBody()
local context = data.context
assert(isa(context, Context), "should be an instance of Context")
local viewComponent = context.viewComponentClass.new()
assert(isa(viewComponent, BaseView), "should be an instance of BaseView: " .. viewComponent.__cname)
local function onTransFinish(viewComponent)
local mediator = context.mediatorClass.new(viewComponent)
mediator:setContextData(context.data)
self.facade:registerMediator(mediator)
self:sendNotification(GAME.LOAD_LAYERS, {
context = context
})
end
local contextProxy = getProxy(ContextProxy)
local fromViewComponent;
local function nextScene()
if context.transType == Context.TRANS_TYPE.ONE_BY_ONE then
SceneManager.transOneByOne(fromViewComponent, viewComponent, onTransFinish)
else
SceneManager.transCross(fromViewComponent, viewComponent, onTransFinish)
end
if context.cleanStack then
contextProxy:cleanContext()
end
contextProxy:pushContext(context)
end
local function onRemoved()
function()
local prevMediator = self.facade:removeMediator(prevContext.mediator.__cname)
fromViewComponent = prevMediator:getViewComponent()
nextScene()
end
end
local prevContext = data.prevContext or contextProxy:getCurrentContext()
if prevContext ~= nil then
assert(isa(prevContext, Context), "should be an instance of Context")
self:sendNotification(GAME.REMOVE_LAYERS, {
context = prevContext,
onFinish = onRemoved
})
else
nextScene()
end
end
¶RemoveLayersCommand
RemoveLayersCommand 有两个参数,一个是要进行移除的 context 对象。当 context 是一个场景结点时(context.parent == nil),只对其子结点进行逆层序移除;否则连同 context 对应的层结点一起移除。另一个参数是一个回调函数,在清除完成后被调用。
local RemoveLayersCommand = class('RemoveLayersCommand', SimpleCommand)
function RemoveLayersCommand:execute(note)
local data = note:getBody()
local context = data.context
assert(isa(context, Context), "should be an instance of Context")
-- Breadth-First-Search
local open = { context }
local close = {}
while #open > 0 do
local context = table.remove(open, 1) -- FIFO
table.insert(close, context)
for _, v in ipairs(context.children) do
table.insert(open, v)
end
end
if context.parent == nil then
-- do not remove root context
table.remove(close, 1)
else
context.parent:removeChild(context)
end
-- remove context tree
local removeState, status, iter
removeState = coroutine.create(function()
while #close > 1 do
local context = table.remove(close) -- LIFO
local subMediator = self.facade:removeMediator(context.mediator.__cname)
local removed = false
local function onRemoved()
coroutine.resume(removeState)
removed = true
end
SceneManager.remove(subMediator:getViewComponent(), onRemoved)
if not removed then coroutine.yield() end
end
-- do the callback
if data.callback then data.callback() end
end)
-- start remove
coroutine.resume(removeState)
end
这里使用 Lua 的协程来组织异步场景卸载,如果用 javascript 的话,可以考虑 async.js 或者将来使用 es6 提供的 generator 。
¶LoadLayersCommand
LoadLayersCommand 有三个参数,一个是要进行层序加载的 context 对象,一个是可选的 parentContext 。当 context 是场景结点时(parentContext = nil)只对其子结点进行载入;否则连同 context 一起加载。另外还有个是完成时的回调函数。
local LoadLayersCommand = class('LoadLayersCommand', SimpleCommand)
function LoadLayersCommand:execute(note)
local data = note:getBody()
local context = data.context
assert(isa(context, Context), "should be an instance of Context")
local open = {}
local restoreLayers, status, iter
restoreLayers = coroutine.create(function()
-- restore context tree
while #open > 0 do
local context = table.remove(open, 1)
for _, v in ipairs(context.children) do
table.insert(open, v)
end
local parentContext = context.parent
local parentMediator = self.facade:retrieveMediator(parentContext.mediator.__cname)
local parentViewComponent = parentMediator:getViewComponent()
local viewComponent = context.viewComponent.New()
local finish = false
local function onFinish()
local mediator = context.mediator.New(viewComponent)
mediator:setContextData(context.data)
self.facade:registerMediator(mediator)
coroutine.resume(restoreLayers)
finish = true
end
SceneManager.overlay(parentViewComponent, viewComponent, onFinish)
if not finish then coroutine.yield() end
end
if data.callback then data.callback() end
end)
local parentContext = data.parentContext
if parentContext ~= nil then
assert(isa(parentContext, Context), "should be an instance of Context")
if parentContext:getContextByMediator(context.mediator) then
print("Mediator already exist: " .. context.mediator.__cname)
return
end
table.insert(open, context)
parentContext:addChild(context)
else
for _, v in ipairs(context.children) do
table.insert(open, v)
end
end
-- start restore
coroutine.resume(restoreLayers)
end
¶BackSceneCommand
在 ContextProxy 部分,我们说到「将当前场景 Context 出栈,再取出下一个 Context 重新加载,即可以实现返回场景。」,除此之外,我们还可以在返回时传递一些 context.data 。这便是 BackSceneCommand 所要做的全部事情:
local BackSceneCommand = class('BackSceneCommand', SimpleCommand)
function BackSceneCommand:execute(note)
local data = note:getBody()
local contextProxy = getProxy(ContextProxy)
if contextProxy:getContextCount() > 1 then
local currentContext = contextProxy:popContext()
local prevContext = contextProxy:popContext()
prevContext:extendData(data)
self:sendNotification(GAME.LOAD_SCENE, {
prevContext = currentContext,
context = prevContext
})
end
end
¶0x03 Base View Component
在 Commands 中随处可见 SceneManager 的踪影。但是在介绍 SceneManager 之前,首先要了解一下基本的 ViewComponent 的生命周期是什么样的,然后再把两个 ViewComponent 的生命周期重叠或连接,就可以表现场景切换。
一般而言,我们会把一个模块做成一个场景或层,使用 UI 编辑器将其打包成一个独立的资源。然后将它作为 ViewComponent 的一个资源载入使用。所以我们并不需要在 ViewComponent 层面区分场景或层。
与 Part I 的设计不同的是,现在的 ViewComponent 并不是一个引擎中的结点(Unity3d 的 GameObject 或者 Cocos2d 的 CCNode)。引擎中的结点只是 ViewComponent 的一个引用。通过这个引用,我们需要维护这个资源的生命周期:
- load() 开始加载资源(方法)
- LOADING 加载进度(事件)
- ON_LOADED 加载完成(事件)
- attach() 绑定父结点(方法)
- init() 初始化(方法)
- enter() 进场并播放进场动画(方法)
- DID_ENTER 进场完成(事件)
- onEnter() 进场完成后回调(方法)
- exit() 退场并播放退场动画(方法)
- onExit() 退场前回调(方法)
- DID_EXIT 退场完成(事件)
- detach() 从父结点移除(方法)
我们需要一种方式,将这些事件推送给 SceneManager,这样当两个场景进行切换的时候,就能自如控制。在 Part III 中的 View 部分,我提到的 LuaNotify 或 emitter.js 就是用来做这件事情的。以下是 BaseViewComponent 的参考实现:
BaseViewComponent = class("BaseViewComponent")
local Event = require('Framework.notify.event')
-- 用于场景切换的事件
BaseViewComponent.LOADED = "BaseViewComponent:LOADED"
BaseViewComponent.DID_ENTER = "BaseViewComponent:DID_ENTER"
BaseViewComponent.DID_EXIT = "BaseViewComponent:DID_EXIT"
-- 返回事件
BaseViewComponent.ON_BACK = "BaseViewComponent:ON_BACK"
-- 层关闭事件
BaseViewComponent.ON_CLOSE = "BaseViewComponent:ON_CLOSE"
function BaseViewComponent:Ctor()
self.event = Event.New() -- 事件
self._isLoaded = false -- 是否加载完成
self._go = nil -- game object 引用
self._tf = nil -- transform 引用
end
-- 获取 UI 名称,由子类实现
function BaseViewComponent:getUIName()
return nil
end
-- 开始加载
function BaseViewComponent:load()
-- 获取 UI 名称
local uiName = self:getUIName()
if uiName ~= nil then
self:LoadUI(uiName, "OnUILoaded")
else
self:OnUILoaded()
end
end
-- 是否加载完成
function BaseViewComponent:isLoaded()
return self._isLoaded
end
-- UI 加载完成后被调用
-- @param go 来自 Unity3d 的 Game Object
function BaseViewComponent:OnUILoaded(go)
-- 加载完成
self._go = go
self._tf = go and go.transform
self._isLoaded = true
-- 执行一些初始化工作
self:init()
-- 通知加载完成
self.event:emit(BaseViewComponent.LOADED)
end
-- 由子类实现的一些初始工作
function BaseViewComponent:init() end
-- 加载完成后进入场景
function BaseViewComponent:enter()
-- 子类可以覆盖该方法,播放一段动画
-- 然后通知完成入场
self.event:emit(BaseViewComponent.DID_ENTER)
-- 通知后会执行 mediator 的 onRegister
-- 入场后执行的一些操作
self:onEnter()
end
-- 由子类实现的入场后操作
function BaseViewComponent:onEnter() end
-- 由子类实现的出场前操作
function BaseViewComponent:onExit() end
-- 退出场景
function BaseViewComponent:exit()
-- 执行一些退场前操作
self:onExit()
-- 子类可以覆盖该方法,播放一段动画
-- 然后通知退场完成
self.event:emit(BaseViewComponent.DID_EXIT)
self:detach()
end
-- 添加到父结点
function BaseViewComponent:attach(parent)
if self._tf ~= nil and parent._tf ~= nil then
self._tf:SetParent(parent._tf, false)
end
end
-- 从父结点删除
function BaseViewComponent:detach(parent)
-- 销毁 Game Object
if self._go ~= nil then
Object.Destroy(self._go)
end
self._go = nil
self._tf = nil
self._isLoaded = false
-- 清空事件队列
self.event:clear()
end
¶0x04 SceneManager
SceneManager 是一个很重要组件,而且因为责任的分工已经相当合理,所以这部分的代码并不多,也不难理解。
当 ViewComponent 的生命周期可控后,给出两个 ViewComponents 要实现他们的切换就变得简单了。然而切换的过程有很多种形式,可以根据自己的需要进行扩展,一般而言有以下两种:
- 在当前场景加载下一场景,穿叉过场动画,然后删除前一场景;
- 播放当前场景的退场动画,加载下一场景(loading),播放下一场景进场动画。
除了切换场景外,由于场景中的结点是可以嵌套的,所以SceneManager 还要处理在一个 ViewComponent 上增加/删除另一个 ViewComponent 的操作。实际上就是半个切换场景的过程。
SceneManager = {}
function SceneManager.transCross(from, to, onFinishCallback)
-- TO load()
-- TO loading
-- TO loaded
-- FROM exit()
-- FROM didExit
-- TO enter()
-- TO didEnter
-- callback()
local function didEnter(e)
onFinishCallback(to)
end
local function didExit(e)
if to ~= nil then
to.event:connect(BaseViewComponent.DID_ENTER, didEnter)
to:enter()
else
didEnter()
end
end
local function onLoaded(e)
if from ~= nil then
from.event:connect(BaseViewComponent.DID_EXIT, didExit)
from:exit()
else
didExit()
end
end
if to == nil or to:isLoaded() then
onLoaded()
else
to.event:connect(BaseViewComponent.LOADED, onLoaded)
to:load()
end
end
function SceneManager.transOneByOne(from, to, onFinishCallback)
-- FROM exit()
-- FROM didExit
-- TO load()
-- TO loading
-- TO loaded
-- TO enter()
-- TO didEnter
-- callback()
local function didEnter(e)
onFinishCallback(to)
end
local function onLoaded(e)
if to ~= nil then
to.event:connect(BaseViewComponent.DID_ENTER, didEnter)
to:enter()
else
didEnter()
end
end
local function didExit(e)
if to == nil or to:isLoaded() then
onLoaded()
else
to.event:connect(BaseViewComponent.LOADED, onLoaded)
end
end
if from ~= nil then
from.event:connect(BaseViewComponent.DID_EXIT, didExit)
from:exit()
else
didExit()
end
end
function SceneManager.overlay(parent, child, onFinish)
-- CHILD load()
-- CHILD loading
-- CHILD loaded
-- CHILD attach()
-- CHILD enter()
-- CHILD didEnter
-- callback()
local function didEnter(e)
onFinish(child)
end
local function onLoaded(e)
if child ~= nil then
child:attach(parent)
child.event:connect(BaseViewComponent.DID_ENTER, didEnter)
child:enter()
else
didEnter()
end
end
if child == nil or child:isLoaded() then
onLoaded()
else
child.event:connect(BaseViewComponent.LOADED, onLoaded)
child:load()
end
end
function SceneManager.remove(viewComponent, onFinishCallback)
-- CHILD exit()
-- CHILD didExit
-- callback()
if viewComponent ~= nil then
if onFinishCallback then
viewComponent.event:connect(BaseViewComponent.DID_EXIT, onFinishCallback)
end
viewComponent:exit()
end
end
¶0x05 Base Mediator
整篇文章似乎都没有其它 Mediator 什么事。但是我们发现 GameMediator 只实现了场景级的 Mediator 的切换,如果想在指定的 Mediator 上嵌套子 Mediator,还需要做一些工作:
BaseMediator = class('BaseMediator', Mediator)
function BaseMediator:ctor(viewComponent)
BaseMediator.super.ctor(self, nil, viewComponent)
end
-- 注册 Mediator
function BaseMediator:onRegister()
-- 事件队列
self.event = {}
self:bind(BaseViewComponent.ON_BACK, function(e)
self:sendNotification(GAME.GO_BACK)
end)
self:bind(BaseViewComponent.ON_CLOSE, function(e)
local contextProxy = getProxy(ContextProxy)
local currentContext = contextProxy:getCurrentContext()
local parentContext = currentContext:getContextByMediator(self.class)
self:sendNotification(GAME.REMOVE_LAYERS, {
context = parentContext
})
end)
-- 注册时要执行的
self:didRegister()
end
-- 由子类实现注册时要执行的
function BaseMediator:didRegister() end
-- 设置上下文参数,通过 context.data 获得的参数
function BaseMediator:setContextData(data)
self.contextData = data
end
-- 订阅来自 UI 的消息
-- @param event 事件名
-- @param callback 回调函数
function BaseMediator:bind(event, callback)
-- 绑定 UI 事件回调
self.viewComponent.event:connect(event, callback)
-- 将事件记录到队列,注销 mediator 时称除
table.insert(self.event, {
event = event,
callback = callback
})
end
function BaseMediator:onRemove()
-- 注销时要执行的
self:willRemove()
-- 移除关联的 UI 事件
for _, v in ipairs(self.event) do
self.viewComponent.event:disconnect(v.event, v.callback)
end
end
-- 由子类实现注销时要执行的
function BaseMediator:willRemove() end
function BaseMediator:addSubLayers(context)
local contextProxy = getProxy(ContextProxy)
local currentContext = contextProxy:getCurrentContext()
local parentContext = currentContext:getContextByMediator(self.class)
self:sendNotification(GAME.LOAD_LAYERS, {
parentContext = parentContext,
context = context
})
end
在主界需要弹出邮件子层模块的时候可以这样:
MainMediator = class("MainMediator", BaseMediator)
MainMediator.OPEN_MAIL = "MainUIMediator.OPEN_MAIL"
function MainMediator:register()
-- ...
self:bind(MainMediator.OPEN_MAIL, function(e)
local childContext = Context.new({
viewComponentClass = MailboxLayer,
mediatorClass = MailboxMediator
})
self:addSubLayers(childContext)
end)
-- ...
end
¶0x06 Summary
将原来混杂在 NestMediator 中的逻辑合理的分配到各个不同的组件。并对 ViewComponent 的生命周期进行详细的描述后,我发现这整个框架除了 ViewComponent 本身是依赖引擎的结点和资源管理以外,其它所有组件都是平台无关的。可以移植到任意平台中使用。至于如何回答 Part III 留下的问题,相信各位也有所领悟。希望这篇文章对你们有所启发 :)
¶0x07 Sequence Diagram
下图以登录场景切换成主场景为例,展示了交叉场景切换的时序图: