
引言
故事始于基于区块链的黑客马拉松。 在活动开始时,我遇到了一个人,他以业余爱好来制作棋盘商务游戏(我在其中一款游戏的测试中),我们一起合作,找到了一个团队,他们在周末与他们“盲目”了一个简单的战略游戏。 黑客马拉松过去了,但热情依然存在。 我们想到了关于幸福,世界社区和选举的多人纸牌游戏的想法。
在系列文章中,我们将反映我们创建游戏的道路,并描述我们已经踏上的耙子,并将在前进中不断前进。
根据削减将是:
- 游戏总结
- 如何决定如何做后端。 它会在哪里“居住”,以便在开发阶段不支付费用
- 开发的第一步-玩家身份验证和配对组织
- 进一步的计划
游戏是关于什么的
人类对世界大战,资源枯竭和持续竞争感到厌倦。 关键派系同意使用现代技术选拔一个领导层。 在指定的时间,世界选民必须决定选择哪一个分数来统治下一个千年。 关键派系进行了“诚实”的权力斗争。 在游戏过程中,每个玩家代表一个分数。
该纸牌游戏与选举有关。 每个派系都有进行选举的预算,收入来源可以增加预算并开始投票。 在游戏开始时,混合了带有动作卡的套牌,并向每位参与者发行了4张卡。 每回合,玩家最多可以执行两个游戏动作。 要使用该卡,玩家将其放在桌上,并在必要时指定目标并从预算中扣除使用卡的费用。 一轮结束后,玩家只能保留一张未使用的卡。 在每个回合开始时,玩家会从套牌中获取卡牌,因此,在每个回合开始时,每个玩家手中都会有4张动作卡。
在第3、6和9轮结束时,从游戏中删除票数最少的玩家。 如果多个玩家的最小票数相同,那么所有具有此结果的玩家将从游戏中淘汰。 这些球员的声音进入了选民的整体视野。
在第12轮结束时,获胜者是得票最多的人。
为后端选择工具
从游戏描述如下:
- 这是多人游戏
- 有必要以某种方式识别玩家并管理帐户
- 社交元素的存在将有益于游戏-朋友,社区(氏族),聊天,成就(成就)
- 排行榜和婚介功能将是必需的。
- 比赛管理功能在将来会很有用
- 假设游戏是纸牌游戏,您需要管理纸牌目录,您可能需要存储可供玩家使用的纸牌和已编译的套牌
- 将来可能需要游戏内经济,包括游戏内货币,虚拟商品(卡)交换
通过查看需求列表,我立即得出结论,在初期阶段创建自己的后端是没有意义的,并向Google寻求了其他选择。 因此,我发现有专门的云游戏后端,其中PlayFab(由Microsoft购买)和GameSparks(由Amazon购买)脱颖而出。
通常,它们在功能上相似并且满足基本需求。 此外,它们的内部体系结构非常不同,相同任务的解决方式也略有不同,并且特征中的显式对应关系很难追踪。 以下是每个平台的正面和负面特征以及有关选择主题的注意事项。
Playfab
正面特征:
- 来自不同游戏的帐户合并为一个主帐户
- 无需单行代码即可描述游戏经济,包括向单独的虚拟商店定价
- 友好的用户界面
- 微软收购后收购产品
- 由Indie Studio订阅制作的所有权成本为99美元(最高10万MAU),切换到专业版时,一千MAU订阅的成本为8美元(最低帐户为300美元)
缺点:
- 游戏数据的存储受到严格限制,例如,免费订阅以存储特定游戏会话的数据(如果我正确理解一切,则使用实体组进行此操作)3个500字节的插槽可用
- 要组织多人游戏,您需要连接第三方服务器,这些服务器将处理来自客户端的事件并计算游戏逻辑。 这是硬件上的Photon或Azure Thunderhead,并且您不仅需要组织服务器,而且还需要将您的订阅至少升级到Indie Studio
- 有必要忍受这样的事实,即云代码没有自动完成功能,也没有办法闯入模块(或找不到)?
- 没有普通的调试器,您只能在CloudScript中编写日志并查看
游戏公园
正面特征:
- 游戏数据存储。 该平台不仅可以在许多地方保存数据(常规游戏元数据,虚拟商品,玩家个人资料,多人游戏会话等),而且还提供了未附带任何内容的完善的数据库即服务,此外,MongoDB和Redis均可立即用于不同类型的数据。 在开发环境中,您可以存储10 MB,在战斗中可以存储10 GB
- 免费订阅(开发)中提供多人游戏,同时限制为10个同时连接和每秒10个请求
- 方便使用CloudCode,包括用于测试和调试的内置工具(Test Harness)
缺点:
- 自从亚马逊(2018年冬季)购买该工具以来,这种感觉一直停滞不前,没有创新
- 再次,在收购亚马逊之后,关税变得更糟;更早之前,有可能免费使用多达10,000 MAU的生产量
- 拥有的生产成本从$ 300开始(标准订阅)
感言
首先,您必须检查游戏的概念。 为此,我想构建没有现金投资的棍棒和透明胶带的原型,并开始游戏机制的游戏测试。 因此,首先,在选择时,我增加了免费订阅开发和测试机制的机会。
GameSparks满足此条件,但PlayFab不满足,因为您将需要一台服务器来处理游戏客户端的事件和一个独立工作室级别的订阅(99美元)。
同时,我接受亚马逊不开发GameSparks的风险,这意味着它可能“死亡”。 考虑到仍然存在的生产成本,我谨记可能需要迁移到另一个平台或我自己的后端。
开发的第一步
连接和认证
因此,在原型开发阶段,选择权就落在了GameSparks上作为后端。 第一步是学习如何连接到平台并验证播放器。 重要的一点是,安装游戏后,用户应该无需注册和SMS即可立即玩游戏。 为此,GameSparks提供了通过调用DeviceAuthenticationRequest方法来创建匿名配置文件的选项,稍后,您可以在匿名配置文件的基础上,通过连接到您的Google帐户来创建一个完整的配置文件。
考虑到我有大脑的TDD,我首先创建了一个测试以将客户端连接到游戏。 由于将来需要使用JS编写CloudCode,因此我将使用mocha.js和chai.js在JS中进行集成测试。 第一次测试结果如下:
var expect = require("chai").expect; var GameClientModule = require("../src/gameClient"); describe("Integration test", function () { this.timeout(0); it("should connect client to server", async function () { var gameClient = new GameClientModule.GameClient(); expect(gameClient.connected()).is.false; await gameClient.connect(); expect(gameClient.connected()).is.true; }); })
默认情况下,mocha.js中的超时为2秒,由于测试是集成的,我立即使其超时。 在测试中,我创建了一个尚未实现的游戏客户端,检查是否与服务器没有连接,调用命令连接到后端,并验证客户端是否已成功连接。
为了使测试变为绿色,您需要下载GameSparks JS SDK并将其添加到项目中,并连接其依赖项(crypto-js和ws),并且当然要实现GameClientModule:
var GameSparks = require("../gamesparks-javascript-sdk-2018-04-18/gamesparks-functions"); var config = new require("./config.json"); exports.GameClient = function () { var gamesparks = new GameSparks(); this.connected = () => (gamesparks.connected === true); this.connect = function () { return new Promise(function (resolve, reject) { gamesparks.initPreview({ key: config.gameApiKey, secret: config.credentialSecret, credential: config.credential, onInit: () => resolve(), onMessage: onMessage, onError: (error) => reject(error), logger: console.log }); }); } function onMessage(message) { console.log("GAME onMessage: " + JSON.stringify(message)); } }
在游戏客户端的开始实施中,需要从配置中读取技术授权以从客户端应用程序创建连接所需的密钥。 连接方法包装了SDK中的相同字段。 最重要的事情发生在connect方法中,它返回带有成功连接或错误的回调的promise,还将onMessage处理程序绑定到同一回调。 onMessage将充当后端的消息处理管理器,现在让它将消息记录到控制台。
看起来工作已经完成,但是测试仍然是红色的。 事实证明,GameSparks JS SDK不能与node.js一起使用;就它而言,它缺少浏览器上下文。 让他认为该节点是罂粟上的Chrome。 我们转到gamesparks.js,并在开始时添加:
if (typeof module === 'object' && module.exports) {
测试变成绿色,继续进行。
如我之前所写,玩家应该能够在进入游戏后立即开始游戏,而我想开始在活动中积累分析数据。 为此,我们将绑定到设备标识符或随机生成的标识符。 检查这将是这样的测试:
it("should auth two anonymous players", async function () { var gameClient1 = new GameClientModule.GameClient(); expect(gameClient1.playerId).is.undefined; var gameClient2 = new GameClientModule.GameClient(); expect(gameClient2.playerId).is.undefined; await gameClient1.connect(); await gameClient1.authWithCustomId("111"); expect(gameClient1.playerId).is.equals("5b5f5614031f5bc44d59b6a9"); await gameClient2.connect(); await gameClient2.authWithCustomId("222"); expect(gameClient2.playerId).is.equals("5b5f6ddb031f5bc44d59b741"); });
我决定立即检查2个客户端,以确保每个客户端在后端创建自己的配置文件。 为此,游戏客户端需要一种方法,您可以在其中将某个标识符传输到GameSparks的外部,然后检查客户端是否已联系所需的玩家资料。 预先在GameSparks门户上准备的个人资料。
要在GameClient中实施,请添加:
this.playerId = undefined; this.authWithCustomId = function (customId) { return new Promise(resolve => { var requestData = { "deviceId": customId , "deviceOS": "NodeJS" } sendRequest("DeviceAuthenticationRequest", requestData) .then(response => { if (response.userId) { this.playerId = response.userId; resolve(); } else { reject(new Error(response)); } }) .catch(error => { console.error(error); }); }); } function sendRequest(requestType, requestData) { return new Promise(function (resolve) { gamesparks.sendWithData(requestType, requestData, (response) => resolve(response)); }); }
实施要归结为发送DeviceAuthenticationRequest请求,从响应中接收播放器的标识符并将其放置在客户端的属性中。 立即,使用另一种方法,该助手使用请求中的包装将请求发送到GameSparks。
这两个测试都是绿色的,仍然需要添加连接的关闭和重构。
在GameClient中,我添加了一种方法来关闭与服务器的连接(断开连接),并结合connect和authWithCustomId的connectAsAnonymous。 一方面,connectAsAnonymous违反了单一责任原则,但似乎并没有违反……同时,它还增加了可用性,因为在测试中通常需要对客户端进行身份验证。 您如何看待?
在测试中,他添加了一个工厂方法助手,该助手创建游戏客户端的新实例并将其添加到已创建客户端的数组中。 在特殊的mocha处理程序中,对数组中的客户端每次运行测试后,我都调用断开连接方法并清除此数组。 我还不喜欢代码中的“魔术字符串”,所以我添加了一个带有测试中使用的自定义标识符的字典。
最终代码可以在存储库中查看,我将在本文结尾处提供该链接。
游戏搜索组织(对接会)
我将启动配对功能,这对多人游戏非常重要。 当我们在游戏中按“查找游戏”按钮时,该系统开始工作。 她会选择竞争对手或队友,或两者(取决于游戏)。 通常,在这样的系统中,每个玩家都有一个数字指示器MMR(配比)-玩家的个人评分,用于选择具有相同技能水平的其他玩家。
为了测试此功能,我提出了以下测试:
it("should find match", async function () { var gameClient1 = newGameClient(); var gameClient2 = newGameClient(); var gameClient3 = newGameClient(); await gameClient1.connectAsAnonymous(playerCustomIds.id1); await gameClient2.connectAsAnonymous(playerCustomIds.id2); await gameClient3.connectAsAnonymous(playerCustomIds.id3); await gameClient1.findStandardMatch(); expect(gameClient1.state) .is.equals(GameClientModule.GameClientStates.MATCHMAKING); await gameClient2.findStandardMatch(); expect(gameClient2.state) .is.equals(GameClientModule.GameClientStates.MATCHMAKING); await gameClient3.findStandardMatch(); expect(gameClient3.state) .is.equals(GameClientModule.GameClientStates.MATCHMAKING); await sleep(3000); expect(gameClient1.state) .is.equals(GameClientModule.GameClientStates.CHALLENGE); expect(gameClient1.challenge, "challenge").is.not.undefined; expect(gameClient1.challenge.challengeId).is.not.undefined; expect(gameClient2.state) .is.equals(GameClientModule.GameClientStates.CHALLENGE); expect(gameClient2.challenge.challengeId) .is.equals(gameClient1.challenge.challengeId); expect(gameClient3.state) .is.equals(GameClientModule.GameClientStates.CHALLENGE); expect(gameClient3.challenge.challengeId) .is.equals(gameClient1.challenge.challengeId); });
三个客户端已连接到游戏(将来,这是检查某些情况的必要最低要求),并已注册以搜索游戏。 在服务器上注册第三个玩家后,便形成了一个游戏会话,玩家必须连接到该游戏。 同时,客户端的状态发生变化,并且出现具有相同标识符的游戏会话的上下文。
首先,准备后端。 在GameSparks中,有一个现成的用于自定义游戏搜索的工具,可在“配置器->匹配”路径中使用。 我创建一个新的,然后继续进行设置。 除了标准参数(例如代码,比赛的名称和描述)外,还显示了自定义游戏模式所需的最小和最大玩家数。 我将代码“ StandardMatch”分配给创建的比赛,并指示2到3的玩家人数。
现在,您需要在“阈值”部分中配置选择玩家的规则。 对于每个阈值,将指示其作用时间,类型(绝对,相对和百分比)和边界。

假设某个MMR为19的玩家开始搜索。在上面的示例中,前10秒将是其他MMR为19到21的玩家的选择。如果未选择该玩家,则会激活第二个搜索边框,这会将搜索范围从16个扩展到接下来的20秒( 19-3)至22(19 + 3)。 接下来,包括第三个阈值,在此阈值内进行的搜索将在14(19-25%)到29(19 + 50%)的范围内进行30秒的搜索,而如果已累积了所需的最少玩家数目,则认为比赛已完成(接受最低分数)玩家)
实际上,该机制更为复杂,因为它考虑了设法加入特定比赛的所有玩家的MMR。 当需要进行游戏的评分模式时,我将分析这些细节(本文中不介绍)。 对于我不打算使用MMR的标准游戏模式,我只需要一个阈值即可。
选择所有玩家后,您需要创建一个游戏会话并将其连接到该玩家。 在GameSparks中,游戏会话功能是“挑战”。 作为该实体的一部分,存储游戏会话数据,并在游戏客户端之间交换消息。 要创建新型的挑战,您需要遵循“ Configurator-> Challenges”的路径。 在这里,我添加了一个代码为“ StandardChallenge”的新类型,并指出这种类型的游戏会话是基于回合的,即 玩家轮流,而不是同时轮流。 GameSparks同时控制着移动顺序。
为了让客户注册以搜索游戏,您可以使用MatchmakingRequest类型的请求,但我不建议您这样做,因为要求玩家的MMR值作为参数之一。 这可能导致游戏客户端欺诈,并且客户端不应该知道任何MMR,这是一项后端业务。 为了正确注册游戏搜索,我从客户端创建了一个任意事件。 这是在“配置器->事件”部分中完成的。 我将此事件称为没有属性的FindStandardMatch。 现在,您需要配置对此事件的响应,为此,我将转到云代码的“ Configurator-> Cloud Code”部分,在“ Events”部分中为FindStandardMatch编写以下处理程序:
var matchRequest = new SparkRequests.MatchmakingRequest(); matchRequest.matchShortCode = "StandardMatch"; matchRequest.skill = 0; matchRequest.Execute();
该代码在StandardMatch中注册了一个MMR为0的玩家,因此,注册用于搜索标准游戏的任何玩家都适合创建游戏会话。 在选择等级比赛时,可能会吸引玩家资料的私人数据以获得此类比赛的MMR。
当有足够的玩家开始游戏会话时,GameSparks将向所有选定的玩家发送MatchFoundMessage消息。 在这里,您可以自动生成游戏会话并向其中添加玩家。 为此,在“用户消息-> MatchFoundMessage”中添加代码:
var matchData = Spark.getData(); if (Spark.getPlayer().getPlayerId() != matchData.participants[0].id) { Spark.exit(); } var challengeCode = ""; var accessType = "PRIVATE"; switch (matchData.matchShortCode) { case "StandardMatch": challengeCode = "StandardChallenge"; break; default: Spark.exit(); } var createChallengeRequest = new SparkRequests.CreateChallengeRequest(); createChallengeRequest.challengeShortCode = challengeCode; createChallengeRequest.accessType = accessType; var tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); createChallengeRequest.endTime = tomorrow.toISOString(); createChallengeRequest.usersToChallenge = []; var participants = matchData.participants; var numberOfPlayers = participants.length; for (var i = 1; i < numberOfPlayers; i++) { createChallengeRequest.usersToChallenge.push(participants[i].id) } createChallengeRequest.Send();
代码首先检查它是否是参与者列表中的第一个参与者。 接下来,代表第一个玩家,创建StandardChallenge的实例,并邀请其余玩家。 邀请的玩家会收到一个ChallengeIssuedMessage消息。 在这里,您可以设想当客户端上显示加入游戏邀请并需要通过发送AcceptChallengeRequest进行确认时的行为,或者您可以以静默方式接受邀请。 因此,我将这样做,为此,在“用户消息-> ChallengeIssuedMessage”中,我将添加以下代码:
var challangeData = Spark.getData(); var acceptChallengeRequest = new SparkRequests.AcceptChallengeRequest(); acceptChallengeRequest.challengeInstanceId = challangeData.challenge.challengeId; acceptChallengeRequest.message = "Joining"; acceptChallengeRequest.SendAs(Spark.getPlayer().getPlayerId());
下一步,GameSparks将调度ChallengeStartedMessage事件。 该事件的全局处理程序(“ Global Messages-> ChallengeStartedMessage”)是初始化游戏会话的理想场所,在实现游戏逻辑时,我会注意这一点。
客户端应用程序的时机已到。 客户端模块中的更改:
exports.GameClientStates = { IDLE: "Idle", MATCHMAKING: "Matchmaking", CHALLENGE: "Challenge" } exports.GameClient = function () { this.state = exports.GameClientStates.IDLE; this.challenge = undefined; function onMessage(message) { switch (message["@class"]) { case ".MatchNotFoundMessage": this.state = exports.GameClientStates.IDLE; break; case ".ChallengeStartedMessage": this.state = exports.GameClientStates.CHALLENGE; this.challenge = message.challenge; break; default: console.log("GAME onMessage: " + JSON.stringify(message)); } } onMessage = onMessage.bind(this); this.findStandardMatch = function () { var eventData = { eventKey: "FindStandardMatch" } return new Promise(resolve => { sendRequest("LogEventRequest", eventData) .then(response => { if (!response.error) { this.state = exports.GameClientStates.MATCHMAKING; resolve(); } else { console.error(response.error); reject(new Error(response)); } }) .catch(error => { console.error(error); reject(new Error(error)); }); }); } }
根据测试,客户端上出现了两个字段-状态和挑战。 onMessage方法已经获得了有意义的外观,现在响应有关游戏会话开始的消息以及无法进行游戏的消息。 还添加了findStandardMatch方法,该方法将相应的请求发送到后端。 测试是绿色的,但我很满意,已经掌握了游戏的选择。
接下来是什么?
在以下文章中,我将描述开发游戏逻辑的过程,从初始化游戏会话到处理移动。 我将分析存储不同类型数据的功能-游戏元数据的描述,游戏世界的特征,来自游戏会话的数据以及有关玩家的数据。 游戏逻辑将通过两种测试来开发-单元测试和集成测试。
我将按与文章相关的部分
上载github上的源代码 。
有一种理解,为了有效地开发一款游戏,您需要扩展我们的发烧友团队。 艺术家/设计师即将加入。 还有Unity3D的专家,他将成为移动平台的领导者,还有待观察。