前排提醒,这个方法土掉渣了,已经弃用。
----
前言
初学开发者,尤其是单人开发者往往需要一个人写程序和做游戏设计,然而,游戏之所以能吸引人是因为设计的有趣,但单人开发往往要耗掉大半部分时间去学习编程或是将想法写成程序,如果想法过于庞大,会导致在编程上消耗的时间增多,这对于游戏开发者而言十分不利,毕竟对大部分游戏开发者而言,游戏保持流畅稳定运行就行,玩家大部分还是只会关注游戏内容本身。对于一个非计算机专业的人而言,写程序这一步往往是一个很大的阻碍,因为不会处理多条件判断,不知如何进行高效率开发,为了解决这个问题,我参考了计算机系统相关知识,总结出一些经验供有需要的人参考。
本文主要针对处理游戏中的复杂交互内容展开,首先解释为何需要程序框架,其次提到数据与代码实现解耦的重要性,并在末尾使用GMS2引擎实现部分功能。虽然在主要的游戏开发日志里提到并不会写有关游戏内容的内容,但关于游戏框架的设计,这部分是可以分享的,而且算得上是比较干货的内容。为了方便学习,前三章的内容采用伪代码进行讲解,第四章实战引入了GMS2内置的GML语言进行讲解。
1.框架
让我们从想一个游戏开始。虽然游戏有各种花样,但总归点一个开始按钮进入游戏,最后,点一个关闭按钮结束游戏,下图就是一个很好的案例,
图1 开始界面示意图
如果我打算直接点击开始,那么在以前的我会在主调用函数里这么写,
if(鼠标点击了开始按键) 进入游戏();
然后就是吧啦吧啦,加载各种游戏组件之类的。
到这里还算正常,那么如果我点击设置按键呢?在以前的我会在下面加一句,
if(鼠标点击了设置按键) 进入设置界面();
这样下来,整个程序就变成了,
if(鼠标点击了开始按键) 进入游戏(); if(鼠标点击了设置按键) 进入设置界面();
这样的写法确实没错,但根本问题不在这里,如果我每在添加一个功能,就要在代码层面上加多一句,虽然一两句不是问题,但问题是当需要处理一组功能,而且每一组功能有相似的部分,但不完全一样,这样的话,就会不得不在设计和编程上花费更多时间。而且通过功能的累积,整个程序都会变得越来越庞大,直到最后,完全不知道应该如何对功能进行修改或者植入之类的。
其次,假设我点击了设置按键,进入了设置界面,它大概占屏幕的一部分,如,
图2 打开设置窗口的开始界面
假如此时我并不打算让别人按"开始"按键的时候直接进入游戏的话,那么会在开始界面上加上一点,
if(鼠标点击了开始按键 and 没有进入设置界面) 进入游戏();
紧接着,又会给这个"没有进入设置界面"赋一个值,初始化一下。同时,我也不希望这个时候按退出按键的时候游戏结束,我会删掉上面写的多余的部分,并且在最前面写到,
if(进入设置界面) { 设置界面内容(); return; }
整体下来,整个开始界面的程序就变成了,
初始化数据(); 循环下列代码; if(进入设置界面) { 设置界面内容(); return; }else{ 进入设置界面=0; } if(鼠标点击了开始按键) 进入游戏(); if(鼠标点击了设置按键) 进入设置界面() 进入设置界面=1; if(鼠标点击了退出按键) 退出();
假设我想在这个界面的基础上添加一个加载游戏的功能,因为会弹出一个界面,其过程与打开设置界面一样,上面的程序就会被写成,
初始化数据(); 循环下列代码; if(进入设置界面) { 设置界面内容(); return; }else if (进入加载游戏界面){ 加载游戏界面(); return; }else{ 进入设置界面=0; 进入加载游戏界面=0; } if(鼠标点击了开始按键) 进入游戏(); if(鼠标点击了设置按键) 进入设置界面() 进入设置界面=1; if(鼠标点击了加载游戏按键) 进入加载游戏界面() 进入加载游戏界面=1; if(鼠标点击了退出按键) 退出();
显而易见,代码量增加了。而且这个例子可被应用于游戏设计里的任何地方,例如攻击的后摇、对话、移动摄像机等,真要那样的话,就不得不添加更多 if 到程序里,而且还要在不同的地方判断什么时候允许上述行为。
对此想到,是否可以将所有交互内容在开始判断之前都进行分类,然后将所有事件统一接收进行判断,最后再反馈到发出事件的物体或者作用对象上?于是,在我上个月写的个人游戏开发日志里提到了一个框架图,是参考了《设计模式》里的一些方法提出的,我的想法是需要多个物体交互的部分添加到这个框架里,不需要互动的就独立整理成类似于UNITY上的预制件,直接调用并赋予一定的值就ok了。那时的想法还不够成熟,经过一个月的打磨后(大概两三天),其消息传递模型简图如下,
图1 消息传递模型简图
这样的话,每一个按键所触发的事件不再需要写在一起,只需要分开到每个按键的脚本里,假设被点击,就发送一个消息因子到消息队列进行集中处理,判断将这条消息删除、延缓还是执行?例子如下,
if(被鼠标点) 发送信息到消息队列();
这样便初步解释了框架的构成,如果各位觉得我讲的不够详细,可以回忆一下曾经玩过的游戏,点击、移动、攻击,有多少情况下可以达成发送消息--集中处理这种机制的循环呢?显而易见,就算是操作系统,也一直在等待输入的。
下面是将打开设置界面的例子重复讲述。
通过点击设置界面按钮,消息因子来到消息队列,因为此时并没有什么限制内容,所以顺利通过,打开了界面。假如设置界面不希望自己打开的时候,能够点"开始"或"退出",就会在经过集中处理层(也叫逻辑层)的时候留下信息,不能点其它两个按键,这样,当我们点击"开始"和"退出"的时候,集中处理层就会主动拒绝请求。当我们添加新功能的时候,我们会发现这个时候的消息层不需要修改任何东西,集中处理层也一样,毕竟我们在执行判断是否打开的时候,并不会在意打开后是怎样的情况,以及按照哪种动画进行打开。
2.数据与代码
可以想到,大部分初学者参考网络传播的各类游戏设计方案的按键交互内容,上来就是一个check_keyboard(),紧接着就是if、if、if循环,例如这样,
//这里假设为2D游戏 //check_keyboard()为按键检测函数,具体实现方法这里不会涉及 if (check_keyboard('A')){ move_turn_left();//左走 } if (check_keyboard('D')){ move_turn_right();//右走 } if (check_keyboard('W')){ look_up();//往上看 } if (check_keyboard('S')){ look_down();//往下看 } ...
这样做的结果显而易见,代码越写越多,过了一段时间后,完全不知道前面写的是什么,紧接着就是想要添加新功能的时候,例如我想用"s"键在角色进入x状态时候,不再是往下看,而是抓住某个东西,又要把前面的代码改一遍。
经过第一章对于消息传递的分析,为了避免如上情况的发生,我认为编程里还是要将代码和数据完全分开,请注意,是完全分开。为了区分二者关系,在我的理解里,"数据"是被判断的东西,"代码"是用来判断的东西。就像是一个电路,电路相当于"代码",电流相当于"数据"。
于是乎,整个程序的循环就变成数据推动类型,我将第一章所讨论到的层级关系可视作数据的逐级传递,那些内置的API暂时可以视作一些开关(至于如何实现的这里不进行描述),在每一层里,当第一批数据(数据A)到达了第一批代码(代码A),便被变成了数据B,并且向下传递,他们之间的关系可以视作,
图2 数据传递模型
为了贯彻前几段提及的思想——"数据"是被判断的东西,"代码"是用来判断的东西,所以将数据传递模型整理成图1(b)的模样。通过额外的数据A*来决定数据B是一组怎样的数据,再通过额外的数据B*来决定后面的数据,依次执行到结束。代码在其中只做了查找和翻译功能。其中需要注意一点,数据A、B,甚至后面的C、D、E...有时候可能是一条数据上的不同位置,代码通过查找对应段落对原数据进行处理或直接传递。
那么,让我们来走一下第一章所讨论的标题界面到设置窗口的流程。
首先,初始数据来源于鼠标点击(输入),一旦点击了设置选项(数据A),就进入"判断是否可以点击的代码"(代码A),"代码"(代码A)需要查阅"另一个文件"(数据A*)判断,如果可以点击,就制作了"点击设置选项的请求"(数据B),进入打开设置窗口的代码(代码B),并从"设置窗口的数据集"(数据B*)里知道设置窗口有什么内容,需要怎样绘制。
图3 数据过五关斩六将示意图
虽然看上去有点绕,在实际的编程中反而相当容易实现,相关案例在第四章进行讲解。第三章的内容主要介绍数据是如何被使用的,我讲得比较浅,如果觉得自己在数据类型这块非常OK了,可以跳过。
3.数据分类
代码有无数种写法,但是对于数据的基本分类屈指可数。本章对常见数据类型不进行解释,主要介绍到数据类型有"组"和"字典",为了防止有 "真" 初学者看不懂后续内容,这里先解释“字典”的概念。就是 字符串(关键字)+(数字/数组/字符串/字符串组) 之类的东西,其定义如下,
字典=[“first":1, "second":[1,2,3,4], "third":"first", "fourth":["first","second","third"...], ...];
在我大学时期翻阅编程语言,包括一些网络教学里,开篇第一章就拿来介绍数据类型,并且说了哪种类型有占多少字节之类,但在我学习阶段,它们并没有受到我过多重视,可能主要得益于个人使用的编程语言并没有对数据进行分类,而且也不用处理超大数据这样。
在数组、字符串组和字典使用时,我们需要分清程序对数据的处理有无有序(这里的序并不是顺序的序,而是秩序的序),例如现在有一数组,
a=[1,2,3];
如果我想知道在这个数组里是否存在3,那么对于3的位置,在这个问题里并不关注。
反观,假设有一字典为,
a=[“first":3,"second":[1,2,3,4]];
如果我想知道在这个字典里"first"的值为多少,那按照所写的字典,我就知道值是3而不是其他值。
介绍完这些低级概念后,就需要介绍高级数据概念。让我们思考一下车道,如果开过车,会知道主路、辅路、高速路这些东西,相比水泥路、沥青路、黄泥路这些低级的道路分类,主路、辅路、高速路、环岛等可以被称作是一种道路的高级分类。所以,如何将不同数据在程序中进行高级分类,是实现框架的关键。(可能举的例子不太浅显易懂。)
试想一下,假如你需要提供一个按键修改功能,功能要如何实现,又应该如何将修改后的按键信息反馈到游戏内部?按照框架,一个最简单的方法是将所有按键集中到一起,记录按键对应,如果哪个按键检测到了,就去找它对应的行为。例如,
key=[<a>, <b>, <c> ...];//<>在这里表示对应按键被触发为1,否则为0 for(var i=0; i<26; i++){ if(key[i]){ 执行相应操作; } }
可以明显看到,key可以通过位置不变,数值改变的方式进行按键修改,比如在key[0]这个地方,给他定义为左走,就可以修改其中的按键映射关系实现修改。可以考虑在设计阶段就将位置和按键的对应表写在注释里。在每次修改键位之后,虽然能实现功能,但总得来说并没对不同关系之间进行很好的数据解耦方式。
为了能使代码变得更自动化,我更倾向这样编写,
key=[<a>, <b>, <c> ...]; //对应按键被触发为1,否则为0 key_to_action(); //按键绑定行为的函数 action=["左":key[0], "右":key[1], "上":key[2], ...]; //最终生成的行为列表 action_list=["左", "右", ...]; for(var i=0; i < size(action_list); i++){ if(action[action_list[i]]){ 执行相应操作; } }
从中,我们就可以看到了三种高级数据类型:实时数据类型(key)、中转数据类型(action)和固定数据类型(action_list)。
其中,实时数据类型主要负责与计算机底层相连,中转数据类型主要通过将实时内容进行翻译,最后通过对比固定数据类型进行比较,很好地实现了不同数据类型之间的解耦。在计算机语言里,这几种数据类型有自己的专业名称,例如C、C++语言里的头文件、动态、静态数据库等,这里没有那么深入。
接着,了解到了这三种数据类型的时候,我们先捋一捋在程序运行过程中,数据是如何传递的。
首先是实时检测输入,数据存储为--实时数据。 接着是将实时数据制作成消息因子,但我更希望它在制作之前就实现实时数据与消息因子的解耦,所以将先进行数据处理--中转数据。 在集中处理层,在通过数据的时候,需要对数据进行判定,是否继续传递,这里第一次不会有任何数据,但会将此条数据进行记录,由于没有中转关系,但又不和计算机底层硬件相挂钩,成为了--第二类实时数据。 在数据通过集中处理层后,根据数据内容,集中处理层修改了对应数据所操作的对象的状态,从而使得对象通过固定数据,修改对象固有属性,进行渲染(例如效果、动画等)。 其中,在对象运行时,依旧会发送信息,禁止其他消息运行--由于不和底层挂钩,是第二类实时数据,周而复始...
这样,就完成了游戏的框架设计,我将在下一章在GMS的基础上提供具体实现,顺带一提,在本框架下,游戏逻辑上对各元素的可控性强,对游戏设计有启发。
4.案例
这一章首先将GMS的部分内容与上述框架相结合,最后对按键映射进行了实际测试。
4.1 介绍GMS所使用到的工具
GMS就是gamemaker studio啊,其历史发展和有哪些优秀作品出现这里并不会关注。作为一个老游戏引擎,到现在都还有更新确实是挺厉害的,不过我在之前从没用GMS写过东西,顺带一提,是我使用任何游戏引擎都没有写出过什么游戏。在选择使用GMS之前,我一直不清楚自己究竟想制作怎样的游戏,看着各类开发者发布的游戏信息显得迷茫,不过终于在去年决定了自己的游戏类型为2D横板过关,所以就用GMS了,虽然UNITY和UNREAL都看过其中的2D,但骨骼绑定对我而言是个新的盲区,我并不打算搞那方面。
反而是GMS提供的内容简单易懂,挺好的。版本为IDE v2.2.4.474,在steam里,可以从属性-测试版选择,因为我第一次接触就是这个版本,后来更新了,改了些东西,不是很愿意接受就用回旧版。
其资源列表如下,分为管理动画的Sprites,管理相当于是预制件的东西的Object,和游戏场景Rooms,
图4 gamemaker studio 2 的资源树列表
Object为GMS编程的核心部分,按照我的理解可以分为主要代码文件和预制件,Scripts可以视作头文件,Rooms为场景(默认运行第一层内容),其他东西可以视作游戏内资源。
根据前几章内容,我需要创建消息因子--消息队列--集中处理层这三个核心Object。
除此之外,我还需要一个控制角色的脚本。对此,根据以前学习的知识,我将这个脚本分为角色状态管理脚本(PLAYER_OBJECT)、角色控制器脚本(CONTROLLER)和角色动画播放脚本(PLAYER_ANIMATE)分开管理,使用PlayerInputLayer作为角色主要脚本管理。
图5 Object复选框展开
可以看到SYSTEM下有许多脚本,有些是上个日志里提到的,也是我未来可能会展开的内容。在这里暂时不管。
如此一来,核心脚本为message、QUEUE、LOGIC+EXECUTE。
接下来从message开始,
图6 message复选框
message作为一个载体,本身是不需要任何变量,但经过我的测试,在GMS中对不存在的变量会报错,所以需要初始化一些东西。其次,消息内容规则化需要设计的参与,这里暂时不展开探讨。
在GMS中,通过代码,
tmp_message = instance_create(x,y,"QUEUE",message); with(tmp_message){ style="BUTTON"; number=10; }
创建message,其中,xy分别为创建的位置,"QUEUE"在Room内部被定义为某一层的名称,例如名为"Object"的层放各种游戏物件,名为"UI"的层用于绘制UI等,这里专门创建了"QUEUE"层进行消息处理、传递。
with(tmp_message)后面部分的内容为对tmp_message内的变量进行定义,可以顺便在这里留下创建其的信息。
多个message被创建在了"QUEUE"层的简图如下所示,
图7 message创建示意图
由于消息因子传递到消息队列上,我们需要接收这一层的所有消息,可以通过instance_exists(message)先访问有没message,再通过instance_find(message),找到所有被丢在房间内的message数据。(这个instance_exists(message)似乎会遍历所有层的message。)
图8 QUEUE复选框
通过以上在消息队列里的代码,实现收集message信息功能,由于在创建message的时候,message内部定义了名为style的变量,以区分这个message的作用,在得到message的信息时,查看message内的style变量,对message进行分类,
switch tmp_message[j].style{ var a = tmp_message[j]; var b = [a.style, a.sponsor, a.b_name]; case "BUTTON": ds_list_add(tmp_b_m, b); break; case "BUTTON_LEAVE": ds_list_add(tmp_bl_m, b); break; }
其中,ds_list_add(A, B)为GML语言的函数,主要是给列表A的后面加一个B变量。
tmp_b_m和tmp_bl_m为两个列表。一个用于接收按键信息,一个用于接收按键信息消除信息。像是消息清除消息一般在QUEUE内部自己清理再传递,而无需经过逻辑层(因为这类消息一定会发生的。)
sponsor为消息发起者。content为消息的内容
这里我稍微偷了点懒,虽然在第二章中提到将数据与代码完全分开的理念贯彻到底,但这里对于消息类型的处理却没有贯彻。因为消息类型主要是按键信息和游戏内信息,分类较少,就把它简单展开了,写本文的时候,恰巧只完成按键检测,所以没有"游戏内信息"的类型。
在消息处理层(LOGIC),通过收集QUEUE内的列表,并采用被控对象的状态禁用列表对其进行判断,便可以实现将数据有逻辑地向下进行传递。
LOGIC: //创建两个相同列表,一个用于对比一个用于下级传递; //接收QUEUE层的数据 logic_list = QUEUE.tmp_b_m; logic_transmit_list = ds_list_create(); //循环,检查QUEUE层数据与相应禁用状态数据 for(var i=0; i<ds_list_size(logic_list); i++){ var tmp_message = ds_list_find_value(logic_list, i); var tmp_ms = tmp_message[1];//在设计阶段定义列表位置为1时为发起者相关信息 var tmp_mc = tmp_message[2];//定义列表位置为2时为改变的状态 //检查禁用状态数据列表//来自被控对象身上 for(var j=0; j<ds_list_size(tmp_ms.ab_list);j++){ var tmp_abound = ds_list_find_value(tmp_ms.ab_list, j); //将信息对等的数据清除 if(tmp_mc==tmp_abound){ ds_list_delect(logic_transmit_list, i); } } }
如此,交互核心内容完成,禁用状态数据需要在设计方面下功夫。
4.2 实现按键-状态控制功能
本章作为最后一章,使用之前提到的所有概念,从按键列表映射到角色控制开始,然后实现角色在不同状态下不同输入对其的改变。
在图5有一个CONTROLLER的Object,我的所有按键信息都在上方,已知的一件事是按键的数量是有限的,和固定数据类型一样,需要提前进行定义,因为我比较喜欢用手柄玩游戏,所以按键列表大致是有20个,考虑到某些按键并没有涉及,所以会对最终按键列表进行修改。如,
//定义按键名称列表//下面共有5个按键,希望它对应A、D、W、S、J键。 button_list=["axislhl","axislhr","axislvu","axislvd","jump_b"]; //定义输入列表,与上面的列表相对应。//ord()返回ASCAII码,例如ord('A')返回65 input_list=[ord("A"),ord("D"),ord("W"),ord("S"),ord("J"),] //创建新map,将上述列表整合 input_to_button=ds_map_create(); for (var i=0; i< 4; i++){ ds_map_add(input_to_button, button_list[i], input_list[i]); }
上列代码实现了对所有被提前设计好的按键的映射,可以在其基础上开发按键修改功能--只需要修改input_list的内容即可。
接下来使用,
//ds_map_size(A)返回A的大小; var tmp_num=ds_map_size(input_to_button); //定义一个空列表 var tmp_button_list[tmp_num]=0; //根据input_to_button的信息,将按键的实时信息填入列表 for(var i=0; i<tmp_num; i++){ var tmp_key=ds_map_find_value(input_to_button, button_list[i]); tmp_button_list[i]=keyboard_check_pressed(tmp_key); }
其中,keyboard_check_pressed(),检测相对应的按键有没被按下,并返回0或1。
接着,通过,
for (var i=0; i< tmp_num; i++){ if(tmp_button_list[i]==1){ var button_message = instance_create_layer(0,0,"QUEUE",message); with (button_message){ style = "BUTTON"; sponsor = other.father; b_name = other.button_list[i]; } } }
在"QUEUE"层创建一个message信息,并且包含了消息类型、消息发起者和消息内容,通过QUEUE整理并向下传递达LOGIC层(方式在上一小节提到过),再到被操作对象。(这里没有提到按键离开的场景如何处理,我先创建一个永远等于上一帧的列表,后面对比两者,如果有差,就说明那个键离开了,就发出按键清理消息,在QUEUE层完成清理,不必走向下一步。)
假设被操作对象的主循环代码为,其中"MOVE_R"对应QUEUE的contant,其中有个中转数据我这边没给出,这个中转数据存于LOGIC层,对应b_name等于"axishr"的时候,
if(STATUS=="MOVE_R"){ x+=10; }
其中,x为这个目标的横坐标位置,STATUS为这个物体里的一个变量(目前我还不会用GMS列表装变量,所以暂时只能分开写)。
则当我按下 "D" 键的时候,物体会朝右走。如果这个时候我在中间加上一项,
if(STATUS=="MOVE_R"){ x+=10; ab_list=["axislvu", "jump_b"]; }
这样,因为LOGIC层会先检测ab_list,所以在按下 "W" 和 "J" 的时候,没有反应。这里都是用了简单的举例,具体的实现对现在的我而言还是有些复杂(主要是设计层面上),不过核心代码和机制就在这边了。
总结
这是我第一次写的经验总结,反复修改和推算后,许多地方的讲解仍旧还不是很到位,可以认为我的能力就仅此而已吧。感觉大部分会来indienova的人在这方面也不成问题,理解了其中的机制后,根据所使用语言的特性进行修改就行了,我觉得GMS的编程还是不够简洁。关于不同数据类型的构建,我还在调试中,末端的角色控制器可以看作是一个预制件,我已经在其他文件中写好了,但暂时没将其植入,所以在最后只展示了一些简单的控制思路(甚至视频也懒得录)。
本文的内容主要为分离数据与代码,为实现高效设计提供思路,按理来说应该是全部数据和代码的分层,但因为一些原因,我暂时做不到最好,待我再研究研究吧... 但相比于游戏在代码层面上的设计,游戏的本身内容才是各位开发者的工作重心。
这篇心得花了大概一周时间完成,期间放弃了几次,原因是觉得对大部分人可能没啥用,但想到虽然不是特别好的想法,但不能就这么放弃掉,决心!决心啊!!所以撑到最后还是发出来了,只是后劲不足,可能讲得不够详细QAQ,甚至实际视频也没有录...
不错的总结(个人觉得还可以更精炼一点哈),输入处理这块,感觉很多引擎没有太重视,仅仅只是支持了底层的输入事件广播,但现代游戏对输入系统的要求其实挺高的,像要同时支持多种输入设备(手柄、键鼠),按键映射,实时输入设备切换,还有作者这里处理的输入和逻辑分离等。
如果采用Unity,推荐下新的InputSystem,基本把我想到和没想到的问题都解决了~
@agoo:实际上这个不仅仅是面向玩家的输入处理,还涉及游戏内其他元素,例如不同敌人之间的互动、UI绘制等。因为AI行为需要知道一些环境、角色和其他敌人的位置信息,并且需要做出什么样的行为(因为我这些东西还没策划出来,暂时没放到原文里)。你可以把它想象成计算机的一个内核,围绕其展开的是用户输入、互联网输入和不同窗口之间的联动,这个内核的目的在于将各方的输入耦合在一起。
我用GMS的主要原因是占用资源小,设计比较灵活。你推荐的UNITY的InputSystem,我会去看的。
非常感谢答复。
@CHIIIB:哦,你这里说的是抽象的输入,哈哈,我个人也比较喜欢思考这类技术框架问题,期待你后续的更新
@agoo:好的,感谢。不过我觉得代码上的内容已经写完了,剩下的事情是整理好各种数据类型之间的关系,觉得写出来太水,不太好... 后面除了主日志,暂时没啥更新计划...
看了一眼,整篇文章满满地写了三个字“表驱动”
@mnikn:确实如此。