本文只是其中的一部分,未完待续。
毫无疑问,异步多人游戏玩法(也就是允许玩家几天登录一次游戏)是手机游戏开发的新趋势。许多热门的多人游戏都是异步的,比如《填字游戏》(一种每次移步一步的拼字游 戏)和《你猜我画》(Zynga所收购的游戏)。
当我的跨平台多人游戏《英雄法师》在iOS上发布时,我以为人们会为它的玩法——允许使用PC或Android设备与朋友一起在线战斗,感到兴奋。让我惊讶的是,我收到的反馈中, 绝大多数的意思是“这款游戏如果能够多名玩家不同时在线也能玩就太好了!”
正如大多数《英雄法师》的玩家所知道的,我并没有感到沮丧——我立即将下一个包括异步多人玩法的更新当作优先工作。我以前从来没有编写过“异步多人”的代码,所以我想 我得从我一惯的做法入手:谷歌搜索。“如何编写异步多人游戏玩法的代码”的搜索结果并不实用:我发现有不少关于“异步玩法多么了不起”和“了解大量支持异步玩法的游戏 ”的文章,但它们并没有讲到“如何制作”的点子上。因此我想到“这对游戏开发者来说应该是很重要的资源”,所以我决定记录我制作《英雄法师》的异步多人玩法的过程,并
发表出来,这样我们都能从我所希望写成的“如何制作”系列文章中获益。
《英雄法师》是用Adobe Flash制作的,所以我的程序代码案例是ActionScript 3格式的。但是,异步多人的设计和机制适用于任何开发语言。
向大师学习
学习如何编程的最好办法就是,研究成功地实现你想要的结果的应用。我的目标是在我的幻想风格、回合制、策略游戏中实现异步多人玩法。苹果应用商店里正好有一款类似的游 戏,并且它的异步玩法做得非常棒。它就是Robot Entertainment的《英雄学院》。所以我花了一些时间玩这款游戏(这是最好的研究方法)。我发现,这款游戏将社交媒体如 Facebook和Twitter,与有效的异步多人UI相结合,很好地解决了“孤立社区”的难题——这也是我自己的“在线即时”多人游戏遇到的困境。

Hero-Academy-Gameplay(from indieflashblog)
以下是《英雄学院》的异步多人玩法的概述:
1、启动游戏时自动登录服务器
2、通过Facebook和Twitter邀请/挑战玩家
3、创建新游戏的选项或寻找随机对手的选项
A、如果玩家创建新游戏,则新游戏将被添加到内部游戏列表中,等待其他玩家加入。
B、如果玩家选择加入游戏,则玩家将加入在内部游戏列表中显示的随机游戏。加入的玩家得到第一回合。
4、玩家在自己的回合中,在提交命令以前,可以执行全部的5次移动或取消所有移动。
5、一旦回合提交,游戏的数据库将更新游戏状态,并“推送”一个提示给对手,告诉该玩家轮到他的回合。
6、对手有24个小时可完成他的回合,否则玩家可以宣布该回合失效。
7、玩家可以选择在这个游戏中轮流,或返回个人“游戏列表”中加载任何已经玩过的游戏。游戏以对手、创建数据、最后一次移动时间和状态(即胜利、失败、等待回合或就绪) 为标签。
《英雄法师》的特殊要求
对《英雄学院》的研究使我深入了解了异步多人玩法,但《英雄法师》因为其特殊的游戏机制,还必须考虑到其他问题:
1、《英雄学院》具有取消功能,对异步玩法来说是非常棒的,因为玩家在提交最终选择以前,可以实验不同的移动组合。《英雄学院》能够这么设定,是因为游戏中的所有伤害量 是固定的。而《英雄法师》要根据骰子数计算伤害,所以撤消的选项就不可行了,因为它会影响游戏的关键机制:运气。
2、《英雄学院》不支持即时多人玩法。所有移动和甚至玩家聊天都是通过数据库更新来记录游戏状态的。虽然有可能和其他人在同一个房间内玩《英雄学院》,但这种体验并不理 想,因为你必须等待推送提示你对手完成他的命令。对于《英雄法师》,这个系统的改进办法是,当双方玩家均在线时,能够保存了“即时”游戏链接——这样你就能实时看到你 的对手的移动和交流信息。
如何制作异步多人玩法
了解了《英雄学院》的UI,以及认真考虑《英雄法师》的特殊要求后,我想到以下执行异步多人玩法的必要步骤:
1、想办法将游戏状态保存到在线数据库中。
2、编写一个数据库查询,用来加载玩家的游戏列表,同时通过点击列表上的项目,使玩家载入和恢复游戏。
3、在载入游戏时,点击可查看该游戏是否可以“即时”玩。如果可以,则加入该游戏,并与目前在线的玩家关联。如果不可以,则创建一个“即时”游戏房间,并载入该游戏。
4、想办法回放任何自玩家上一次登录后没有“看到”的动画。
5、在实际游戏中,使数据库中已保存的游戏状态更新玩家的命令(这与《英雄学院》是不同的,因为《英雄学院》要求你等到对方按下回合结束键,数据库才更新)。通过编写持 续的、更小的更新,可以节省带宽,而且可以很自然地从异步过渡到即时玩法。
6、想办法将即时玩法元素(回合计时器、掉落计时器、游戏持续时间表、AI变化控制器)过渡到异步玩法(游戏邦注:这是《英雄法师》的特殊要求,不适用于其他异步游戏)。
7、设计一个UI,用于浏览和加入异步多人游戏。
8、制作一个匹配系统,允许玩家选择军队参数、对手类型等,还可以将玩家与数据库中的可用对手相匹配。
9、当游戏回合结束时,通过邮件或设备推送提示发送回合开始的信息给下一个对手。

game List Layout(from indieflashblog)
异步多人游戏允许两个或以上的玩家参与游戏,不需要同时登录。支持异步玩法的关键是,将游戏状态保存到在线数据库中,这样你和你的对手才能在自己的回合时重新取回游戏 。本文将解释我如何实现游戏状态储存和重新载入,并且提供实用的代码案例,希望在你为游戏设计相同的玩法时能派上用场。
本文将介绍:
1、如何通过简单的2D网格表现法和基于该表现法的命令记录表现游戏状态。
2、如何使用Smart Fox Server Pro的服务器端扩展将游戏数据写入在线数据库。
要求
前提:
开发回合制游戏的经验
熟悉ActionScript 3.0
知道如何设置MySQL数据库
知道如何编写Smart Fox服务器扩展
必需产品:
Flash Professional
Smart Fox Server Pro
MySQL Database
用户水平:
中级到高级
将游戏状态表现为数据
游戏状态是由所有定义游戏面板当前状态的元素组成的:游戏面板的布局、游戏部件的位置、游戏中的所有角色的当前属性和作用、各玩家手中的卡片,以及(如果对游戏很重要 )产生游戏当前状态的一系列移动。如何根据这些因素的复杂度良好地展示游戏数据。在《英雄法师》中,我使用了两种办法——简单的2D网格表现法和基于表现法的命令记录。
简单的2D网格表现法
可以使用用二维数组表现游戏部件在游戏面板网格上的位置。例如,一个简单三连棋游戏可以表现如下:
//CODE EXAMPLE 1: TIC TAC TOE REPRESENTED AS 2D ARRAY
var ticTacToeGameState:Array = [];
ticTacToeGameState[0] = [X, O, X];
ticTacToeGameState[1] = [X, X, O];
ticTacToeGameState[2] = [O, X, O];这个基于表现法的数据在程序代码的情况下是足够的,但对于异步游戏,必须使用平面数据结构将这个表现法保存到在线数据库中。当表现游戏状态时,为了节约带宽和服务器空 间,最好使用尽可能少的信息。
假设我们知道三连棋总是3×3,那么这个游戏可以使用平面字符串表示如下:
//CODE EXAMPLE 2: TIC TAC TOE REPRESENTED AS FLAT STRING
var ticTacToeGameState:String = “XOXXXOOXO”;在取回游戏状态数据时,我们可以再次建立如上所示的二维数组:
//CODE EXAMPLE 3: CONVERT 2D GAME STRING TO 2D ARRAY
var ticTacToeGameGrid:Array = [];
for(var i:int = 0; i < 3; i++){
var gridRow:Array = [];
for(var j:int = 0; j < 3; j++){
gridRow.push(ticTacToeGameState.charAt(i+j));
}
ticTacToeGameGrid.push(gridRow);
}《英雄法师》使用这个简单的2D网格表现法将地图布局保存成一系列X和O。X表示墙,O表示开放空间,起始位置是一系列表示玩家和特殊单位类型放置区域的数值组合。
基于表现法的命令日志
如上所示,拉成一条单行文本串的二组数组可以用来表现许多基于网格的游戏。那种根据特定游戏活动发生时间的游戏很适合用基于现表法的命令日志来表示。
基于表现法的命令日志的好处
命令日志表现法的作用是,使游戏引擎通过提供产生当前状态的游戏命令列表,重制保存好的游戏状态。命令被储存成简化符号,以节省文件空间和带宽。当接收命令日志时,动 画将不可播放,这样游戏就可以立即重制了。
使用命令日志重制游戏确保所有必要的游戏细节:游戏卡片、面板部件和这些部件的状态(游戏邦注:准确地表现为完全相同的样式,这个样式产生最初的游戏状态)。另外,命 令日志显示了完整的游戏历史。对于异步游戏来说,这是极其有益的,因为玩家可以回顾活动列表,然后想起他们的当前状态是如何产生的,从而制定相应的策略。玩家还可以恢复自己没有机会看到的活动。
缩略的游戏符号
编写有效的基于表现法的命令日志,困难的地方在于设计一种既简单又准确的符号形式。为命令格式定义一系列预期标准也是很重要的。以下是我为《英雄法师》制作的句法:
1、各个独立命令放在“
2、命令内容由“|”分开,内容分配由“=”表示
3、所有命令都包含定义命令类型(“cT”)的内容。不同的命令类型来自相应函数名称的缩写符号。例如,指示单位执行某活动的命令可以表示为“cT=uA”。
4、复杂的数据结构如单位和活动由特殊的数值id表示。
A、根据单位被添加到游戏面板的顺序分配id。
B、根据咒语在面板数组的索引来分配id。
C、根据能力在单位能力数组的索引来分配id。
5、命令的目标用逗号隔开;网格坐标(X和Y)用冒号隔开。
通过遵守严格的符号和用数值id引用复杂的实例对象,游戏命令可以用来表示简单的字符串,如下所示:
//CODE EXAMPLE 4: OBJECT TO STRING FUNCTION
function objectToString(object:Object, separator:String, valAssignment:String):String{
var string:String = “”;
for (var prop:* in object){
string += prop + valAssignment + object[prop] + separator;
}
string = string.substr(0, string.length – separator.length);
return string;
}如果你的对象包含嵌套的数组,你就必须首先使用特殊分离器和分配字符将那些数组编码成字符串:
//CODE EXAMPLE 5: CONVERT ARRAY TO STRING
var myArray:Array = [4, 5, 6, 7];
var myStringArray:String = myArray.toString();以下是《英雄法师》中的完整游戏命令:
//CODE EXAMPLE 6: GENERATE GAME COMMAND
function generateUseActionCommandString():String{
//Create a new object to store command properties
var HM_UseAction:Object = new Object();
//Store the command type: “uA” represents “useAction”
HM_UseAction.cT = “uA”;
//The abilityUser is a complex, custom datatype
//So, we store the id of the unit using ability
HM_UseAction.uId = abilityUser.unitId;
//The unit’s ability is also a complex, custom datatype
//So, we store the id that represents its index in the abilities array
HM_UseAction.i = abilityIndex;
//pT represents the primary targets
//In actual game, a function discerns between target types (units, spaces)
//Here, we simply convert the array of choices to a comma deliniated string
HM_UseAction.pT = primaryTargetsToActOn.toString();
//Encapsulate the command within c-tags
var strCmd:String = “
//A preview of the assembled command
trace(strCmd) //
//Return the command
return strCmd;
}作为参考,下列函数可以用来将字符串转换回对象:
//CODE EXAMPLE 7: STRING TO OBJECT FUNCTION
function stringToObject(string:String, separator:String, valAssigment:String):Object{
var object:Object = new Object();
var props:Array = string.split(separator);
for(var i:int = 0; i < props.length; i++){
var vals:Array = props[i].split(valAssigment);
object[vals[0]] = vals[1];
}
return object;
}将游戏写入数据库
如果游戏应用可以用简单的文本文件表现它的保存状态,那么下一步就是将游戏的保存数据写入在线数据库,这样其他玩家就可以随时恢复游戏状态。为此,你需要一个带MySQL、 SQL或其他形式的数据库的网上服务器,以及一个网上服务或服务器来与数据库交流、运行必要的查询,和发送/接收来自应用的数据。
《英雄法师》使用Smart Fox服务器完成实时多人连接(在线聊天和异步玩法),所以我使用服务器端代码来处理与数据库的交流活动。《英雄法师》的数据储存在MySQL数据库中 ,我已经通过我的主机供应商GoDaddy.com提前做好这个数据库了。我喜欢使用SmartFox服务器,是因为我可以通过ActionScript 1.0直接使用MySQL,而不必担心不懂PHP或其他服
务器语言的问题。
定义储存游戏数据的表格
定义将用来把游戏信息保存在数据库中的表格也非常重要。为此,《英雄法师》使用两套表格:
表格“hm_games”用来保存所有相关的游戏数据。“cmdLog”栏保存游戏引擎将用于重建游戏状态的符号命令的实际列表。
hm_gameresults
Field Type Notes
ID_MEMBER mediumint(8) The unique id of player participating in this game
ID_GAME int(10) The unique id of the game record for this result
result tinyint(4) The outcome of the game for this player (win/loss)
ratingChange tinyint(4) The change in players rating for ranked games创建新游戏记录
《英雄法师》具有异步多人玩法,但我还没有给异步匹配系统做过UI。然而,令人兴奋的游戏创建屏幕非常适合用来解释如何将新游戏记录保存到数据库。
游戏客户端和在线服务之间的基本通信运作如下:
1、如果游戏主机已配置所有游戏选项,并且玩家觉得满意,则他们会按下“Start Game”。
2、游戏客户端将游戏背景格式化为符号游戏命令,发送命令给服务器,并等待回应。
3、服务器端脚本接收命令并创建游戏记录,执行一个MySQL声明,以便在hm_gameresults表格中创建新入口,和在hm_gameresults表格中为各名玩家创建新记录。
4、如果服务器的数据库运行完全顺利,则服务器对客户端作出回应,反馈新创建的游戏记录的ID_GAME。如果服务器运行失败,客户端接收到新游戏无法创建的反馈。
5、如果客户端接收ID_GAME,游戏主机就用这个内容重新装配命令符号,并发送开始游戏命令给所有玩家。如果收到“操作失败”,则游戏客户端将显示错误信息。
注:如果你需要学习如何给Smart Fox Server Pro编写服务器端扩展,请参考其他网上教程。
当符号化游戏命令装配完毕,就使用以下函数发送命令给服务器端扩展:
//CODE EXAMPLE 8: SEND CREATE GAME COMMAND
private function sendStartGameCommand(HM_GameVars:Object):void{
//Store a reference to the created game settings object for use later
gameVarObj = HM_GameVars;
//Check to see that smartFox is connected and that there are at least 2 players
if(smartFox.isConnected == true && playerSettingsList.length > 1){
//Send the command to create new game record to Smart Fox Server extension
//Commands can be sent as string or xml; normally, I use string for speed
//In this case, I use xml to save the work of encoding to string
smartFox.sendXtMessage(“HMServer”, “CreateGameRecord”, HM_GameVars, “xml”);
}
else{
//If this is a practice game with only 1 player, no need to store to database
//Fire game up immediately
fireUpGameWithRecordID(-1);
}
}在服务器端,我给“Create Game”命令添加了新条件,用来在数据库中插入新游戏记录:
//CODE EXAMPLE 9: CREATE GAME RECORD IN DATABASE
function handleRequest(cmd, params, user, fromRoom, protocol){
if(protocol == “xml”){
//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….
else if(cmd == “CreateGameRecord”){
//EXTRACT THE PLAYER INFORMATION AND TURN ORDER FROM THE RECEIVED COMMAND
var players = String(params.pS).split(“,”);
var randomTurnOrders = params.rTO.split(“,”);
//GENERATE THE MYSQL STATEMENT TO ADD NEW GAME RECORD BASED ON GAME SETTINGS
var gameRecordSQL = “INSERT into hm_games (ranked, timeCreated, version, status, timeRecorded, whoseTurn, cmdLog) VALUES (”
gameRecordSQL += “‘” + params.r + “‘, “ //ranked
gameRecordSQL += “‘” + Math.floor(getTimer() / 1000) + “‘, “ //timeCreated
gameRecordSQL += “‘” + params.v + “‘, “ //version
gameRecordSQL += “‘” + 1 + “‘, “ //status
gameRecordSQL += “‘” + Math.floor(getTimer() / 1000) + “‘, “ //timeRecorded
gameRecordSQL += “‘” + stringToObject(players[randomTurnOrders[0]], “;”, “:”).hmId + “‘, “ //whoseTurn
gameRecordSQL += “‘” + “
gameRecordSQL += “)”;
//EXECUTE MYSQL COMMAND AND CHECK IF IT WAS SUCCESSFUL
success = dbase.executeCommand(gameRecordSQL);
if(success == false){
//IF THIS FAILS, WE NEED TO REPORT BACK AN ERROR TO CLIENT
trace(“UNABLE TO CREATE GAME RECORD”);
response.error = “Unable to create new game record in database”;
}
else{
//ONCE GAME RECORD IS ADDED, GRAB ITS ID (WE KNOW ITS THE LAST INSERTED RECORD)
sql = “SELECT LAST_INSERT_ID()”
var queryRes = dbase.executeQuery(sql);
var dataRow = queryRes.get(0);
//STORE THE GAME RECORD ID IN OUR RESPONSE OBJECT
response.id = dataRow.getItem(“LAST_INSERT_ID()”);
//CREATE A STATEMENT TO INSERT A NEW RECORD IN GAME RESULTS TABLE FOR EACH PLAYER
var gameResultsSQL = “INSERT into hm_gameresults (ID_GAME, ID_MEMBER, result, ratingChange) VALUES “;
for(var i = 0; i < players.length; i++){
//CONVERT THE PLAYER OPTIONS FROM STRING TO OBJECT
//THIS IS SO WE CAN EXTRACT PROPERTIES LIKE PLAYER ID
var playerOptions = stringToObject(players[i], “;”, “:”)
gameResultsSQL += “(LAST_INSERT_ID(), ‘” + playerOptions.hmId + “‘, ‘” + “-2″ + “‘, ‘” + “0″ + “‘)”
if(i < players.length – 1){
gameResultsSQL += “, “;
}
}
success = dbase.executeCommand(gameResultsSQL);
if(success == false){
//IF THIS FAILS, WE NEED TO REPORT BACK AN ERROR TO CLIENT
trace(“UNABLE TO CREATE GAME RESULTS RECORD IN DATABASE”);
response.error = “Unable to create game results records in database”;
}
}
}
//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….
}
}返回客户端,我给来自服务器的“Create Game”命令的响应添加了新条件:
//CODE EXAMPLE 10: RECEIVE SERVER SIDE RESPONSE
private function onExtensionResponse(evt:SFSEvent):void{
//EXTRACT RESPONSE TYPE AND RESPONSE DATA FROM SFSEvent
var type:String = evt.params.type;
var dataObj:Object = evt.params.dataObj;
//….CODE OMITTED….
//EXTRA COMMAND FROM RETURNED DATA OBJECT
cmd= dataObj.cmd;
var error:String = dataObj.error;
//….CODE OMITTED….
if(error != “”){
//IF RESPONSE RETURNS AN ERROR, SHOW USER A MESSAGE PROMPT
showPrompt(“HM_MessagePrompt”, cmd + ” Error”, error);
}
else{
//….CODE OMITTED….
//ADD CONDITION FOR SERVER RESPONSE CREATE GAME RECORD
else if(cmd == “CreateGameRecord”){
//INSTRUCT GAME OPTION SCREEN TO FIRE UP GAME RECORD
gameOptionsScreen.fireUpGameWithRecordID(dataObj.id);
}
//….CODE OMITTED….
}
}这个调用游戏大厅的最后一个函数来发出开始游戏的命令:
//CODE EXAMPLE 11: FIRE UP GAME
public function fireUpGameWithRecordID(gameRecordId:int):void{
//Recall in previous step we stored gameVarObj for future use
//Here, we add the database id for the new game record
gameVarObj.gId = gameRecordId;
//Assemble the game command into abbreviated string notation
var HM_Command:String = Utils.objectToString(gameVarObj, “|”, “=”);
//Add the command to client side que
addToCommandQue(HM_Command);
//Send the command to start game to any live players
if(smartFox.isConnected == true){
smartFox.sendCmd(HM_Command);
}
}更新游戏状态
当创建在在线数据库中的游戏记录和游戏客户端可以获得引用记录的ID时,游戏状态的改变就可以由附加的新游戏命令轻松记录到命令日志栏中。
在本文的前半部分,我想到游戏状态的更新应该根据游戏创建的方式来决定。在Robot Entertainment的《英雄学院》一例中,玩家在提交回合以前可以选择取消,更新游戏状态自 然要在回合提交后发生。相反地,《英雄法师》允许玩家秘各个可用单位互动、施放咒语和发动攻击(根据骰子数决定伤害程度)。因为结果的随机性,《英雄法师》就不能使用 取消功能了。因此,我决定,玩家每发送一次命令,游戏的命令日志就更新一次。
因为《英雄法师》也可以即时玩,所以我决定把我现在的服务器扩展(用于交换即时玩家之间的游戏命令)也更新了,使它也能处理储存在数据库中的游戏状态。这样,只需要让 客户端发送一次命令给服务器,我就可以最有效地利用带宽。
以下是处理游戏状态更新的代码:
//CODE EXAMPLE 12: UPDATE GAME RECORD
function handleRequest(cmd, params, user, fromRoom, protocol){
if(protocol == “str”){
//GENERATE LIST OF RECIPIENTS THE SERVER WILL SEND THIS COMMAND TO
//….CODE OMITTED….
//params[2] stores game record id
//If this game record id is included, we need to write this command to stored game log
if(params[2] != undefined){
if(params[1].indexOf(“cT=eT”) != -1){//If this is an end turn command
//Convert notated command into object
var cmdObj = stringToObject(params[1]+””, “|”, “=”);
//Get the id of player whose turn is next
var nextTurnId = cmdObj.nId;
//Write update to game record in database
sql = “UPDATE hm_games set cmdLog = CONCAT(cmdLog, ‘
” + Math.floor(getTimer() / 1000) +”, whoseTurn = “+nextTurnId+” WHERE ID_GAME = ” + params[2];
}
else{
//Write update to game record in database
sql = “UPDATE hm_games set cmdLog = CONCAT(cmdLog, ‘
+ params[2];
}
success = dbase.executeCommand(sql);
if(success == false){
//THE DATABASE DID NOT RECORD THE MOVE CORRECTLY
//CREATE A NEW RESPONSE TO NOTIFY GAME CLIENT OF THE ERROR
}
}
_server.sendResponse([params[1]], -1, null, recipients, “str”);
return;
}
}总结
本文介绍了制作一个异步多人游戏的最基本的步骤:将游戏状态表现为数据,并保存到在线数据库中。在下一篇文章中,我将分享如何从数据库中恢复游戏状态,以及如何使用 Flash、ActionScript和Smart Fox服务器拓展在异步多人模式和即时在线模式之间无缝地转换。
异步游戏真的非常棒,因为玩家可以无需长期待在游戏中便能享受到有趣的游戏体验。是在线服务器成就了这种便捷的游戏风格,而本篇文章将着重解释如何通过服务器加载之前 所储存的数据,并将其用于游戏客户端的用户界面上。
本篇文章将解释:
1.如何查询储存于MySQL数据库中的一列游戏记录,并将结果传送给游戏客户端
2.如何在客户端上说明查询结果,并设计一个有意义的游戏列表用户界面而帮助我们更有效地游戏
3.如何通过一款异步游戏而再次创造出生动且同步的多人体验
4.如何重播动画以呈现出玩家对手的移动
要求
预备知识
基于游戏的开发体验
熟悉ActionScript 3.0
阅读过本系列文章的第二部分
产品要求
Flash Professional(试用版)
Smart Fox Server Pro(试用版)
MySQL数据库
用户级别
高级
生成玩家的游戏列表
大受欢迎的异步游戏,如《英雄法师》便使用了“游戏列表”用户界面,即让玩家能够进入并继续之前的异步游戏过程。
Hero-Academy-Game-List(from indieflashblog)
Hero-Academy-Game-List(from indieflashblog)
在创造《英雄法师》的游戏列表时,我最先创造了名为“HM_GamesList”的全新用户界面屏幕类。我想要先专注于数据和代码组件,所以最初的设计便局限于数据头和滚动列表组 件,即能够用于在服务器上填充信息。
这一界面中有一个数据查询库,即带有一列活跃玩家的游戏数据。所有MySQL查询生成都是发生在服务器一端,而与我们的在线服务器的交流如下:
1.游戏客户端:从服务器上请求数据
2.服务器:处理请求,向客户端发送回应
3.游戏客户端:收到服务器回应
4.游戏客户端:基于数据执行预期任务
步骤1:请求游戏列表
游戏客户端从服务器上请求游戏列表:
//CODE EXAMPLE 1: Request Game List from Server
private function getGameList(lowerLimit:int){
//Create a new object to send command parameters
var params = new Object();
//Pass the player’s unique member id
params.pId = pId;
//Show user prompt while waiting for response
showPrompt(“ProcessingRequestPrompt”);
//Send Smart Fox Server an extension message
/*
sendXtMessage(xtName:String, cmd:String, paramObj:*, type:String = “xml”)
xtName = Name of your server side extension
cmd = Unique identifier name for this command
paramObj = Object contain parameters for command
type = Indicates whether we’re sending as XML or raw string
*/
smartFox.sendXtMessage(“HMServer”, “Game List”, params, “xml”);
}步骤2:处理游戏列表请求
服务器端将处理请求并向客户端发送响应。在本系列文章的第二部分中,我曾经解释过游戏是如何使用两个附录(hm_games和hm_gameresults)保存到MySQL数据库中。功能 loadGameList将创建MySQL查询并发回我们所需要的数据去生成游戏列表。
//CODE EXAMPLE 2: Handle Game List Request on Server
function handleRequest(cmd, params, user, fromRoom, protocol){
if(protocol == “xml”){
//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….
else if(cmd == “Game List”){
if(params.hmId != null){
//THE FOLLOWING MYSQL STATEMENT GATHERS A LIST OF GAMES PLAYER HAS PLAYED BY JOINING
//THE GAME AND GAME RESULTS TABLES CREATED IN PART 2
var sql = “SELECT ID_GAME from hm_games JOIN hm_gameresults using (ID_GAME) WHERE ID_MEMBER =1″+params.hmId;
//WE CREATE AN ARRAY TO STORE THE GAME LIST
var gameList = [];
//WE EXECUTE THE QUERY
var queryRes = dbase.executeQuery(sql);
//IF THE QUERY RETURNS RESULTS, POPULATE TO ARRAY
if(queryRes != null && queryRes.size() > 0){
for(var i = 0; i < queryRes.size(); i++){
//GET THE ACTIVE ROW
var dataRow = queryRes.get(i);
//CREATE GAME RECORD OBJECT
var gameRecord = {};
//STORE THE GAME ID IN THE RECORD
gameRecord.ID_GAME = dataRow.getItem(“ID_GAME”);
//ADD RECORD TO ARRAY
gameList.push(gameRecord);
}
}
//STORE THE GAME LIST IN THE SERVER RESPONSE
response.gameList = getGameList(params.hmId);
}
}
//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….
}
}步骤3:收到游戏列表回应
游戏客户端收到服务器回应。
//CODE EXAMPLE 3: Receive Game List from Server
private function onExtensionResponse(evt:SFSEvent):void{
//EXTRACT RESPONSE TYPE AND RESPONSE DATA FROM SFSEvent
var type:String = evt.params.type;
var dataObj:Object = evt.params.dataObj;
//….CODE OMITTED….
//EXTRA COMMAND FROM RETURNED DATA OBJECT
cmd= dataObj.cmd;
var error:String = dataObj.error;
//….CODE OMITTED….
if(error != “”){
//IF RESPONSE RETURNS AN ERROR, SHOW USER A MESSAGE PROMPT
showPrompt(“HM_MessagePrompt”, cmd + ” Error”, error);
}
else{
//….CODE OMITTED….
//ADD CONDITION FOR SERVER RESPONSE CREATE GAME RECORD
else if(cmd == “Game List”){
//HIDE OUR PROCESSING REQUEST PROMPT
hidePrompt();
//INSRUCT OUR GAME LIST CLASS TO RECEIVE THE LIST
gameList.receiveGameList(dataObj);
}
//….CODE OMITTED….
}
}步骤4:填充游戏列表
游戏客户端执行预期任务而填充列表:
//CODE EXAMPLE 4: Populate Game List
private function receiveGameList(gameList:Object):void{
//The game list is returned from server as array
var gameList:Array = dataObj.gameList;
//Create a new data provider to store the list
var dp:DataProvider = new DataProvider();
//Iterate through the list to add new items to data provider
for(var i:int = 0; i < gameList.length; i++){
var gameRecord:Object = gameList[i];
//Add a label property to object so it shows up in list cell
gameRecord.label = gameRecord.ID_GAME;
//Add item to data provider
dp.addItem(gameRecord);
}
//Set our UI list’s data provider
list.dataProvider = dp;
}而以下便是我们的结果:

gameListLayoutBasic2(from indieflashblog)
高级游戏列表查询
尽管具有功能性,但是上述所创造的基本游戏列表缺少了稳定用户体验所需要的关键信息。玩家需要知道游戏的创造时间,上一个回合是什么时候,是谁的回合,最重要的是游戏 将加载哪个对手。
整合游戏和游戏结果列表
最理想的查询需要使用最少的资源和带宽将所有相关信息传回客户端。由Reflection Software的程序员Marco Rousonelos所设计的这一查询结合MySQL能够帮助各大论坛使用排名 和派生表去生成预期结果集:
#CODE EXAMPLE 5: ADVANCED GAME LIST QUERY
SELECT IF(whoseTurn = 2 and status != 2, 1, 0) as myTurn, ID_GAME, ID_GAMETYPE, version, timeLastTurn, timeCreated, timeRecorded, status, isAsync, whoseTurn,
MAX(CASE WHEN PN = 1 THEN ID_MEMBER ELSE NULL END) AS ‘P1ID’,
MAX(CASE WHEN PN = 1 THEN memberName ELSE NULL END) AS ‘P1N’,
MAX(CASE WHEN PN = 1 THEN result ELSE NULL END) AS ‘P1R’,
MAX(CASE WHEN PN = 2 THEN ID_MEMBER ELSE NULL END) AS ‘P2ID’,
MAX(CASE WHEN PN = 2 THEN memberName ELSE NULL END) AS ‘P2N’,
MAX(CASE WHEN PN = 2 THEN result ELSE NULL END) AS ‘P2R’
FROM
(SELECT g.ID_GAME, g.ID_GAMETYPE, g.version, timeLastTurn, timeCreated, timeRecorded, status, isAsync, whoseTurn, r.ID_MEMBER, r.result,
( CASE g.ID_GAME
WHEN @curGame
THEN @curRow := @curRow + 1
ELSE @curRow := 1 AND @curGame := g.ID_GAME END
) AS PN
FROM hm_games g
JOIN hm_gameresults r USING(ID_GAME)
JOIN hm_gameresults pg ON g.ID_GAME = pg.ID_GAME AND pg.ID_MEMBER =2
,(SELECT @curRow := 0, @curGame := -1) n
) data
JOIN smf_members m USING(ID_MEMBER)
GROUP BY ID_GAME基于这一查询我们能够生成如下结果集:
ID_GAME 1010
P1ID 1
P1N Ross
P1R 0
P2ID 2
PD2 Kelly
P2R 0
Status 1
whose Turn 1
……
添加额外的玩家
对于支持两个以上玩家的游戏,我们需要在查询中添加如下附加内容:
#CODE EXAMPLE 6: Additional Player Support
MAX(CASE WHEN PN = 3 THEN ID_MEMBER ELSE NULL END) AS ‘P3ID’,
MAX(CASE WHEN PN = 3 THEN memberName ELSE NULL END) AS ‘P3N’,
MAX(CASE WHEN PN = 3 THEN result ELSE NULL END) AS ‘P3R’,
MAX(CASE WHEN PN = 4 THEN ID_MEMBER ELSE NULL END) AS ‘P4ID’,
MAX(CASE WHEN PN = 4 THEN memberName ELSE NULL END) AS ‘P4N’,
MAX(CASE WHEN PN = 4 THEN result ELSE NULL END) AS ‘P4R’结果排序
我们应该按照如下顺序设置结果集:
1.游戏状态(首先呈现出进行中的游戏)
2.回合(首先呈现出玩家所处回合)
3.最新更新(首先呈现出游戏的最新更新)
我们可以通过添加一些排序次序而做到这一点,即对于查询的声明:
#CODE EXAMPLE 7: Order Statement
Order by status asc, myTurn desc, timeRecorded desc限制结果
我们必须清楚这一查询结果将为所有发出请求的会员账号发送所有游戏记录结果。但是随着游戏变得更加受欢迎,即越来越多玩家开始进入游戏,我们将面对越来越庞大的数据集 。所以为了确保服务器,网络和用户设备不会负荷过大,我们必须包含限制声明,如此用户便只能接收到特定的结果:
#CODE EXAMPLE 8: Limit Statement
Limit 0, 30定制查询
我们可以根据不同个性化的游戏调整并定制查询,同时也能够通过包装服务器请求中的额外参数对此进行控制。例如你可以储存一个“lowerLimit”属性和一个“limitSpan”属性 去控制查询的限制。
基于稳定的查询,即能够传送必要的结果集,我们将准备生成更有效的用户体验而呈现出结果。
设计异步多人UI
玩家的游戏列表是异步多人游戏体验的核心。该列表是用于导航,检查状态,并反映游戏的发展。除此之外,游戏列表也是一个非常棒的排行榜/记录工具,能够用于回顾过去的战 斗,敌人等等内容!

gameListUI(from indieflashblog)
相关游戏记录信息
一个优秀的游戏列表是始于一个优秀的游戏记录。每一个游戏记录都应该包含如下信息:
最后一个回合或者完成游戏所需要的时间
游戏创造的时间
状态(不管是轮到玩家攻击,等待回合,防御,或获得胜利)
参与其中的玩家名字
这些属性能够有效地帮助玩家选择想要加载的游戏。除此之外我们也可以添加更多细节以及其它可能性:
独特的游戏记录ID
是否进行排名
地图的名称
游戏对象
最理想的情况便是设计能够匹配列表大小的游戏记录,从而让它们能够更有效地呈现在任何规格的手机设备上。《英雄法师》便是利用玩家形象去呈现角色肖像:

character portraits(from indieflashblog)
填充列表
在设计好游戏的记录单元格布局后,我们可以将游戏记录类别添加到列表组件中,从而确保玩家可以使用该内容去访问游戏过程。往列表中添加记录的过程与在UI列表上添加内容 一样,只不过这是在添加一些简单的单元格,而我们所添加的则是自己定制设计的单元格。
《英雄法师》使用了AURA多屏幕组件UI。AURA代表面线ActionScript 3.0动画,实用工具和资源。这是我所编写的一个类别和组件库,即用于提升像监听器与资源管理和UI设计等 任务的速度。在屏幕截图下方的列表是符合屏幕规格以及用户设备输入控制的高级组件。举个例子来说吧,如果你正在一个触屏输入手机设备上玩游戏,我们便可以通过滑动去操 作该列表。而如果面对的是台式机,你则需要使用标准滚动条进行导航。我们也可以面向手机GPU去优化该列表,并在像第一代iPad等设备商基于60帧/秒去渲染单元格。
游戏加载
游戏列表的主要功能是让玩家能够通过在列表上选择一个项目去加载之前保存的游戏环节。与游戏保存的过程类似,游戏加载也要求客户端和服务器代码去创造游戏加载请求,并 从数据库中检索游戏状态,并启动游戏引擎去恢复玩家想要玩的游戏内容。这一次我们也需要遵循4个步骤:
1.游戏客户端:向服务器请求数据
2.服务器:处理请求,向客户端发送回应
3.游戏客户端:收到服务器回应
4.游戏客户端:基于数据执行预期任务
注:尽管我们能在游戏列表查询中收集游戏状态数据,但是我仍建议使用另一个服务器请求去获得新游戏记录,如此才能有效节省带宽。
步骤1:请求游戏加载
游戏列表中的每个单元格将使用如下代码向服务器发送请求:
//CODE EXAMPLE 9: Client Side Load Game Request
//In the Game List constructor, add an event listener to our list for when a cell is clicked
public function HM_GameList(){
//…CODE OMITTED
list.addEventListener(ListEvent.ITEM_CLICK, gameSelected, false, 0, true);
}
//The game selected function handles our server request
private function gameSelected(evt:Event):void{
//First ensure a valid cell is selected
if(list.selectedIndex != -1){
/*
It’s possible that older games may not be compatible with newer versions of the engine.
So, It’s a good idea to store the required game version in the game record data.
You can write an compatability check function to ensure the version is compatible.
*/
if(HM_App.isCompatibleVersion(list.selectedItem.v) == false){
HM_Main.HMMain.showPrompt(“HM_MessagePrompt”, “Version Mismatch”, “Your game version ‘”+ HM_App.appVersion +”‘ is not compatible with this recorded
game’s version ‘” + list.selectedItem.v+”‘.”);
return;
}
//Once again, create a new params object to store request parameters
var params = new Object();
params.gId = list.selectedItem.ID_GAME;
//Show our request prompt to the user
showPrompt(“ProcessingRequestPrompt”);
//And send the message to server
smartFox.sendXtMessage(“HMServer”, “Load Game”, params, “xml”);
}
}步骤2:处理游戏请求
在服务器这端,我们将为“游戏加载”请求添加一个状态。可能有人会问,既然《英雄法师》也能够使用Smart Fox进行同步游戏,为什么我们不在玩家同时在线时将异步游戏变成 在线实时对抗呢?游戏空间将在服务器上循环访问空间列表,并检查是否有任何空间的游戏ID符合玩家想要异步加载的记录。如果能够找到合适的对抗,那么服务器将传回空间ID 让它能够与在线玩家进行连接。而对于实时对抗,玩家可以直接从游戏空间中加载游戏数据。但是如果找不到实时对抗,那么服务器便只能从数据库加载游戏状态。
注:为了实现这一循环,我们必须在创造一个实时游戏空间时将游戏记录ID当成空间变量储存起来。但是如果你只对异步游戏玩法感兴趣的话,你便无需这么做。
//CODE EXAMPLE 10: Server Side Handle Load Game Request
function handleRequest(cmd, params, user, fromRoom, protocol){
if(protocol == “xml”){
//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….
else if(cmd == “Load Game”){
//First, we package our response to include the game record id and a room id
response.gId = params.gId;
response.rId = -1;
//HERE WE WANT TO GO THROUGH LIST OF ACTIVE GAMES AND SEE IF ANY MATCH TARGET GAME ID, IF SO, JOIN THAT ROOM, OTHERWISE, FIRE UP GAME
var rooms = _server.getCurrentZone().getRooms();
for(var i = 0; i < rooms.length; i++){
var room = rooms[i];
if(room.getName().indexOf(“#”+params.gId) != -1 || (room.getVariable(“gId”) != null && room.getVariable(“gId”).getValue() == params.gId)){
response.rId = room.getId();
break;
}
}
//A live room matching the game id was not found, so we need to load the game
if(response.rId == -1){
var gameRecord = loadGame(params.gId, response);
response.cL = gameRecord.cL;
var memberId = user.getVariable(“hmId”).getValue();
sql = “SELECT lastCmd from hm_gameResults WHERE ID_MEMBER = “+memberId+” and ID_GAME =”+params.gId;
queryRes = dbase.executeQuery(sql);
dataRow = queryRes.get(0);
response.lC = dataRow.getItem(“lastCmd”);
}
}
//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….
}
}游戏加载功能将为游戏加载检索必要的信息。
//CODE EXAMPLE 11: Server Side Load Game Function
function loadGame(ID_GAME, response){
//Generate a MySQL statement to load the record for the provided game record id
sql = “SELECT cmdLog, timeCreated, timeRecorded, timeLastTurn, status from hm_games WHERE ID_GAME = ” + ID_GAME;
queryRes = dbase.executeQuery(sql);
//If the query was unsuccessful, add an error message to the prompt to inform user
if(queryRes == null || queryRes.size() <= 0){
response.error = “Unable to load game”;
return “”;
}
else{
//Hero Mages can be played synchronously and asynchronously
//Whenever an unfinished game is loaded as an async game, we change isAsync property to reflect
if(queryRes.get(0).getItem(“status”) != 2){
dbase.executeCommand(“Update hm_games set isAsync = 1 WHERE ID_GAME = ” + ID_GAME);
}
//Package our response with the cmdLog, which is where we store game state in PART 2
var gameRecord = {};
gameRecord.cL =queryRes.get(0).getItem(“cmdLog”)
return gameRecord;
}
}步骤3:接收游戏加载回应
回到客户端,我们的Smart Fox服务器扩展响应监听器需要一个新的状态去接收来自服务器的“游戏加载”回应。基于回应类型,我们可以选择加入现有的实时游戏或使用收到的参 数创造一款新游戏:
//CODE EXAMPLE 12: Client Side Load Game Response
private function onExtensionResponse(evt:SFSEvent):void{
//….CODE OMITTED….
//ADD CONDITION FOR SERVER RESPONSE LOAD GAME
else if(cmd == “Load Game”){
//Hide the waiting for response prompt
hidePrompt();//Check to see if server provided a room id
if(dataObj.rId == -1){
//Room id not provided, so we load the game state directly from response object
loadGame(dataObj);
}
else{
/*
The server provided a room id. This means there is a
live game for this session already created by another player
so all we have to do is join the game
*/
joinRoom(dataObj.rId, “”, false);
}
}
//….CODE OMITTED….
}步骤4:游戏状态加载
基于不同游戏引擎,游戏功能的加载也会不同,不过游戏功能都需要处理以下一些任务:
1.将接收到的游戏状态数据串转化回对象中
2.设置两个不同的标记“isRunningCommandList”=正确以及“useAnimations”=错误
3.在引擎中运行命令列表从而在幕后有效地“播放游戏”。你的引擎代码应该检查isRunningCommandList标记以确保当命令的自动回应(游戏邦注:如反攻行动)已包含于命令列 表时,我们不会再次启动它。
重放动画
遵循上述步骤你将能从游戏列表中加载任何游戏,并恢复与最后一次移动记录相符合的游戏状态。然而我们也需要考虑到异步游戏组件也会在其他玩家离开时改变游戏状态。而简 单地加载当前的游戏状态会让玩家感到困惑,因为他们并不知道自己的组件正在执行怎样的命令。为了创造有效的异步多人游戏体验,我们便需要对命令记录过程和游戏加载代码 做出一定的修改。
记录最后一次移动
为了分别为每个玩家记录最后一次移动,我们需要在hm_gameresults(是在第二部分文章所创造的名为“lastCmd”的列表)上添加额外的属性。这一属性是一个整数值,即用于储 存玩家最后一次移动的相关索引。
当我们需要发送新的游戏命令时,只要将命令记录索引沿着命令进行传达便可。随后,我们将在代码块中(也就是用于处理游戏状态更新)添加如下代码:
//CODE EXAMPLE 13: Storing lastCmd
function handleRequest(cmd, params, user, fromRoom, protocol){
if(protocol == “str”){
//GENERATE LIST OF RECIPIENTS THE SERVER WILL SEND THIS COMMAND TO
//….CODE OMITTED….
//params[2] stores game record id
//If this game record id is included, we need to write this command to stored game log
if(params[2] != undefined){
if(params[1].indexOf(“cT=eT”) != -1){//If this is an end turn command
//Convert notated command into object
var cmdObj = stringToObject(params[1]+””, “|”, “=”);
//Get the id of player whose turn is next
var nextTurnId = cmdObj.nId;
//Write update to game record in database
sql = “UPDATE hm_games set cmdLog = CONCAT(cmdLog, ‘
” + Math.floor(getTimer() / 1000) +”, whoseTurn = “+nextTurnId+” WHERE ID_GAME = ” + params[2];
}
else{
//Write update to game record in database
sql = “UPDATE hm_games set cmdLog = CONCAT(cmdLog, ‘
+ params[2];
}
success = dbase.executeCommand(sql);
if(success == false){
//THE DATABASE DID NOT RECORD THE MOVE CORRECTLY
//CREATE A NEW RESPONSE TO NOTIFY GAME CLIENT OF THE ERROR
}
//***NEW CODE BEGIN***
//Get list of all users in this room and update the lastCmd property in game results for everyone who witnessed this move live
var lastCmd = cmdObj.lC; //Store the lastCmd to record last witnessed move in live players’ gameresult records
var allUsers = room.getAllUsers();
for(i = 0; i < allUsers.length; i++){
var memberId = allUsers[i].getVariable(“hmId”).getValue();
sql = “UPDATE hm_gameResults set lastCmd =”+lastCmd+” WHERE ID_MEMBER = “+memberId+” and ID_GAME =”+gId;
trace(“GAME RECORD UPDATE: ” + sql);
dbase.executeCommand(sql);
}
//***NEW CODE END***
}
_server.sendResponse([params[1]], -1, null, recipients, “str”);
return;
}为那些看不见的移动呈现动画
我们将在服务器上的游戏加载处理程序上添加一个额外内容去储存玩家的lastCmd,如下:
//CODE EXAMPLE 14: Getting lastCmd
var memberId = user.getVariable(“hmId”).getValue();
sql = “SELECT lastCmd from hm_gameResults WHERE ID_MEMBER = “+memberId+” and ID_GAME =”+params.gId;
queryRes = dbase.executeQuery(sql);
dataRow = queryRes.get(0);
response.lC = dataRow.getItem(“lastCmd”);以下便是完整的游戏加载回应:
//CODE EXAMPLE 15: Revised Game Load Handler
function loadGame(ID_GAME, response){
//Generate a MySQL statement to load the record for the provided game record id
sql = “SELECT cmdLog, timeCreated, timeRecorded, timeLastTurn, status from hm_games WHERE ID_GAME = ” + ID_GAME;
queryRes = dbase.executeQuery(sql);
//If the query was unsuccessful, add an error message to the prompt to inform user
if(queryRes == null || queryRes.size() <= 0){
response.error = “Unable to load game”;
return “”;
}
else{
//Hero Mages can be played synchronously and asynchronously
//Whenever an unfinished game is loaded as an async game, we change isAsync property to reflect
if(queryRes.get(0).getItem(“status”) != 2){
dbase.executeCommand(“Update hm_games set isAsync = 1 WHERE ID_GAME = ” + ID_GAME);
}
//Package our response with the cmdLog, which is where we store game state in PART 2
var gameRecord = {};
gameRecord.cL =queryRes.get(0).getItem(“cmdLog”)
//***NEW CODE BEGIN***
//CODE EXAMPLE 14: Getting lastCmd
var memberId = user.getVariable(“hmId”).getValue();
sql = “SELECT lastCmd from hm_gameResults WHERE ID_MEMBER = “+memberId+” and ID_GAME =”+params.gId;
queryRes = dbase.executeQuery(sql);
dataRow = queryRes.get(0);
response.lC = dataRow.getItem(“lastCmd”);
//***NEW CODE END***
return gameRecord;
}
}在储存了最后的命令索引后,我们可以通过再次激活useAnimations标记而有效地播放适当的动画。
其它注意事项
除了我所解释的这些步骤,我们还可以通过许多方法去定制异步体验并添加额外的功能。举个例子来说吧,如果玩家能够拥有无限的世界去回应一款异步游戏的话会怎样?一个缺 乏运动精神的玩家会无限期延缓他的回合,并阻止获胜玩家宣布胜利。而解决这一问题的一大方法便是设置“最大等待”期限,让玩家可以与长时间未回到游戏中的对手解除关系 。就像在《英雄法师》中,我便规定如果玩家在3天内未回到之前的游戏回合中,那么对手玩家就可以选择与之解除关系。
使用过滤器只呈现出活跃的游戏,基于特定对手寻找游戏,以及查阅对手的状态等也是非常有帮助的功能。
总结
本篇文章主要解释了异步多人游戏用户界面的创建,已储存的游戏环节的加载,以及对手最后一次移动的动画重放。而在接下来的文章中我们将专注于异步多人游戏匹配系统的相 关理念,从而让玩家可以无需在线连接而开始玩一款新游戏或加入现有的游戏中。
多人游戏成功的关键在于拥有大量一起玩游戏的用户群。但如果一开始你并没有什么用户基础,又该如何集结大量玩家呢?移除“必须在线玩游戏”这个条件。异步匹配功能有助 于促进玩家参与多人游戏,即使他们并不在线!随机匹配方式则可让玩家遇到新人并结识新友,邀请系统则有助于玩家发展这些友情,并创造传播游戏的热情。本文将说明如何使 用游戏服务器和数据库来管理创造和加入多人游戏的过程。
本文将说明:
1.如何创造一个直观的多人模式界面
2.如何设置随机玩家的异步多人模式匹配
3.如何设计异步游戏邀请系统
4.如何设计好友管理系统,以便玩家邀请对方参与游戏(即使对方并不在线)
要求
*必备知识
开发回合制游戏的经验
熟悉ActionScript 3.0
*必需产品
Flash Professional
Smart Fox Server Pro
MySQL Database
*用户级别
高级
创造直观的多人体验
用户体验是创造异步多人游戏最重要的部分。无论你的游戏有多棒,如果它不能立即清晰地让玩家知道如何设置游戏,那就一定会流失用户。
在《Hero Mages》最初的即时多人模式部署中,用户可以登录和立即进入聊天大厅,并同其他玩家交流、创造游戏,访问系统中的其他模块(例如积分排行榜和地图编辑器)。该 系统模拟了类似《暗黑破坏神》、《星际争霸》等游戏的原版Battle.net的多人门户设计。硬核游戏玩家通常很熟悉这种设置,但许多休闲玩家却会对此不知所措。
学习案例
确定游戏界面如何运行,以及设计有效而直观的布局确实是个大挑战。最有效的方法之一就是探索其他游戏如何处理这个问题,然后在原先的部署基础上再进行优化和提升。
《Hero Academy》是一款拥有直观用户界面的成功异步多人游戏。多人模式会直接启动玩家的游戏列表,以便他们开始新的配对或者继续玩他们已经开始的游戏。为了联系其他玩 家,你的游戏屏幕上要有三个选项:一个是通过搜索邀请玩家,一个是找到随机对手,一个是通过Facebook邀请好友。《Hero Academy》的社交媒体绑定是一个更便于邀请好友的 重要功能。最后,《Hero Academy》的随机匹配屏幕也允许玩家去设置他们的团队并找到对手。注意要妥善设置“购买”按钮,以便何促销IAP内容。
注:本文并非旨在探讨如何绑定Facebook功能,但我会说明你设计自己的游戏内好友列表(针对那些并不使用Facebook的玩家)的过程。

《Hero Academy》的多人模式始于游戏列表(from indieflashblog)
《Hero Mages》功能的多人模式界面具有一个直观的通道屏幕,允许玩家访问该应用所有可用的功能,其中包括创造新游戏、加载和继续玩当前游戏,查看积分排行榜,设计定制 地图等功能。这其中还包含经典的在线聊天大厅界面,玩家可以从右上方的图标进入该界面。
《Hero Mages》支持即时多人和异步多人模式,它与设计一个支持玩家不同选项的新游戏创造屏幕密切相关。我在《Hero Academy》中添加了直接从英雄选择画购买扩展内容的功 能。但这里存在一些微妙的不同之处。“购买”标签并不出现于各个角色之上,而是出现一个将你引 向具有不同产品的新扩展画面的“购买”按钮。这种设置的首要原因是角色需 要成套购买。第二个原因是将英雄直接“锁定”在列表中,许多原来的玩家就会觉得自 己购买应用时没有“获得完整的游戏”。这种分离法能够更好地将新角色区分出来,让玩家 看到新角色是扩展内容(而不是核心套件)。

《Hero Mages》中的多人模式门关(from indieflashblog)
最后,《Hero Mages》的邀请系统与《Hero Academy》不同,它需要独立的画面。玩家可以邀请多达7名的好友加入游戏。《Hero Academy》“搜索好友”功能的一个弱点在于,如 果你的好友不在Facebook上,那你每次想邀请他们玩游戏时都需要输入对方的用户名。而《Hero Mages》则在你邀请好友加入游戏时创建了游戏内好友列表,从而改进了这一系统 。我稍后会说明这个系统的运行方式。

HeroMages_Invite2(from indieflashblog)
(在邀请好友时,你可以从一个下拉菜单中选择好友,该菜单还提供了添加还不是好友的玩家选项)
如何加入游戏?
注意以上所示的两个界面风格都不包包含“加入游戏”按钮。随着游戏创造的推进,人们该如何联系上对方?答案就是由屏幕背后的服务器来处理人们加入游戏的问题。当玩家“ 创造”一个配对时,他们实际上是向服务器发送了与一名玩家联系的请求。服务器会根据其他玩家是否在等待的情况,将这一请求存储为新游戏记录或者以已在排队的现成游戏记 录连接他们的游戏参数选择。系统不应令玩家为加入游戏的任务而操心,因为这会增加游戏体验的复杂性。除非你的游戏还支持实时多人模式,并且你也允许用户在游戏中途过程 中加入。《Hero Mages》的玩家可以通过主聊天大厅,点击右侧列表的即时游戏即可加入。
值得注意的是,《Hero Mages》还会在轮到玩家的回合时,将异步玩家引进即时游戏回合中。如果此时玩家正在异步情况下通过游戏列表玩对手载入的游戏,那么他们就会自动加 入即时游戏回合中。
总结有效的多人界面特点
正如上图所示,有效的多人游戏界面包含以下关键元素:
1.访问多人模式功能的门关屏幕
2.创造新游戏的明显而直观的方法
3.邀请好友的有效系统
设置一个随机的1v1配对
正如上文所述,有效的异步多人界面可以通过配对消除加入戏的问题。创造两名玩家之间随机的1v1匹配模式的最基本方法就是在首次发出配对请求时创造一个新游戏纪录。之后 ,当玩家再次请求配对时,就以先存的会话将他们连接起来。
当玩家开始一个随机对手配对时,会有以下两者中的一种情况:
1.现成的配对不可行,所以服务器要创造一个新配对,并将其存储在数据库中。用户会进入一个“寻找玩家”的屏幕。他们可能会继续等待一位对手加入,或者取消等待。即使他 们取消了等待,其请求仍然会保存在服务器中。这样,当有对手加入时,系统就会向其发送通知,他们就可以准备玩游戏了。
2.现成的配对是可行的,所以服务器完成了一个带有用户名参数的待定配对,并向他们发送游戏记录。服务器应分配“加入的”玩家开始第一回合。这样他们在游戏加载完成时就 可以立即开始玩游戏。

HeroMages_FindingPlayer(from indieflashblog)
(当玩家进入这一界面时,系统就创造了一个新的游戏记录。玩家可以等待与一名对手建立联系或者选择取消!)

HeroMages_SearchingForOpponentPrompt(from indieflashblog)
(该系统会在数据库中建立配对请求,并在找到一名对手时通知玩家,这样他们就无需继续等待。)

HeroMages_SearchingForOpponent(from indieflashblog)
(新记录出现在玩家游戏列表中,代表该系统正在搜索一名对手。)
如何运行
本文并不讨论《Hero Mages》编程随机配对模式的代码执行方法,但会完整地概括该流程的要点。使用本文所列要点、编写客户端的技术,以及前两篇文章中所提到的服务器端代 码,将有助于你获得编写任何异步多人游戏的切入点。
1.当用户选择“随机匹配”按钮时,就会触发“sendMatchMakeCommand”,它会在客户端指导游戏创造类去创建一个新的游戏数据结构,并向服务器发送一个请求。
2.在服务器端,查看是否还有尚未配对的游戏正在排队等待。
A 如果当前没有尚未配对的游戏,在服务器创造一个拥有-1状态的新游戏记录,显示游戏正在等待一个对手。向客户端发回一个请求,显示未发现配对。
B 如果当前存在未配对游戏,载入该游戏记录的数据,并在回复中将其发回客户端。
3.在客户端
A 如果没找到配对,显示“寻找玩家”屏幕。如果玩家取消了,显示提示“配对就绪时我们会通知您”。
B 如果找到配对了,创造一个新游戏数据,整合玩家游戏参数(例如英雄选择)与配对玩家的当前游戏设置数据,并将响应发回到服务器。
4.在服务器端
A 为配对玩家更新具有新记录的游戏成绩表
B 更新具有新游戏设置数据的游戏记录
C 返回响应命令以载入游戏(就像第3篇文章中的载入游戏方式,只是这里会有一个额外的已包含配对游戏的标识)
5.在客户端
A 载入游戏数据并开始游戏。配对玩家可以立即开始玩游戏。
B 向服务器发送响应提示游戏已经配对
6.在服务器端,查看原来的配对创造者是否在线。如果在线,向其发送应用内部通知,告知配对已就绪。如果不在线,就发送推送通知或电子邮件。如果玩家仍然处于“寻找玩家 ”屏幕,我们就可以将他们与对手连接到即时游戏会话中。
随机异步游戏很适合1发配对,但如果你想同2人以上的玩家一起游戏又该如何呢?也许你可以设置一个更大的随机游戏,但这却存在可能对异步游戏产生危害的问题。例如,假如 你玩随机的PvP游戏,而你的队友却打算退出游戏了该怎么办?此外还要考虑到越多玩家参与一款游戏,轮到每位玩家的时间就越长。如果是随机玩家,大家就无法联系对方,告诉 他“嘿,轮到你了,让我们完成这个游戏吧!”这正是创造与好友绑定的游戏邀请系统的好处。这样你就知道和你一起玩的人可以看到游戏设置,以且能够一起完成游戏会话。
设计异步游戏邀请系统
1.游戏组织者启动一个新游戏创造屏幕,并且能够分配所有的游戏选项。这包括选择地图、游戏目标、组队以及增加电脑对手。游戏屏幕应该包括针对每位玩家(人类或者电脑) 的可增加单元。
2.游戏主持人可以使用玩家选择下拉菜单并选择“邀请好友”来邀请玩家。这里的邀请仅局限于玩家好友,因为玩家不可以向那些无意玩多人游戏的随机玩家发送干扰性的邀请。
3.当所有的选项都设置和分配好时,游戏组织者可以点击“发送邀请”按钮。这可以向服务器发送包含游戏设置数据,以及所有参与玩家的ID这一信息。
4.在服务器端,使用本系列第2篇中介绍的相同功能创造一个新游戏记录,但这里有两个不同之处:
A 游戏状态存储为-2以指示游戏邀请接受情况仍然待定
B hm_gameresults表格应该进行调整以便纳入存储玩家邀请接受状态的新参数以及定制游戏参数。
5.当游戏数据中的服务器创造好游戏记录时,它就会像其他游戏一样出现在邀请玩家的游戏列表。但当玩家选择它时,它不会启动游戏,而是载入游戏当前参数返回游戏创造屏幕 。此时玩家可以选择他们的英雄,并选择接受或拒绝邀请。
6.如果玩家拒绝邀请,游戏状态就会设置为-3,或者邀请被拒绝。游戏就会向所有被邀请玩家显示为完成状态,并且不再向其开放。
7.如果玩家接受邀请,系统就会呼叫服务器将该名玩家的接受状态设置为true。
8.在客户端,游戏记录界面应该破译被接受的响应,并在游戏记录绘制恰当的标记。例如,将那些已被邀请但并未回应的玩家显示为信封图标。而那些接受邀请者则显示为绿色的 打勾符号,拒绝进显示为红色的打叉符号。
9.当最后一名玩家接受游戏邀请时,该服务器就会分配游戏数据以便合并所有的新玩家参数。如果接受邀请的玩家积极应战,它就会发送一个启动游戏的信号,让他们各自开始游 戏。否则,它就会发送通知,指出哪位玩家先开局。
游戏成绩表的附件
回忆本系列第2篇中提到的hm_gamersulfts表格。这个表格用于存储与玩家相关的数据,其中包括玩家输赢结果,以及他们见证的最后一个游戏命令指数(用于查看游戏动画重播) 。对于邀请系统来说,我们需要额外的数值来追踪邀请响应,以及任何用于定制玩家团队或角色选择的参数。

table 1(from indieflashblog)
记住,游戏邀请只是简单地创造一个会出现在玩家游戏列表的新游戏记录。唯一的区别在于除非所有玩家都接受邀请,该游戏状态为-2,或者所有玩家的接受情况仍然待定。我们 不想在游戏数据本身中存储玩家准备状态,因为两名玩家在同一时间接受邀请,就会覆盖另一者的接受状态。使用每位玩家各自的游戏成绩记录,就可以让每位玩家修改自己的状 态。只有当最后一名玩家接受邀请时,我们才能重写游戏数据以纳入该玩家的所有游戏设置信息(类似于以上部分中提到的合并配对的随机玩家)。
更深的设计考虑
游戏邀请发送出去后,如果玩家不回应怎么办?
游戏邀请的响应方式与游戏记录一样。它们会与玩家正在玩的游戏或已经完成的游戏一样出现在游戏列表。其区别就在于,游戏邀请的状态标识是-2,在游戏列表中的标注是“邀 请待定”。

HeroMages_PendingInvite(from indieflashblog)
(邀请待定的游戏记录使用符号来指示哪位玩家已经接受邀请)
注意这些图标是用于代表游戏邀请状态。那些已被邀请但并未回应的玩家显示为信封图标。而那些接受邀请者则显示为绿色的打勾符号,拒绝进显示为红色的打叉符号。
要注意保持游戏列表的整洁,以确保玩家返回应用时很看到清晰的“任务”集合。
之前文章的游戏列表分类算法运行方法如下:
1.玩家需要回应邀请的游戏处于最高优先权,因为游戏只有在所有玩家都接受的状态下才能开始。它们要在游戏列表中居首。
2.轮到玩家出击的游戏处于第二优先权,因为如果玩家不完成自己的回合,这些游戏就无法取得进展。这些记录要按照日期第二分类条件显示在列表中。
3.排在第三位的是正在进行中的游戏或者等待其他玩家接受邀请的游戏,它们同样依照记录中最后一次活动的时期/时间进行分类。
4.最后是已经完成的游戏和拒绝的邀请,同样以时间分类。
那些尚未得到响应的邀请仍会显示在玩家的游戏列表中,等待其他玩家的操作(类似于游戏等待对手的回合)。玩家可以选择“放弃”3天后仍未执行自己这一回合的对手。在游戏 开始之前的任何时间,任何玩家都可以更改对游戏邀请的接受状态,例如将接受改为拒绝。这样会让游戏显示“邀请被拒绝”的状态。游戏就会显示为完成,并且不会再影响玩家 状态。
当游戏准备开始时,谁先下手?
基于邀请的游戏参与玩家可能多达8人。这里不建议由最后进来的玩家先开始,而应该遵循最初游戏设置所计算出来找随机回合顺序。系统应该在游戏开始时向首个玩家发送执行操 作的通知。当游戏开始时,游戏状态就会更改为“游戏正在进行中”,玩家就会看到游戏列表中的游戏记录更新。
什么机制可以阻止玩家发送不受欢迎的邀请?
玩家可能使用积分排行榜作为查看最高排名的玩家是谁,并向对方提交挑战请求,以期赢得更高的点数。但这有可能让热门玩家受到干扰,《Hero Mages》就要求玩家只能邀请好 友,而不是任何玩家。这有助于杜绝不请自来的游戏邀请,保持游戏列表的整洁性。
与此同时,系统也不能限制玩家在游戏中发展友谊。为此,《Hero Mages》允许你通过游戏邀请来请求新好友。当添加一名新好友时,玩家就可以向其发送游戏邀请。如果该玩家 接受游戏邀请,他们就会自动成为游戏组织者的好友。如果他们拒绝游戏邀请,这种友情状态仍会处于待定状态,直到他们接受或者取消邀请为者。我们将在下一个环节中介绍创 造游戏内好友系统的过程。
创造游戏内好友系统
像Facebook这类社交网站是将玩家与现成的好友连接起来的优秀解决方法,但仅仅依赖这些网站却可能疏离那些并不使用社交媒体或者不喜欢让你的游戏获取其社交媒体帐号的用 户。
推荐使用游戏内好友系统的原因如下:
1.你可以完全控制如何建立游戏内的友谊关系,它具有更高的灵活性,更便于在游戏内部社区中进行病毒扩散。
2.玩家的友谊独立于社交网络,所以无论你的用户是否在Facebook或其他社交网站上,你都可以创建玩家社区。
3.但仍然可以增加绑定社交媒体的选项,以便强化你的游戏社区。
这一部分描述的是设计游戏内好友系统,以及将其同游戏界面和邀请系统绑定,以便促进游戏玩家社区发展的过程。
好友表格
创造好友系统的第一步是创建一个新的数据库表格。以下就是《Hero Mages》的标准惯例,我使用的标识符是 “hm_friends”。该表格应该包括以下属性:

table 2(from indieflashblog)
客户端好友列表UI
为了创造新的好友关系,客户端将需要一些额外的用户界面组件设置,以便向服务器发送一个新好友请求。一个重要的问题就是:“玩家如何在游戏中设置友情?”你的好友系统 越直观,玩家就越有可能在游戏中建立友情,强化游戏的玩家社区和长期可行性。
《Hero Mages》中的玩家可以通过3种方式建立友情:
1.打开另一玩家的资料,选择“添加好友”按钮。玩玩可以通过在线聊天室获取对方资料、积分排行榜,并在游戏中的玩家列表中选择该玩家名称。
2.使用“好友管理”屏幕(通过多人模式门关进入)并寻找玩家的用户名。
3.当设置新游戏邀请时选择“添加好友”。可通过好友管理选项2执行这一操作,之后返回游戏邀请屏幕,允许玩家快速邀请新好友加入游戏。

还可以在设置定制游戏邀请时添加好友(from indieflashblog)
添加好友
客户端将以既定的合适UI,向服务器发送一个好友请求,其中包含发送请求的成员ID,以及他们想添加的好友ID。
在服务器端会发生以下情况:
1.首先查看是否已存在好友关系。
sql = “SELECT * FROM hm_friends WHERE ID_MEMBER = “+params.hmId+” AND ID_FRIEND = “+params.fId;2.根据查询是否回馈结果,创造一个新的友情记录或更新当前的友情记录状态。
A 如果查询没有结果,说明不存在好友关系,就要创造新记录:
sql = “INSERT into hm_friends (ID_MEMBER, ID_FRIEND, accepted) VALUES (“+params.hmId+”, “+params.fId+”, 1) , (“+params.fId+”, “+params.hmId+”, -1)”
;B 否则就是存在好友关系,更新好友关系的状态:
sql = “UPDATE hm_friends set accepted = 1 WHERE ID_MEMBER = “+params.hmId+” and ID_FRIEND = “+params.fId;3.向客户端发送一个包含玩家更新好友列表的回复。该列表包括名称、ID、虚拟角色,以及玩家好友的友情状态。
值得注意的是,每个好友关系是由hm_friends表格中的两个记录所定义。在各个好友关系中,玩家将具有一个由ID_MEMBER存储他们自己ID的记录,以及另一个在ID_FRIEND中存储 好友ID的记录。这两条记录都很重要,这样玩家就可以在无需创建/删除额外记录的情况下添加和移除好友。
遵循这一做法的原因有三:
1.保持追踪已存在的好友关系,确保玩家无法向不明确的对象发送好友请求。当对方拒绝好友请求时,“接受”属性就会设置为0,这样该用户就不会再出现额外的好友请求。只有 在该玩家添加了最初发送请求者为好友时,两者才能建立好友关系。
2.保持追踪已存在的好友关系,以便程序查看“sentGameInvite”属性。这一属性存储了游戏是否向这名玩家发送了邀请的情况。如果这一数值标识为true,那么游戏就不允许玩家 再向未接受邀请的用户发送干扰性的邀请信息。
3.最重要的是,确认好友关系生效需要这两条记录。可以将该系统想象成双方握手。发送邀请的玩家会自动将其“接受”标记为true,而获得邀请的玩家则将其“接受”标记为-1 (未决定),直到他们决定接受或拒绝为止。
移除好友
以这一设置移除好友很容易:简单地将将玩家的“接受”状态更新为0以移除好友。这要使用到以下陈述:
sql = “UPDATE hm_friends set accepted = 0 WHERE ID_MEMBER = “+params.hmId+” and ID_FRIEND = “+params.fId;获取好友列表
好友系统会集合在一起,通过这种服务器端的MySAL陈述将好友列表返回客户端的玩家。以下是应部署于服务器端代码的 “getFriendsList”函数例子:
//CODE EXAMPLE FRIEND LIST
function getFriendsList(hmId){//PASS THE ID OF THE PLAYER WHOSE FRIEND LIST WE ARE RETURNING
//STEP 1: Generate friends list query results by joining tables with pertinent data together
sql = “SELECT * FROM (r”;
sql += “SELECT s.memberName as friendName, avatar, m.ID_FRIEND as fId, f.accepted as friendAccept, m.accepted as myAccept, m.sentGameInvite as g FROM
hm_friends mr”
sql += “JOIN hm_friends f ON f.ID_MEMBER = m.ID_FRIEND AND m.ID_MEMBER = f.ID_FRIENDr”
sql += “JOIN smf_members s on m.ID_FRIEND = s.ID_MEMBERr”
sql += “WHERE m.ID_MEMBER = “+hmId+” and f.accepted != 0 and m.accepted != 0″;
sql += “) as xr”;
sql += “ORDER BY myAccept, friendAccept, friendName”;
//STEP 2: Populate the results into an array of objects that can be populated into a list component by client
var queryRes = dbase.executeQuery(sql);
var friendsList = [];
for(var i = 0; i < queryRes.size(); i++){
var tempRow = queryRes.get(i)
var row = new Object()
row.n = tempRow.getItem(“friendName”)
row.a = tempRow.getItem(“avatar”);
row.fId = tempRow.getItem(“fId”);
row.g = tempRow.getItem(“g”);
//STEP 3: Set the status of the player based on query results
if(tempRow.getItem(“myAccept”) == -1){
row.s = -2; //This player has requested me as a friend
}
else if(tempRow.getItem(“friendAccept”) == -1){
row.s = -1; //I’ve requested this player as a friend and am waiting response
}
else{
row.s = 1; //We’re friends
}
friendsList.push(row);
}
return friendsList;
}处理客户端的服务器响应
同之前的例子一样,最后一步就是处理客户端的服务器响应。最佳做法是在一个普遍易用的位置存储好友列表数据。这样,数据就可以填充多种视图,例如位于好友管理系统的好 友列表UI,或者游戏邀请系统中的下拉菜单。
总结
本文说明了如何设计一个高效异步多人界面,如何设置随机配对,如何设计游戏邀请系统,以及如何创造简单的好友列表等方法。本系列下篇文章将说明将完整的异步多人体验绑 定在一起的最终必要元素。
