cocos2d-x游戏引擎核心之三——主循环和定时器

时间:2022-10-30 14:35:42

cocos2d-x游戏引擎核心之三——主循环和定时器

2014-10-28 11:40 by 小天_y, 107 阅读, 0 评论, 收藏,  编辑

一、游戏主循环
在介绍游戏基本概念的时候,我们曾介绍了场景、层、精灵等游戏元素,但我们却故意避开了另一个同样重要的概念,那就是游戏主循环,这是因为 Cocos2d 已经为我们隐藏了游戏主循环的实现。读者一定会对主循环的作用有疑问,为了解答这个问题,我们首先来讨论游戏实现的原理。
游戏乃至图形界面的本质是不断地绘图,然而绘图并不是随意的,任何游戏都需要遵循一定的规则来呈现出来,这些规则就体现为游戏逻辑。游戏逻辑会控制游戏内容,使其根据用户输入和时间流逝而改变。因此,游戏可以抽象为不断地重复以下动作:
  1 处理用户输入
  2 处理定时事件
  3 绘图
游戏主循环就是这样的一个循环,它会反复执行以上动作,保持游戏进行下去,直到玩家退出游戏。在 Cocos2d 中,以上的动作包含在 CCDirector 的某个方法之中,而引擎会根据不同的平台设法使系统不断地调用这个方法,从而完成了游戏主循环。
现在我们回到 Cocos2d-x 游戏主循环的话题上来。上面介绍了 CCDirector 包含一个管理引擎逻辑的方法,它就是CCDirector::mainLoop()方法,这个方法负责调用定时器,绘图,发送全局通知,并处理内存回收池。该方法按帧调用,每帧调用一次,而帧间间隔取决于两个因素,一个是预设的帧率,默认为 60 帧每秒;另一个是每帧的计算量大小。当逻辑处理与绘图计算量过大时,设备无法完成每秒 60 次绘制,此时帧率就会降低。
mainLoop()方法会被定时调用,然而在不同的平台下它的调用者不同。通常 CCApplication 类负责处理平台相关的任务,其中就包含了对 mainLoop()的调用。有兴趣的读者可以对比 Android、iOS 与 Windows Phone 三个平台下不同的实现,平台相关的代码位于引擎的"platform"目录。
mainLoop()方法是定义在 CCDirector 中的抽象方法,它的实现位于同一个文件中的 CCDisplayLinkDirector 类。现在我们来看一下它的代码:

cocos2d-x游戏引擎核心之三——主循环和定时器
void CCDisplayLinkDirector::mainLoop()
{
    if (m_bPurgeDirecotorInNextLoop)
    {
        m_bPurgeDirecotorInNextLoop = false;
        purgeDirector();
    }
    else if (! m_bInvalid)
    {
        drawScene();
        //释放对象
        CCPoolManager::sharedPoolManager()->pop();
    }
}
cocos2d-x游戏引擎核心之三——主循环和定时器

 

上述代码主要包含如下 3 个步骤。
  1 判断是否需要释放 CCDirector,如果需要,则删除 CCDirector 占用的资源。通常,游戏结束时才会执行这个步骤。  

  2 调用 drawScene()方法,绘制当前场景并进行其他必要的处理。

  3 弹出自动回收池,使得这一帧被放入自动回收池的对象全部释放。
由此可见,mainLoop()把内存管理以外的操作都交给了 drawScene()方法,因此关键的步骤都在 drawScene()方法之中。下面是 drawScene()方法的实现:

cocos2d-x游戏引擎核心之三——主循环和定时器
void CCDirector::drawScene()
{
    //计算全局帧间时间差 dt
    calculateDeltaTime();
    if (! m_bPaused)
    {
        m_pScheduler->update(m_fDeltaTime);
    }
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    if (m_pNextScene)
    {
        setNextScene();
    }
    kmGLPushMatrix();
    //绘制场景
    if (m_pRunningScene)
    {
        m_pRunningScene->visit();
    }
    //处理通知节点
    if (m_pNotificationNode)
    {
        m_pNotificationNode->visit();
    }
    if (m_bDisplayStats)
    {
        showStats();
    }
    if (m_pWatcherFun && m_pWatcherSender)
    {
        (*m_pWatcherFun)(m_pWatcherSender);
    }
    kmGLPopMatrix();
    m_uTotalFrames++;
    //交换缓冲区
    if (m_pobOpenGLView)
    {
        m_pobOpenGLView->swapBuffers();
    }
    if (m_bDisplayStats)
    {
        calculateMPF();
    }
}
cocos2d-x游戏引擎核心之三——主循环和定时器

我们看到 drawScene()方法内进行了许多操作,甚至包含了少量 OpenGL 函数。这是由于 Cocos2d-x 在游戏主循环中对引擎的细节进行了许多处理,我们并不关心这些细节,因此我们首先剔除掉细枝末节,整理出一个精简版本的 drawScene()方法

cocos2d-x游戏引擎核心之三——主循环和定时器
void CCDirector::drawSceneSimplified()
{
    _calculate_time();

    if (! m_bPaused)
    m_pScheduler->update(m_fDeltaTime);

    if (m_pNextScene)
    setNextScene();

    _deal_with_opengl();

    if (m_pRunningScene)
    m_pRunningScene->visit();
    
    _do_other_things();
}
cocos2d-x游戏引擎核心之三——主循环和定时器

 

对比一下 drawSceneSimplified()与 drawScene()的代码,可以发现我们省略掉的代码主要用于处理 OpenGL 和一些细节,如计算 FPS、帧间时间差等。在主循环中,我们主要进行了以下 3 个操作。
  1 调用了定时调度器的 update 方法,引发定时器事件
  2 如果场景需要被切换,则调用 setNextStage 方法,在显示场景前切换场景。
  3 调用当前场景的 visit 方法,绘制当前场景。
场景的绘制与 OpenGL 密切相关,后面详细讨论

二、定时器事件

(1)在游戏主循环 drawScene 方法中,我们可以看到每一帧引擎都会调用 m_pScheduler 的 update 方法。m_pScheduler 是 CCScheduler 类型的对象,是一个定时调度器。所谓定时调度器,就是一个管理所有节点定时器的对象,它负责记录定时器,并在合适的时间触发定时事件。接下来我们详细介绍定时调度器。

从 CCNode 说起

  前面我们简要介绍了游戏主循环,并在 Cocos2d-x 的游戏主循环中引出了定时调度器 CCScheduler 的调度方法 update。update 方法主要负责定时器的调度,我们将对它进行详细分析,但在此之前,了解 CCScheduler 公开的接口是很有必要的,这会有助于我们对调度器类有一个整体的认识。因此,我们从 CCNode 的定时器接口开始分析。Cocos2d-x 提供了两种定时器,分别是:
  update 定时器,每一帧都被触发,使用 scheduleUpdate 方法来启用;
  schedule 定时器,可以设置触发的间隔,使用 schedule 方法来启用。
下面就是这两个方法的代码:

cocos2d-x游戏引擎核心之三——主循环和定时器
void CCNode::scheduleUpdateWithPriority(int priority)
{
    m_pScheduler->scheduleUpdateForTarget(this, priority, !m_bIsRunning);
}
void CCNode::schedule(SEL_SCHEDULE selector, float interval, unsigned int repeat, float delay)
{
    CCAssert( selector, "Argument must be non-nil");
    CCAssert( interval >=0, "Argument must be positive");

    m_pScheduler->scheduleSelector(selector, this, interval, !m_bIsRunning, repeat, delay);
}
cocos2d-x游戏引擎核心之三——主循环和定时器

其中 m_pScheduler 是 CCScheduler 对象。可以看到,这两个方法的内部除去检查参数是否合法,只是调用了 CCScheduler提供的方法。换句话说,CCNode 提供的定时器只是对 CCScheduler 的包装而已。不仅这两个方法如此,其他定时器相关的方法也都是这样。这里没有必要列出所有的代码,富有探索精神的读者可以打开 CCNode 的源代码。
经过上面的分析,我们已经知道 CCNode 提供的定时器不是由它本身而是由 CCScheduler 管理的。因此,我们把注意力转移到定时调度器上。显而易见,定时调度器应该对每一个节点维护一个定时器列表,在恰当的时候就会触发其定时事件。打开 CCScheduler 类的头文件,可以看到它主要包含的成员。

cocos2d-x游戏引擎核心之三——主循环和定时器

为了注册一个定时器,开发者只要调用调度器提供的方法即可。同时调度器还提供了一系列对定时器的控制接口,例如暂停和恢复定时器。在调度器内部维护了多个容器,用于记录每个节点注册的定时器;同时,调度器会接受其他组件(通常与平台相关,例如在 iOS 下为 CADisplayLink)的定时调用,随着系统时间的改变驱动调度器。
调度器可以随时增删或修改被注册的定时器。具体来看,调度器将 update 定时器与普通定时器分别处理:当某个节点注册update 定时器时,调度器就会把节点添加到 Updates 容器中,为了提高调度器效率,Cocos2d-x 使用了散列表与链表结合的方式来保存定时器信息;当某个节点注册普通定时器时,调度器会把回调函数和其他信息保存到 Selectors 散列表中。

(2)update 方法
在游戏主循环中,我们已经见到了 update 方法。可以看到,游戏主循环会不停地调用 update 方法。该方法包含一个实型参数,表示两次调用的时间间隔。在该方法中,引擎会利用两次调用的间隔来计算何时触发定时器。
update 方法的实现看起来较为复杂,而实际上它的内部多是重复的代码片段,逻辑并不复杂。我们可以利用 Cocos2d-x 中精心编写的注释来帮助理解 update 方法的工作流程,相关代码如下:

cocos2d-x游戏引擎核心之三——主循环和定时器
// main loop
void CCScheduler::update(float dt)
{
    m_bUpdateHashLocked = true;

    if (m_fTimeScale != 1.0f)
    {
        dt *= m_fTimeScale;
    }

    // Iterate over all the Updates' selectors
    tListEntry *pEntry, *pTmp;

    // updates with priority < 0
    DL_FOREACH_SAFE(m_pUpdatesNegList, pEntry, pTmp)
    {
        if ((! pEntry->paused) && (! pEntry->markedForDeletion))
        {
            pEntry->target->update(dt);
        }
    }

    // updates with priority == 0
    DL_FOREACH_SAFE(m_pUpdates0List, pEntry, pTmp)
    {
        if ((! pEntry->paused) && (! pEntry->markedForDeletion))
        {
            CCScriptEngineProtocol* pEngine = CCScriptEngineManager::sharedManager()->getScriptEngine();
            if (pEngine != NULL && kScriptTypeJavascript == pEngine->getScriptType())
            {
                CCScriptEngineManager::sharedManager()->getScriptEngine()->executeSchedule(NULL, dt, (CCNode *)pEntry->target);
            }
            
            pEntry->target->update(dt);            
        }
    }

    // updates with priority > 0
    DL_FOREACH_SAFE(m_pUpdatesPosList, pEntry, pTmp)
    {
        if ((! pEntry->paused) && (! pEntry->markedForDeletion))
        {
            pEntry->target->update(dt);            
        }
    }

    // Iterate over all the custom selectors
    for (tHashSelectorEntry *elt = m_pHashForSelectors; elt != NULL; )
    {
        m_pCurrentTarget = elt;
        m_bCurrentTargetSalvaged = false;

        if (! m_pCurrentTarget->paused)
        {
            // The 'timers' array may change while inside this loop
            for (elt->timerIndex = 0; elt->timerIndex < elt->timers->num; ++(elt->timerIndex))
            {
                elt->currentTimer = (CCTimer*)(elt->timers->arr[elt->timerIndex]);
                elt->currentTimerSalvaged = false;

                elt->currentTimer->update(dt);

                if (elt->currentTimerSalvaged)
                {
                    // The currentTimer told the remove itself. To prevent the timer from
                    // accidentally deallocating itself before finishing its step, we retained
                    // it. Now that step is done, it's safe to release it.
                    elt->currentTimer->release();
                }

                elt->currentTimer = NULL;
            }
        }

        // elt, at this moment, is still valid
        // so it is safe to ask this here (issue #490)
        elt = (tHashSelectorEntry *)elt->hh.next;

        // only delete currentTarget if no actions were scheduled during the cycle (issue #481)
        if (m_bCurrentTargetSalvaged && m_pCurrentTarget->timers->num == 0)
        {
            removeHashElement(m_pCurrentTarget);
        }
    }

    // Iterate over all the script callbacks
    if (m_pScriptHandlerEntries)
    {
        for (int i = m_pScriptHandlerEntries->count() - 1; i >= 0; i--)
        {
            CCSchedulerScriptHandlerEntry* pEntry = static_cast<CCSchedulerScriptHandlerEntry*>(m_pScriptHandlerEntries->objectAtIndex(i));
            if (pEntry->isMarkedForDeletion())
            {
                m_pScriptHandlerEntries->removeObjectAtIndex(i);
            }
            else if (!pEntry->isPaused())
            {
                pEntry->getTimer()->update(dt);
            }
        }
    }

    // delete all updates that are marked for deletion
    // updates with priority < 0
    DL_FOREACH_SAFE(m_pUpdatesNegList, pEntry, pTmp)
    {
        if (pEntry->markedForDeletion)
        {
            this->removeUpdateFromHash(pEntry);
        }
    }

    // updates with priority == 0
    DL_FOREACH_SAFE(m_pUpdates0List, pEntry, pTmp)
    {
        if (pEntry->markedForDeletion)
        {
            this->removeUpdateFromHash(pEntry);
        }
    }

    // updates with priority > 0
    DL_FOREACH_SAFE(m_pUpdatesPosList, pEntry, pTmp)
    {
        if (pEntry->markedForDeletion)
        {
            this->removeUpdateFromHash(pEntry);
        }
    }

    m_bUpdateHashLocked = false;

    m_pCurrentTarget = NULL;
}
cocos2d-x游戏引擎核心之三——主循环和定时器

借助注释,能够看出 update 方法的流程大致如下所示。
  1 参数 dt 乘以一个缩放系数,以改变游戏全局的速度,其中缩放系数可以由 CCScheduler 的 TimeScale 属性设置。
  2 分别枚举优先级小于 0、等于 0、大于 0 的 update 定时器。如果定时器没有暂停,也没有被标记为即将删除,则触发定时器。
  3 枚举所有注册过普通定时器的节点,再枚举该节点的定时器,调用定时器的更新方法,从而决定是否触发该定时器。
  4 我们暂不关心脚本引擎相关的处理。
  5 再次枚举优先级小于 0、等于 0、大于 0 的 update 定时器,移除前几个步骤中被标记了删除记号的定时器。


(3)对于 update 定时器来说,每一节点只可能注册一个定时器,因此调度器中存储定时器数据的结构体_listEntry 主要保存了
注册者与优先级。对于普通定时器来说,每一个节点可以注册多个定时器,引擎使用回调函数(选择器)来区分同一节点下注册的不同定时器。调度器为每一个定时器创建了一个 CCTimer 对象,它记录了定时器的目标、回调函数、触发周期、重复触发还是仅触发一次等属性。
CCTimer 也提供了 update 方法,它的名字和参数都与 CCScheduler 的 update 方法一样,而且它们也都需要被定时调用。不同的是,CCTimer 的 update 方法会把每一次调用时接收的时间间隔 dt 积累下来,如果经历的时间达到了周期,就会引发定时器的定时事件。第一次引发了定时事件后,如果是仅触发一次的定时器,则 update 方法会中止,否则定时器会重新计时,从而反复地触发定时事件。
回到 CCScheduler 的 update 方法上来。在步骤 c 中,程序首先枚举了每一个注册过定时器的对象,然后再枚举对象中定时器对应的 CCTimer 对象,调用 CCTimer 对象的 update 方法来更新定时器状态,以便触发定时事件。

至此,我们可以看到事件驱动的普通定时器调用顺序为:系统的时间事件驱动游戏主循环,游戏主循环调用 CCScheduler的 update 方法,CCScheduler 调用普通定时器对应的 CCTimer 对象的 update 方法,CCTimer 类的 update 方法调用定时器对应的回调函数。对于 update 定时器,调用顺序更为简单,因此前面仅列出了普通定时器的调用顺序。
同时,我们也可以看到,在定时器被触发的时刻,CCScheduler 类的 update 方法正在迭代之中,开发者完全可能在定时器事件中启用或停止其他定时器(如下图)。不过,这么做会导致 update 方法中的迭代被破坏。Cocos2d-x 的设计已经考虑到了这个问题,采用了一些技巧避免迭代被破坏。例如,update 定时器被删除时,不会直接删除,而是标记为将要删除,在定时器迭代完毕后再清理被标记的定时器,这样即可保证迭代的正确性

cocos2d-x游戏引擎核心之三——主循环和定时器

(4)使用实例

在vs2010中新建cocos2d-x的工程项目,命名为Helloworld。

cocos2d-x游戏引擎核心之三——主循环和定时器
//HelloWorldScene.h
#ifndef __HELLOWORLD_SCENE_H__
#define __HELLOWORLD_SCENE_H__

#include "cocos2d.h"

#include "SimpleAudioEngine.h"

class HelloWorld : public cocos2d::CCLayer
{
public:
    // Here's a difference. Method 'init' in cocos2d-x returns bool, instead of returning 'id' in cocos2d-iphone
    //
    virtual bool init();  
    //void log(float dt);   //注意参数类型  
    void update(float dt);

    // there's no 'id' in cpp, so we recommand to return the exactly class pointer
    static cocos2d::CCScene* scene();
    
    // a selector callback
    void menuCloseCallback(CCObject* pSender);

    // implement the "static node()" method manually
    CREATE_FUNC(HelloWorld);
};

#endif  // __HELLOWORLD_SCENE_H__
cocos2d-x游戏引擎核心之三——主循环和定时器
cocos2d-x游戏引擎核心之三——主循环和定时器
//HelloWorldScene.cpp
#include "HelloWorldScene.h"

using namespace cocos2d;

CCScene* HelloWorld::scene()
{
    CCScene * scene = NULL;
    do 
    {
        // 'scene' is an autorelease object
        scene = CCScene::create();
        CC_BREAK_IF(! scene);

        // 'layer' is an autorelease object 创建层
        HelloWorld *layer = HelloWorld::create();
        CC_BREAK_IF(! layer);

        // add layer as a child to scene
        scene->addChild(layer);
    } while (0);

    // return the scene
    return scene;
}

//update函数,首字母小写  
void HelloWorld::update(float dt)  
{  
    CCLog("schedule");  
}  
//定时器
/*void HelloWorld::log(float dt)  
{  
CCLog("schedule");  
} */ 

// on "init" you need to initialize your instance
bool HelloWorld::init()
{
    bool bRet = false;
    do 
    {
        //////////////////////////////////////////////////////////////////////////
        // super init first对父类进行初始化
        //////////////////////////////////////////////////////////////////////////

        CC_BREAK_IF(! CCLayer::init());

        //////////////////////////////////////////////////////////////////////////
        // add your codes below...
        //////////////////////////////////////////////////////////////////////////

        // 1. Add a menu item with "X" image, which is clicked to quit the program.

        // Create a "close" menu item with close icon, it's an auto release object.
        //创建菜单并添加到层
        CCMenuItemImage *pCloseItem = CCMenuItemImage::create(
            "CloseNormal.png",
            "CloseSelected.png",
            this,
            menu_selector(HelloWorld::menuCloseCallback));
        CC_BREAK_IF(! pCloseItem);

        // Place the menu item bottom-right conner.
        pCloseItem->setPosition(ccp(CCDirector::sharedDirector()->getWinSize().width - 20, 20));

        // Create a menu with the "close" menu item, it's an auto release object.
        CCMenu* pMenu = CCMenu::create(pCloseItem, NULL);
        pMenu->setPosition(CCPointZero);
        CC_BREAK_IF(! pMenu);

        // Add the menu to HelloWorld layer as a child layer.
        this->addChild(pMenu, 1);

        // 2. Add a label shows "Hello World".

        // Create a label and initialize with string "Hello World".
        //创建标签并添加到层
        CCLabelTTF* pLabel = CCLabelTTF::create("Hello World", "Arial", 24);
        CC_BREAK_IF(! pLabel);

        // Get window size and place the label upper. 
        CCSize size = CCDirector::sharedDirector()->getWinSize();
        pLabel->setPosition(ccp(size.width / 2, size.height - 50));

        // Add the label to HelloWorld layer as a child layer.
        this->addChild(pLabel, 1);

        // 3. Add add a splash screen, show the cocos2d splash image.
        //创建精灵并添加到层
        CCSprite* pSprite = CCSprite::create("HelloWorld.png");
        CC_BREAK_IF(! pSprite);

        // Place the sprite on the center of the screen
        pSprite->setPosition(ccp(size.width/2, size.height/2));

        // Add the sprite to HelloWorld layer as a child layer.
        this->addChild(pSprite, 0);

        bRet = true;
    } while (0);

    //开启定时器,延时2s执行,执行3+1次,执行间隔1s  
    //this->schedule(schedule_selector(HelloWorld::log),1,3,2);  
    //update定时器
    scheduleUpdate();
    return bRet;
}

void HelloWorld::menuCloseCallback(CCObject* pSender)
{
    // "close" menu item clicked
    CCDirector::sharedDirector()->end();
}
cocos2d-x游戏引擎核心之三——主循环和定时器

注:代码中含有update定时器和普通定时器(已注释)两种实例。