木匣子

Web/Game/Programming/Life etc.

使用 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 处理,并以异步的方式串连起来。切换场景的过程如下:

  1. 首先是取出当前场景 Context,按层序遍历场景树,然后逆序逐个删除,由于删除层结点的过程有可能存在动画效果,所以这个过程是异步的。这部分的代码由 RemoveLayersCommand 配合 SceneManager 实现,最终留下当前场景的根结点;
  2. 场景的切换可以有不同的方式(详见 SceneManager 部分),以其中一种方式载入下一个场景的根节点;
  3. 对新场景的根结点(context)进行层序遍历,逐一还原每一个层结点,由于加载层结点的过程可能存在动画效果,所以这个过程也是异步的。这部分的代码由 LoadLayersCommand 配合 SceneManager 实现;
  4. 切换场景过程完成。
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 的一个引用。通过这个引用,我们需要维护这个资源的生命周期:

  1. load() 开始加载资源(方法)
  2. LOADING 加载进度(事件)
  3. ON_LOADED 加载完成(事件)
  4. attach() 绑定父结点(方法)
  5. init() 初始化(方法)
  6. enter() 进场并播放进场动画(方法)
  7. DID_ENTER 进场完成(事件)
  8. onEnter() 进场完成后回调(方法)
  9. exit() 退场并播放退场动画(方法)
  10. onExit() 退场前回调(方法)
  11. DID_EXIT 退场完成(事件)
  12. 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

下图以登录场景切换成主场景为例,展示了交叉场景切换的时序图:

SceneMgr.png

图片使用 plantuml 绘制,另附 sublime text 插件:diagram