Gamemonkey编程---高级进阶4

这些东西是平时遇到的, 觉得有一定的价值, 所以记录下来, 以后遇到类似的问题可以查阅, 同时分享出来也能方便需要的人, 转载请注明来自RingOfTheC[ring.of.the.c@gmail.com]

     继续翻译GameMonkey脚本语言的文章, 这些文章都是在GameDev网站上找到的. 在翻译的过程中, 更加深了我对GM的了解和兴趣, 它的协程机制确实比Lua的协程在原生支持方面增强了很多, so enjoy! 上次GM参考手册的翻译放在了一篇文章, 感觉显的太长了, 所以这次我决定将这些长篇翻译分成多篇文章, 这样阅读起来比较方便, 而且可以避免一次信息过大

原文地址: http://www.gamedev.net/reference/programming/features/gmScriptAdv/page2.asp

 

线程, 栈和函数调用

     协程是GameMonkey脚本语言中最重要的部分, 我将在这一节中详细的探讨协程工作的有关细节, 同时也会阐述GameMonkey虚拟机如何实现函数调用以及函数是如何和协程结合在一起的.
     协程是存在于虚拟机中的, 一个协程包含如下元素:
        1. 一个虚拟机范围的唯一Id
        2. 一个栈
        3. 一个执行的函数
        4. 一个指向当前指令的指针(如果有)
        5. 一个列表, 用来记录阻塞的和未决的信号
        6. 协程状态机信息
     协程的栈是一个高效的列表, 它用来存储函数调用时使用到的gmVariable对象, this变量, 传给协程的函数参数以及函数调用过程中要用到的其他变量, 也包括函数的返回值.
     协程的函数是一个gmFunctionObject对象, 这个对象既可以是一个本地的C/C++函数, 也可以是一个脚本函数. 一个脚本函数本质上是一个bytecode的指令序列, 这个bytecode指令序列由GameMonkey虚拟机来解释执行以完成在脚本中定义的行为. 执行虚拟机bytecode依靠协程中的一些结构, 虚拟机不能脱离协程而执行函数.
      当GameMonkey中发生函数调用时, 会发生哪些事情? 首先, 将this的变量压入栈中, 如果没有绑定任何值, 那么这里就压入null; 然后压入的是函数对象; 接着是依次压入参数; 最后压入一个栈帧, 这样就完成了函数调用时所有的初始化(哪些协程执行时需要用到的内部数据和结构), 为VM中的协程的执行做好准备. 栈帧是十分重要的东西, 它允许VM保存指向正确指令和数据的的指针, 比如说, 允许实现内建的函数嵌套调用机制. 在VM中执行的函数, 不管它是一个本地函数, 还是通过解释bytecode的一个脚本函数.  任何返回值都是压入栈上返回的, 最后把栈帧弹出恢复函数调用前的环境允许脚本继续执行函数调用后面的代码. 
     接下来我们手工的创建一个协程并调用一个本地函数来观察这种行为. 首先我们将写一个简单函数来接受两个整型参数并把他们写到cout中.

     int GM_CDECL gmFunctionTest(gmThread* a_thread) {
          GM_CHECK_INT_PARAM(a_param1, 0);
          GM_CHECK_INT_PARAM(a_param2, 1);
          std::cout << "Param 1: " << a_param1 << std::endl;
          std::cout << "Param 2: " << a_param2 << std::endl;
          return GM_OK;
     }

     下面, 我们通过使用GameMonkey API来一步一步的展示调用函数的细节

     gmMachine gm;
     int threadid = 0;
     gmThread* thread = gm.CreateThread(&threadid);
     thread->PushNull();  // 压入this
     thread->PushFunction(gm.AllocFunctionObject(gmFunctionTest)); // 压入函数
     thread->PushInt(50);
     thread->PushInt(100);
     thread->PushStackFrame(2);

     就像你看到的这样, 我们创建了一个协程, 并向其中压入了所需要的信息, 然后通过调用gmThread::PushStackFrame函数压入栈帧使函数立即执行. 如果函数是一个脚本函数, 你可以通过调用gmThread::Sys_Execute函数来启动执行函数的协程或者是gmMachine::Execute函数来启动整个虚拟机.

     gmMachine gm;
     // 创建一个全局的脚本函数
     gm.ExecuteString("global func = function(a, b) { print(\"Param 1: \", a); print(\"Param 2: \", b); };");
     gmFunctionObject* func = gm.GetGlobals()->Get(&gm, "func").GetFunctionObjectSafe();
     int threadid = 0;
     gmThread* thread = gm.CreateThread(&threadid);
     thread->PushNull();
     thread->PushFunction(func);
     thread->PushInt(50);
     thread->PushInt(100);
     thread->PushStackFrame(2);
     thread->Sys_Execute();

     想从函数中返回一个值是很简单的, 只需要在return前将它压到栈上, 函数返回调用方就可以从栈顶取得这个值, 或者被调用的是一个脚本函数, 我们可以在调用Sys_Execute的时候传入一个gmVariable类型的指针, 就可以获取到返回值.
     这一小节我们演示了手动调用一个函数的过程, 并且看到了在调用的时候它是如何与协程以及栈之间进行互动操作的. 我们推荐你使用gmCall这个辅助类来调用函数, gmCall包装了本文中讲到的API 函数, 使用它可以减少错误发生的可能性.

回调返回值

     迄今为止, 所有你看到的示例中的函数都是返回GM_OK或者是返回GM_EXCEPTION. 这个小节将讨论这些返回值对于虚拟机的意义以及其他一些返回值及意义.
     当函数调用发生或者是一个操作符回调函数被调用时, 你可以选择合适的返回值来告诉虚拟机函数调用成功或是失败. 如果一个调用成功的话(返回GM_OK), 所有的事情都正常继续, 一切正常. 但是如果发生失败呢? 比如回调函数收到了一个错误的参数类型, 或者是游戏中发生了一个必须通知给你的脚本环境的错误. 在种情况下, 你可以返回一个GM_EXCEPTION, 这将导致驱动该函数的协程被终止掉.
     常用的返回值有如下几种:

     Value                                 Meaning
GM_OK 成功, 没啥其他意义
GM_EXCEPTION 发生了一个错误, 驱动该函数的协程被终止
GM_SYS_YIELD 导致VM将本协程立即挂起, 去执行其他协程
GM_SYS_BLOCK 协程设置成为阻塞的, 下面将会讲到更多细节
GM_SYS_SLEEP 强制协程去睡眠, 时间对应Sys_SetTimeStamp和Sys_SetStartTime的设置

     当然还有一些其他的返回值, 但是那些值都是虚拟机内部使用的, 我们不应该使用它们, 冒然的使用它们会导致虚拟机发生错误.

使用API发信号

     就像在前面的文章中演示的那样, 你可以在脚本中使用信号, 这个小节将告诉你如何使用API来发送信号给协程. 这是一种强大的机制, 它将允许你实现基于事件的系统, 通过发信号给虚拟机来模拟游戏事件, 或者是向特定的游戏实体的协程发生信号. 举个例子, 在很多RPG游戏中, 都会出现使用对话框提供给玩家一些选项, 在脚本中你可以告诉引擎去打开对话框并显示特定的文字, 然后等待玩家同意或者拒绝, 下面是一个脚本, 演示了这样的场景

     ShowQuestDialog(Quests.SaveThePrincess);
     response = block(QuestDialog.Accept, QuestDialog.Decline);
     if (response == QuestDialog.Accept) {
          // do something
     } else {
          // do something else
     }

     在这个例子中, ShowQuestDialog函数是一个本地函数用来在你的GUI系统中显示一个非模态的对话框, 然后等待用户通过点击按钮发生答复事件. 如果这个对话框是一个非模态的, 你需要在你的脚本中使用同样的方式来回应用户的输入, 所以你可以阻塞在两个不同的信号上, 分别表示同意和拒绝. 因为协程是在用户回答前阻塞的, 所以它在GameMonkey虚拟机中处于休眠状态, 因此在此期间它不会浪费任何cpu时间, 只到它被信号唤醒才开始继续工作. 在这个场景下信号就是是脚本系统用来模拟GUI 中的对应的对话框的按钮被按下的事件.
     来看一些简单的代码:

     enum QuestDialog {
          QD_DECLINE,
          QD_ACCEPT,
     };
     // 这个函数模拟模态对话框
     int GM_CDECL gmShowQuestDialog(gmThread* a_thread) {
          std::cout << "A princess is in danger, do you wish to save her?" << std::endl << "[Accept | Decline] " << std::endl;
           return GM_OK;
     }
     int HandleUserAcceptQuest(gmMachine& gm, int threadid, QuestDialog response) {
          gm.Signal(gmVariable( (int)response), threadid, 0);
     }
     int main(int argc, int* argv[]) {
          gmMachine gm;
          gm.GetGlobals()->Set(&gm, "ShowQuestDialog", gmVariable(gm.AllocFunctionObject(gmShowQuestDialog)));
          gmTableObject* dialogResponses = gm.AllocTableObject();
          dialogResponses->Set(&gm, "Accept", gmVariable(QD_ACCEPT));
          dialogResponses->Set(&gm, "Decline", gmVariable(QD_DECLINE));
          gm.GetGlobals()->Set(&gm, "QuestDialog", gmVariable(dialogResponses));
          const char* script = "ShowQuestDialog();\n"
          "response = block(QuestDialog.Accept, QuestDialog.Decline); \n"
          "if (responese == QuestDialog.Accept) { print(\"[Quest accepted]\"); } else { print(\"Quest declined]\");  } ";
          int threadid = 0;
          gm.ExecuteString(script, &threadid);
          Sleep(5000);
          HandleUserAcceptQuest(gm, threadid, QD_ACCEPT);
          gm.Execute(0);
          return 0;
     }

     这里的大部分代码都是用来设置我们的全局脚本变量和绑定对话框问答函数. 在虚拟机中运行这个简单的脚本它会马上调用对话框函数(在你的游戏实现中, 你应该打开一个真正的GUI窗口), 然后马上阻塞以等待用户响应. 信号本身可以通过gmMachine::Signal函数来产生, 该函数接受一个gmVariable参数以及一个可选的协程Id -- 这和在脚本中调用signal加按摩手机是一样的.
     第二个例子是在脚本中创建门, 门本身保持关闭状态直到收到打开它的指令, 可能是因为玩家使用了开关, 或者是向某一个触发开门的机关开火. 为门编写脚本很简单, 创建一个门, 并且马上阻塞在open的信号上. 当开门被触发时, 一个动画游戏事件就开始异步的播放开门动画, 然后门的控制函数睡眠5秒钟, 在醒来后, 关门的游戏动画开始播放, 门重新阻塞在open事件上 -- 这样的行为将无限重复或者直到协程被终止, 并将门变的不可用或是直接从游戏中删除.

     global DoorFunction = function() {
          while (true) {
               print(this, "waiting for use...");
               block("usedoor");
               this:startAnimation("dooropen");
               sleep(5);
               this.startAnimation("doorclose");
          }
       };
       createDoor(DoorFunction);

     本地函数createDoor会创建一个新的门, 并且运行你传递过去的函数. 注意在这个例子中我使用输出字符串的方式来代替开关门动画的播放, 你的游戏中可能需要创建一个真正的门的实体并且作为一个user object返回.

     int GM_CDECL gmCreateDoor(gmThread* a_thread) {
          GM_CHECK_FUNCTION_PARAM(a_func, 0);
          gmStringObject* door = a_thread->GetMachine()->AllocStringObject("TESTDOOR");
          gmCall gmcall;
          gmcall.BeginFunction(a_thread->GetMachine(), a_func, gmVariable(door), false);
          gm.call.End();
          a_thread->PushString(door);
          return GM_OK;
     }

     在游戏中, 只要你出发了该信号, 就会开门. 这里我只是将手动使虚拟机执行下一帧, 但是你的游戏循环将会在每一帧中自动执行它.

     gmSignal(gmVariable(gm.AllocStringObject("usedoor")), GM_INVALID_THREAD, 0);

     运行输出:

     TESTDOOR waiting for use...
     TESTDOOR starting animation: dooropen
     TESTDOOR starting animation: doorclose
     TESTDOOR waiting for use...

     这个例子很好的展示了通过协程和信号来控制游戏中门的行为, 去除了用户显式的创建它们. 对于用户来说, 他们只需要简单的创建出门开关时的行为, 他们不需要知道信号的相关细节.

使用API阻塞

     可以通过使用API来阻塞协程, 通过这一点可以实现比在脚本中阻塞更加灵活的行为. 当你在脚本中阻塞的时候, 当前执行协程会立即挂起并把自己的状态设置为blocked. 而通过API来阻塞一个协程时允许你继续正常运行. 阻塞API只是将被阻塞的协程注册起来, 到下一个执行周期时虚拟机才会挂起它. 为了阻塞一个协程, 你可以使用gmMachine::Sys_Block函数, 该函数需要一个协程Id和一个gmVariable的列表用来指示该协程阻塞在那些gmVariable上. 因为是阻塞在gmVariable上, 所以十分的灵活, 当然阻塞在另一个协程上的行为是被允许的, 但是我们不推荐这样做, 因为它将导致你不想得到的行为, 或者是不可预期的行为(比如协程阻塞的信号永不发生)
     一个的例子快速的演示了你怎样通过API来阻塞:

     enum {
          SIG_ZERO,
          SIG_ONE,
     };
     int GM_CDECL gmBlockTest(gmThread* a_thread) {
          gmVariable blocks[] = {gmVariable(SIG_ZERO), gmVariable(SIG_ONE)};
          int ret = a_thread->GetMachine()->Sys_Block(a_thread, 2, blocks);
          if (ret == -1) {
               return GM_SYS_BLOCK;
          }
          a_thread->Push(blocks[ret]);
          return GM_OK;
     }

     在这个函数中我设定了两个变量, 值分别为0和1, 用来告诉GameMonkey虚拟机使当前执行的协程阻塞在它们上面. 在底层gmMachine::Sys_Block函数先会尝试用光任何仍然在协程上未决的信号. 如果检测到有一个未决信号是要阻塞的信号时, 它就立即返回你提供的阻塞变量的索引值, 通过向协程的栈中压入这个值作为返回值, 并且立即返回, 用户可以根据这个返回值检测发生了哪一个信号. 如果没有对应的未决信号, gmMachine::Sys_Block将返回一个-1 --- 注意, 当发生这种情况时, 你应该返回一个GM_SYS_BLOCK通知虚拟机这个协程将要挂起, 只到有信号发生唤醒它. 当你绑定这个函数到虚拟机后, 你可以这样来使用它:

     r = blocktest();
     print("signal fired: ", r);

     它将一直阻塞, 只到你使用gmMachine::Signal API或者是在脚本中使用signal()
     在游戏中往往存在大量的异步行为, 比如说播放一个动画或者是声音, 进行自动寻路/移动等等. 在现实世界中, 很多类似这样的行为都是相关的系统从一个消息列表中获取一个事件消息并处理. 想象一下, 命令一个Npc穿过一个城堡, Npc管理系统收到这个移动消息并且在接下面的每一帧中都移动这个Npc一小点, 很多帧以后, Npc到达了终点位置. 这时Npc管理器就可以发出一个信号通知游戏中的相关系统对Npc的移动操作完成了.
     你的脚本可能常常想要触发异步行为, 但是不得不将它们看做是同步的等待一个任务结果. 如果在将消息入队后虚拟机界限的函数立即返回, 脚本将继续执行而不是等待完成那就更好了. 在前面的章节中我们介绍了GameMonkey的block和signal函数, 它们就可以用于实现这样的机制, 在这里你可以发起异步事件, 然后立即阻塞到信号上等待信号的通知.

     doasyncbehaviour();
     ret = block("ok", "failed");

     尽管这样可以工作, 但是在实践中它并不那么好, 而且要求脚本编写者必须总是记住在异步行为后去阻塞. 在下一个小节我将演示一个例子来说明如果更好的在GameMonkey脚本中支持异步游戏事件. 
     演示一个例子, 你可以想象一个城堡守卫Npc, 它在两个塔中间来回巡逻, 当它到达一个塔时, 他会检查周围的情况, 如果没有什么异常, 他就继续巡逻. 守卫的行为可以用下面的状态变迁来表示:

                                        image

     在游戏引擎中, 通过使用一个发生一个goto消息给Npc管理器使守卫开始移动, 当到守卫达目的地的时候返回一个goto_success消息给游戏系统 -- 当然有可能发生比如目的地不能到达这样的事, 守卫移动中被攻击等等, 这样的情况下发生一个goto_failed消息给游戏系统.
     一个异步调用的事件序列如下:
         1. 脚本调用NpcWalkTo(dest)
         2. 游戏向消息队列中压入goto消息
         3. 脚本中阻塞协程
         4. 游戏运行, Npc管理器获得了移动消息
         5. Npc管理器在每一帧中移动Npc只到到达目的地
         6. 向脚本系统发送一个到达目的地的消息
         7. 脚本系统唤醒阻塞的协程, 并运行

     通过代码可以看到在实际情况中如何实现上述的行为, 在代码中我们可以绑定两个函数到GameMonkey中, CreateNPC函数用来创建游戏中实体, NPCGoto函数有一个游戏实体如守卫Npc和一个目的地作为参数. 为了简化例子, 这里的目的地是一个全局的目的地表中的一个元素, 包括TOWER_ONE或者是TOWER_TWO; 在真实的游戏中你可能希望一个向量, 或者是一个名叫TOWER_ONE标签. 首先你需要在游戏中创建一个守卫Npc, 然后启动它的协程.

     global GuardMove = function() {
          this:stateSet(GuardGotoTowerOne);
     };
     npc = CreateNpc();
     npc:thread(GuardMove);

     守卫的行为函数立即跳转到状态函数中, 使Npc开始向第一个塔移动

     global GuardGotoTowerOne = function() {
          print("Going to Tower One...");
          res = NpcGoto(this, Location.TOWER_ONE);
          if (res == Event.SUCCESS) {
               print("Arrived at tower One");
               this:stateSet(GuardWait);
           } else {
               print("Couldn.t get there");
           }
     };

     上面这段脚本通过调用NPCGoto函数, 并且传递TOWER_ONE坐标让守卫Npc开始移动. 在NPCGoto本地代码中会给游戏系统发送一个消息表示我们想移动守卫Npc到一个特定的位置.

     GM_CHECK_INT_PARAM(a_entity, 0);
     GM_CHECK_INT_PARAM(a_location, 1);
     // 向游戏消息队列中Push一个消息
     NPCGotoMessage* msg = new NPCGotoMessage(a_entity, a_thread->GetId(), a_location);
     s_Game.GetMessageSystem().PushMessage(msg);

     绑定函数立即阻塞调用协程, 挂起它只到我们从游戏中收到一个成功或者失败的结果.

     gmVairable blocks[] = {gmVariable(SM_SUCCESS), gmVariable(SM_FAILED)};
     int res = a_thread->GetMachine()->Sys_Block(a_thread, 2, blocks);
     if (res == -1) {
          return GM_SYS_BLOCK;
     }

     游戏继续运行, Npc管理器在收到NPCGotoMessage消息后就开始在每一帧中移动守卫Npc, 在这个例子中使用基于时间的触发器来模拟行走过程, 在真实的游戏中你需要处理守卫Npc在世界中坐标的改变.

     if (_Dur >= 2500.0f) {
          NPCArrivalMessage* msg = new NPCArrivalMessage(ent._Id, ent._ScriptThreadId, ent._Destination);
          s_Game.GetMessageSystem().PushMessage(msg);
          ent._State = Entity::ES_WAITING;
          _Dur = 0;
     }

     脚本管理系统监听到了一个NPCArrivalMessage, 它就用信号将移动的结果返回给守卫Npc的协程.

     if (a_msg->MessageType == MT_GOTO_SUCCESS) {
          NPCArrivalMessage* msg = reinterpret_cast<NPCArrivalMessage* >(a_msg);
          _Machine.Signal(gmVariable(SM_SUCCESS), msg->ScriptThreadId, 0);
     }

     GameMonkey脚本虚拟机处理这个信号, 并且唤醒对应的协程, 在这种情况下, 脚本可以通过替换协程的状态函数来驱动守卫Npc开始向TOWER_TWO移动. 
     就像你看到的, 这样的机制允许你给游戏中的实体编写脚本, 让他们同步, 而且不用担心你的游戏引擎中的复杂的消息支持系统. 处理NPCGoto函数就像一个普通的函数调用一样, 隐藏了阻塞行为, 这样就可以允许脚本编写者编写简单的逻辑而且不用担心在游戏中发生的其他事情.

     自己的话: 翻译的东西有时候感觉挺别扭的, 但是总是自己在读, 读上几次后居然也就觉得通了, 可能就是这样吧, 自己犯的错是最不容易察觉到的.

posted @ 2010-12-28 10:00  ROTC  阅读(1828)  评论(0编辑  收藏  举报