木匣子

Web/Game/Programming/Life etc.

使用 PureMVC 构建游戏项目 Part III

期待已久的 Part III 来了,不过这次的标题并没有 Cocos2d-js ——因为这两个月我换了工作,重心已经从 Cocos2d-js 转移到 Unity3d + ulua 了。

不过我并没有放弃 PureMVC,并且在新的项目实践中验证了这个框架强大的平台无关性。正如 PureMVC 的理念那样,这是一个纯粹的基于设计模式的框架,内部使用观察者模式构建的消息机制,并不依赖于平台的消息机制。

之所以这么晚才写 Part III 是因为我希望在新项目中多总结提炼一些值得思考的问题,现在是时候跟大家分享了。虽然本文使用 Lua 语言的举例子,但是框架思路本身是通用的。

0x00 PureMVC.lua

虽然 PureMVC 官方并没有提供 Lua 版本,但是在 github 上可以找到两个国人移植的版本:

  • https://github.com/Ravior/puremvc-lua-framework
  • https://github.com/themoonbear/puremvc-lua

我使用的是后者,并在此基础上做了一些修订:

  • https://github.com/themoonbear/puremvc-lua/pull/2
  • https://github.com/themoonbear/puremvc-lua/pull/3

此外我自己移植了 statemachine 但是在实践中发现使用 statemachine 管理界面切换并无多大必要,所以本文就不深入讨论状态机了。

0x01 WHY MVC

为什么要在游戏中使用 MVC ?回顾 Part IPart II,我们试图使用 PureMVC 搭建的并不是游戏的核心部分(例如卡牌游戏的战斗场景)——而是游戏的外围系统(登录、选服、任务、邮件、编队、装备、活动等)。

MVC 能够提供一个良好的框架来搭建除实时交互部分之外的更接近应用层面的非实时交互部分。至于游戏的核心部分,我本人认为使用 MVC 模式去实现一个实时系统并不是一个合理的方式。幸好这部分的内容正好可以剥离在 MVC 之外,采用沙盒的形式向 MVC 传递少量关键操作信息即可(战斗暂停,返回,结束等)。

0x02 HOW MVC

重新回顾一下 puremvc,同时对 Part I 中的片面理解做一些纠正。

Facade

Facade 是 puremvc core 的门面,实际上它只是维护 model/view/controller 这三个容器,对 proxy/mediator/command 做增(register)删(remove)查(retrieve/has)操作。同时提供一个 sendNotification 方法在整个 core 中广播消息。

例如启动游戏这一步,就是创建一个 puremvc core 然后在 controller 中绑定一个 StartupCommand 命令,最后广播 STARTUP 消息,触发 StartupCommand 的执行:

game = pm.Facade.getInstance('game')
game:registerCommand(GAME.STARTUP, StartupCommand)
game:sendNotification(GAME.STARTUP)

在 puremvc 外,总是可以使用 game:sendNotification(msg, args) 向 core 广播消息。在 puremvc 内 proxy/mediator/command 都是 Notifer 的子类,所以他们的子类都可以使用 self:sendNotification(msg, args) 向 core 广播消息。

Model

Model 是 Proxy 的容器,而 Proxy 是存放游戏数据的地方。
可以按模块将 Model 划分如下:

  • PlayerProxy(玩家数据)
  • MailProxy(邮件数据)
  • TaskProxy(任务数据)
  • TeamProxy(角色数据)
  • BagProxy(背包数据)
  • StageProxy(关卡数据)

此外,我们应该对游戏使用的基本数据对象进行封装,创建相应的值对象(Value Object,简称 VO),例如:Player/Mail/Task/Hero/Item/Stage/…

这些值对象存放着相对应的服务端数据,并且提供一些简单的接口来处理对应逻辑,如:

Player = class("Player", BaseVO)

function Player:ctor(data)
    self.id = data.id
    self.level = data.level
    self.exp = data.exp
    self.coin = data.coin
    self.gem = data.gem
end

function Player:hasEnoughCoin(coin)
    return self.coin >= coin
end

function Player:canLevelUp(exp)
    -- TODO: test if player can level with adding exp
    return false
end

BaseVO 是所有 Value Object 的基类,实现了一些例如 clone() 之类的方法。

除了 PlayerProxy 只持有一个 Player 值对象外,其它 Proxy 往往是以集合的形式持有一批同类的值对象。所以他们的行为很像容器,可以是数组,栈或者哈希表。并且提供了增删改查值对象的功能。当容器内的值对象被增删改时,会向系统广播相应的信息,并在这些信息中携带相应值对象的副本(clone):

BagProxy = class("BagProxy", Proxy)

function BagProxy:ctor()
    -- initial data
    self.super.ctor(self, {}, self.__cname)
end

BagProxy.ITEM_ADDED = "BagProxy:ITEM_ADDED"
BagProxy.ITEM_UPDATED = "BagProxy:ITEM_UPDATED"
BagProxy.ITEM_REMOVED = "BagProxy:ITEM_REMOVED"

function BagProxy:addItem(item)
    assert(self.data[item.id] == nil, "item already exist, use updateItem() instead.")

    self.data[item.id] = item:clone()
    self:sendNotification(BagProxy.ITEM_ADDED, item:clone())
end

function BagProxy:updateItem(item)
    assert(self.data[item.id], "item should exist.")

    self.data[item.id] = item:clone()
    self:sendNotification(BagProxy.ITEM_UPDATED, item:clone())
end

function BagProxy:removeItem(item)
    local item = self.data[item.id]
    assert(item, "item should exist")

    self.data[item.id] = nil
    self:sendNotification(BagProxy.ITEM_REMOVED, item)
end

function BagProxy:getItemById(itemId)
    if self.data[itemId] then
        return self.data[itemId]:clone()
    end
    return nil
end

为什么要大量使用 clone 呢?其实这是一种防御式编程。由于这些值对象随时可以被创建或引用,很可能在写代码的时候不经意间修改了值对象,但是忘了更新到 Proxy 中;或者在添加到 Proxy 后又去修改同一个值对象,系统的其它部分察觉不到修改,神不知鬼不觉就埋下了 bug 。

使用 clone 的好处是你可以随意地修改并使用值对象,直接看到修改后的效果,而在验证逻辑正确后,再使用 Proxy 的 update 方法通知整个系统某个值对象的变化。例如在界面中操作经验药水与人物,经验药水和人物的值对象都是副本,可以随意修改 count 或 exp 字段,界面根据这些值对象当前的数据进行更新即可看到效果。如果玩家取消这些操作,只需要重新去 Proxy 里取出原始的值对象,重新更新界面即可还原这些修改。可见实现值对象的深拷贝是非常安全且有意义的。

相关阅读:Lua 深拷贝的实现

View

View 是 Mediator 的容器。Mediator 负责维护视图组件(View Component)侦听来自视图的事件(例如触发某个按钮),并传发到系统中,以执行相应的 Command 。另外,Mediator 还关注来自系统的消息,并调用视图提供的接口更新相应的界面。

在开发的过程中按功能编写相应的 Mediator 和 ViewComponent:

  • MainMediator / MainScene (主界面)
  • MailMediator / MailLayer (邮件界面)
  • TaskMediator / TaskScene (任务界面)
  • StageMediator / StageScene (关卡界面)

场景级界面(Scene)通常互不共存,而层级界面(Layer)可以层叠于场景级界面之上。但每个 Meidator 只能有一个实例在系统中,切换场景或关闭后被移除。

将 Mediator 与界面分离有非常多的好处。Mediator 只关心业务逻辑,而具体的界面长什么样,它并不需要操心。而视图可以专心处理各种复杂的界面效果,最终只把与业务逻辑有关的事件传达给 Mediator,并提供一些接口给 Mediator 用来处理 Model 层数据变化时要接收的值对象。前期开发的时候,如果没有具体的界面,只需要完成接口操作即可,实际界面可以在后期随时变更。下面是登录模块的示例:

LoginMediator = class("LoginMediator", BaseMediator)

LoginMediator.ON_LOGIN = "LoginMediator:ON_LOGIN"
LoginMediator.ON_SERVER = "LoginMediator:ON_SERVER"

function LoginMediator:onRegister()
    self.viewComponent.event:connect(LoginMediator.ON_LOGIN, function(e, user)
        self:sendNotification(GAME.LOGIN, user)
    end)

    self.viewComponent.event:connect(LoginMediator.ON_SERVER, function(e, user)
        self:sendNotification(GAME.LOGIN, user)
    end)
end

function LoginMediator:onRemove()
    self.viewComponent.event:clear(LoginMediator.ON_LOGIN)
    self.viewComponent.event:clear(LoginMediator.ON_SERVER)
end

function LoginMediator:listNotificationInterests()
    return {
        GAME.USER_LOGIN_SUCCESS,
        GAME.USER_LOGIN_FAILED
    }
end

function LoginMediator:handleNotification(note)
    local name = note:getName()
    local body = note:getBody()

    if name == GAME.USER_LOGIN_SUCCESS then
        local serverProxy = getProxy(ServerProxy)
        local servers = serverProxy:getServers()
        self.viewComponent:updateServerList(servers)

    elseif name == GAME.USER_LOGIN_FAILED then
        self.viewComponent:displayLoginError()

    end
end

LoginMediator 关心界面点击登录按钮时提交上来的用户值对象,然后使用将这个对象交给相应的 Command 完成登录;接着影响登录成功的消息,加载并显示服列表,如果登录失败,弹出相应的失败信息。

此外 LoginMediator 还关心玩家从服务器列表选择了哪个服务器,然后将这个服务器值对象转发给系统相应的 Command 完成进入服务器操作。

实际上 Mediator 承担着一份系统与视图之间的接口协议的职责。从上面两个需求,我们很快知道登录界面要做哪些事情:

  • 当用户点击注册按钮时向 LoginMediator 发送 LoginMediator.ON_LOGIN 事件,并传递从界面上收集来的用户信息;
  • 提供一个 updateServerList 方法,当有新的服列表可用时,传入服列表数据,并更新界面;
  • 当用户选择一个服务器的时候,向 LoginMediator 发送 LoginMediator.ON_SERVER 事件,并传递相应的服务器信息;

于是一个临时的登录界面就可以出炉了:

local LoginScene = class("LoginScene", BaseUI)

function LoginScene:onEnter()

    -- test login
    self.event:emit(LoginMediator.ON_LOGIN, User.New({
        username = "test"
        password = "test"
    }))
end

function LoginScene:updateServerList(servers)

    -- test on server
    self.event:emit(LoginMediator.ON_SERVER, servers[1])
end

function LoginScene:displayLoginError()
    print("can not login to server")
end

注意到 Mediator 和视图之间的通讯并不是通过 sendNotification,而是私有的一套机制。在这里我使用的是 LuaNotify。(使用 javascript 的同学可以试试 emitter ,毕竟 cocos2d-js 的那个 CCEventManager 太难用了。)

Controller

Controller 是 Command 的容器。注册 Command 的时候,只把 Command 类与关联的消息绑定起来,等到消息被触发时,才实例化对应的 Command 并执行 execute 方法。

在 Part I 中由于认识不足,忽视了 Command 的潜在能力,将大量本该放在 Command 中的业务逻辑塞到了 Proxy 和 Mediator 里面。使得代码条理不清晰,Proxy 与 Mediator 偶合度也提高了很多。

由于是手机网游,大多数 Command 会触发网络消息,并注册异步的回调函数,Command在执行完后并没有被 GC,而是等回调函数执行完后才被垃圾回收。以下是装备打造的示例:

local BuildCommand = class('BuildCommand', SimpleCommand)

function BuildCommand:execute(note)
    local data = note:getBody()

    local consumables = {
        gold = data.gold,
        oil = data.oil,
        silver = data.silver,
    }

    -- check resources
    for k, v in pairs(consumables) do
        if not (50 <= v and v <= 999) then
            alert(k .. " is not in range [50, 999]: " .. v)
            return
        end
    end

    local playerProxy = self.facade:retrieveProxy(PlayerProxy)
    local player = playerProxy:getData()

    if not player:isEnough(consumables) then
        alert("resources are not enough")
        return
    end

    NetManager:send(PROTOCOL.BUILD, consumables, function(data)
        if data.result == 0 then
            player:consume(consumables)
            playerProxy:updatePlayer(player)

            local bagProxy = self.facade:retrieveProxy(BagProxy)
            local item  = Equipment.new(data.item)
            bagProxy:addItem(item)

            self:sendNotification(GAME.BUILT, item)
        else
            print("can not build equipment: " + data.result)
        end
    end)
end

装备打造命令,传入使用的资源数量,并按策划案做一些本地检查,如果资源不足,直接就终止操作并进行提示,而无须发送到服务端再检查。如果正常,则调用网络接口发送协议内容,等待回调。

在回调中处理最终的数据变化,并使用 Proxy 更新数据,Proxy 会把这些变化通知到系统中。此外,对于重要消息,可以在最后额外作一些通知。以便让相关的界面作一些视觉上的效果。

0x03 To be continue

有人说没有必要把界面拆成 Mediator 和 View Component,直接把所有逻辑都放在 Mediator 就好了。由于篇幅过长,这里暂时不予反驳,待我开个 Part IV 详细介绍场景管理时解释。到时候会详细剖析 Part II 提到的场景栈重构。


2015/08/25 updated: