多人游戏在游戏行业中仍然占据主导地位。为什么不呢?为了解决旧的恩怨,解决争端,甚至满足竞争的渴望,在线与其他用户进行战斗,既令人愉悦又令人放松。
这就是为什么 这个 Launchpad 应用程序 创建了自己的实时策略游戏 Pizza Tribes,其中涉及……等等……老鼠!游戏玩法包括训练一群老鼠烘焙和出售披萨以获取金币,最终目标是比任何其他玩家生成更多金币。
尽管它很有创意,但如果没有 Redis 的能力,这个应用程序将无法为用户提供实时游戏体验,因为 Redis 可以有效地在组件之间传输数据。任何延迟都会导致实时游戏体验无法实现。
让我们看看这个应用程序是如何创建的。但在我们继续之前,我们想指出,我们拥有各种各样的应用程序,这些应用程序正在影响你的日常生活,你可以 在 Redis Launchpad 上查看。
你将使用 Redis 构建一个多人基于浏览器的实时策略游戏。下面我们将按时间顺序介绍每个步骤,并概述创建此应用程序所需的所有组件。
准备好了吗?好的,让我们直接进入。
该应用程序在客户端-服务器通信方面采用了非传统方法。这是因为它严重依赖于网络套接字来执行通常由 HTTP 请求/响应完成的职责。
下面是一个典型的网络套接字流程示例
由于此应用程序依赖于 Web-Sockets,因此我们将花更多时间讨论它们所扮演的角色。首先,网络套接字通信严重依赖于 Redis 来发送和检索消息。
API 不执行任何游戏逻辑,而只是在将客户端消息推送到 Redis 之前验证这些消息。同时,工作线程可以水平扩展,同时依赖 Redis 通过系统有效地推送消息。
由于 Web Socket 是通过单个 TCP 连接进行的双向轻量级通信协议,因此很难扩展 Web API(持有套接字)。
此解决方案试图最大程度地减少 Web API 的负载,以便它可以专注于将数据发送到客户端。
注意:消息不是使用 Redis 发布/订阅在 API 和工作线程之间发送。相反,使用 Redis 列表 (RPush 和 BLPop)。这样做的一个优点是工作线程或 API 可以重新启动,而不会丢失消息,而使用 Redis 发布/订阅,所有内容都将被遗忘。
工作线程需要延迟某些任务(例如,在 5 分钟后完成建筑物的建设)。这是通过对排序集 user_updates 进行更新来实现的。然后,更新程序会拉取排序集的顶层记录,该记录由时间决定。如果时间已过,它将从集合中删除该记录,然后更新该用户的游戏状态。
下面是一个简化的典型流程
i. 验证命令
ii. 更新用户游戏状态
JSON.ARRAPPEND user:$user_id:gamestate .constructionQueue $constructionItem
iii. 发现下次需要更新用户游戏状态的时间(例如,当建造完成时)
iv. 设置用户游戏状态的下一次更新时间
ZADD user_updates $timestamp $user_id
3. 更新程序在下次更新时间更新用户游戏状态,执行多个命令
i) 运行以下命令以获取下一个需要更新的用户(以及在什么时间)
ZRANGE user_updates 0 0 WITHSCORES
ii) 如果分数(时间戳)已过
a) 删除下一次更新时间
REM user_updates $user_id
b) 执行游戏状态更新
c) 查找下次需要再次更新用户游戏状态的时间
d) 设置下一次更新时间
ZADD user_updates $timestamp $user_id
协议缓冲区用于定义在客户端/服务器和服务器/客户端之间发送的消息。
以下是完整定义。
syntax = "proto3";
package pizzatribes;
option go_package = "github.com/fnatte/pizza-tribes/internal/models";
import "education.proto";
import "building.proto";
import "research.proto";
message ClientMessage {
message Tap {
string lotId = 1;
}
message ConstructBuilding {
string lotId = 1;
Building building = 2;
}
message UpgradeBuilding {
string lotId = 1;
}
message RazeBuilding {
string lotId = 1;
}
message CancelRazeBuilding {
string lotId = 1;
}
message Train {
Education education = 1;
int32 amount = 2;
}
message Expand {
}
message Steal {
int32 amount = 1;
int32 x = 2;
int32 y = 3;
}
message ReadReport {
string id = 1;
}
message StartResearch {
ResearchDiscovery discovery = 1;
}
string id = 1;
oneof type {
Tap tap = 2;
ConstructBuilding constructBuilding = 3;
UpgradeBuilding upgradeBuilding = 4;
Train train = 5;
Expand expand = 6;
Steal steal = 7;
ReadReport readReport = 8;
RazeBuilding razeBuilding = 9;
StartResearch startResearch = 10;
CancelRazeBuilding cancelRazeBuilding = 11;
}
}
以下是完整定义。
syntax = "proto3";
package pizzatribes;
option go_package = "github.com/fnatte/pizza-tribes/internal/models";
import "gamestate.proto";
import "stats.proto";
import "report.proto";
message ServerMessage {
message Response {
string requestId = 1;
bool result = 2;
}
message User {
string username = 1;
}
message Reports {
repeated Report reports = 1;
}
string id = 1;
oneof payload {
GameStatePatch stateChange = 2;
User user = 3;
Response response = 4;
Stats stats = 5;
Reports reports = 6;
}
}
.
├── cmd (golang source files for each command/process)
│ ├── api
│ ├── migrator
│ ├── updater
│ └── worker
├── docs
├── internal (shared golang source files)
├── protos (protobuf files used by both backend and frontend)
└── webapp (frontend application)
├── fonts
├── images
├── plugins
├── src
└── tools
先决条件
有两种方法可以在本地运行应用程序。你可以将所有内容(Redis、服务、Web 应用程序)都运行在 Docker 容器中,或者你可以选择更快地开发。
克隆存储库
git clone https://github.com/redis-developer/pizza-tribes
这无疑是在本地运行项目的简便方法。要开始,你需要执行如下所示的 docker-compose 命令
cd pizza-tribes
cp .env.default .env
docker-compose up --build -d
此命令将实现以下操作
执行此命令后,将实现以下操作:
如果你想进行更改,那么你将受益于 Web 应用程序中的热模块替换 (HMR)。这将使你能够更高效地构建 Go 应用程序。为此,使用 docker-compose 运行 Redis,然后在你的主机操作系统上运行服务和 Web 应用程序
printf "HOST=:8080\nORIGIN=http://localhost:3000\n" > .env
docker-compose up -d redis redisinsight
make -j start # Build and run go services (see Makefile for details)
cd webapp # in another terminal
npm install
npm run dev
执行此命令后,你应该得到以下结果
注意:Web 应用程序将代理对 /api 的调用到http://localhost:8080(参见 webapp/vite.config.ts)。
用户存储为键 user:{user_id} 中的哈希集,其中包含字段。这些字段包括
可以使用用户名通过 username:{username} 查找 user_id。
注册通过以下命令实现
SET username:{username} user_id
HSET user:{user_id} "id" user_id "username" username "hashed_password" hash
身份验证如下所示
GET username:{username}
HGETALL user:{user_id}
注意:如果哈希值匹配,则创建 JWT
使用 RedisJSON,用户游戏状态存储为键 user 中的 JSON 值。
user:{user_id}:gamestate
它使用以下结构执行此操作:
syntax = "proto3";
package pizzatribes;
option go_package = "github.com/fnatte/pizza-tribes/internal/models";
import "google/protobuf/wrappers.proto";
import "education.proto";
import "building.proto";
import "research.proto";
message OngoingResearch {
int64 complete_at = 1;
ResearchDiscovery discovery = 2;
}
message Training {
int64 complete_at = 1;
Education education = 2;
int32 amount = 3;
}
message Construction {
int64 complete_at = 1;
string lotId = 2;
Building building = 3;
int32 level = 4;
bool razing = 5;
}
message Travel {
int64 arrival_at = 1;
int32 destinationX = 2;
int32 destinationY = 3;
bool returning = 4;
int32 thieves = 5;
int64 coins = 6;
}
message GameState {
message Resources {
int32 coins = 1;
int32 pizzas = 2;
}
message Lot {
Building building = 1;
int64 tapped_at = 2;
int32 level = 3;
}
message Population {
int32 uneducated = 1;
int32 chefs = 2;
int32 salesmice = 3;
int32 guards = 4;
int32 thieves = 5;
int32 publicists = 6;
}
Resources resources = 1;
map<string, Lot> lots = 2;
Population population = 3;
int64 timestamp = 4;
repeated Training trainingQueue = 5;
repeated Construction constructionQueue = 6;
int32 townX = 7;
int32 townY = 8;
repeated Travel travelQueue = 9;
repeated ResearchDiscovery discoveries = 10;
repeated OngoingResearch researchQueue = 11;
}
message GameStatePatch {
message ResourcesPatch {
google.protobuf.Int32Value coins = 1;
google.protobuf.Int32Value pizzas = 2;
}
message LotPatch {
Building building = 1;
int64 tapped_at = 2;
int32 level = 3;
bool razed = 4;
}
message PopulationPatch {
google.protobuf.Int32Value uneducated = 1;
google.protobuf.Int32Value chefs = 2;
google.protobuf.Int32Value salesmice = 3;
google.protobuf.Int32Value guards = 4;
google.protobuf.Int32Value thieves = 5;
google.protobuf.Int32Value publicists = 6;
}
ResourcesPatch resources = 1;
map<string, LotPatch> lots = 2;
PopulationPatch population = 3;
google.protobuf.Int64Value timestamp = 4;
bool trainingQueuePatched = 5;
repeated Training trainingQueue = 6;
bool constructionQueuePatched = 7;
repeated Construction constructionQueue = 8;
google.protobuf.Int32Value townX = 9;
google.protobuf.Int32Value townY = 10;
bool travelQueuePatched = 11;
repeated Travel travelQueue = 12;
bool discoveriesPatched = 13;
repeated ResearchDiscovery discoveries = 14;
bool researchQueuePatched = 15;
repeated OngoingResearch researchQueue = 16;
}
根据用例,以不同的方式访问游戏状态。但是为了完整检索,使用以下方法
JSON.GET user:{user_id}.gamestate
在其他情况下,使用路径仅检索数据的子集
JSON.GET user:{user_id}.gamestate '.lots["5"]'
JSON.GET user:{user_id}.gamestate .population
游戏状态更新是使游戏运行的关键,也是游戏中最重要的流程之一。其目的是
此外,游戏状态更新还将:
更新程序在循环中运行,查询一个名为 user_updates 的排序集合。它通过运行以下命令检索排序集合中的顶部记录
ZRANGE user_updates 0 0 WITHSCORES
{1.6208459243016696e+18 c2e16taink8s73ejr3qg}
通过使用 WITHSCORES,我们还检索了该用户需要游戏状态的时间戳。因此,更新程序可以检查时间戳是否小于现在。如果是,则用户可以执行以下命令
ZREM user_updates c2e19af8q04s73f8j8lg
2. 继续更新游戏状态
注意:存在一定程度的风险,因为如果游戏状态更新失败,用户将不再在 user_updates 排序集合中拥有记录。发生这种情况时,将不会安排任何游戏状态更新。
为了避免这种情况,游戏将确保用户在登录时安排游戏状态更新。
更新使用检查和设置方法(WATCH、MULTI、EXEC)执行。这是通过以下步骤实现的
WATCH user:{user_id}:gamestate
JSON.GET user:{user_id}:gamestate
有关简单游戏状态更新的更多详细信息,请参见以下跟踪
watch user:c2e19af8q04s73f8j8lg:gamestate: OK
JSON.GET user:c2e19af8q04s73f8j8lg:gamestate .: {"resources":{"coins":20,"pizzas":0},"lots":{"1":{"building":3},"2":{"building":0},"9":{"building":1},"10":{"building":2}},"population":{"uneducated":8,"chefs":1,"salesmice":1,"guards":0,"thieves":0},"timestamp":1620845911,"trainingQueue":[],"constructionQueue":[],"townX":51,"townY":58,"travelQueue":[]}
[multi: QUEUED
JSON.SET user:c2e19af8q04s73f8j8lg:gamestate .timestamp 1620845921: OK
JSON.SET user:c2e19af8q04s73f8j8lg:gamestate .resources.coins 22: OK
JSON.SET user:c2e19af8q04s73f8j8lg:gamestate .resources.pizzas 0: OK
exec: []]
unwatch: OK
注意:资源外推、建筑物完成、训练和完成旅行都是使用上面描述的流程实现的。
游戏状态更新后,您需要安排下一次更新。以下是操作方法
ZADD user_updates {timestamp_of_next_update} {user_id}
RedisTimeseries 模块用于跟踪用户资源的变化。使用以下密钥跟踪资源
user:c2e19af8q04s73f8j8lg:ts_coins
User:c2e19af8q04s73f8j8lg:ts_pizzas
每次执行游戏状态更新时,都会将新的数据点插入每个密钥。以下是一个示例
TS.ADD user:{user_id}:ts_coins {timestamp_now} {current_amount_of_coins} TS.ADD user:{user_id}:ts_pizzas {timestamp_now} {current_amount_of_pizzas}
当用户想要查看他们的资源历史记录时,将使用以下命令从过去 24 小时检索汇总数据点。
from := now - 24 hours
to := now
timeBucket := 1 hour
TS.RANGE user:{user_id}:ts_coins {from} {to} AGGREGATION avg {timeBucket}
TS.RANGE user:{user_id}:ts_pizzas {from} {to} AGGREGATION avg {timeBucket}
游戏状态更新将改变用户拥有的硬币数量,这就是我们需要更新排行榜的原因。排行榜是一个带有密钥 leaderboard 的排序集合。通过运行以下命令进行更新
ZADD leaderboard {current_amount_of_coins} {user_id}
当任何用户想要访问排行榜时,将使用以下命令检索数据
ZREVRANGE leaderboard 0 20 WITHSCORES
从性能角度来看,实现实时游戏是创建成功的在线多人游戏最重要的目标之一。未能实现这一点将极大地影响用户的体验,无论游戏的其他质量如何先进。
尽管架构复杂,但 Redis 通过其轻松在不同组件之间压缩数据的能力消除了这一障碍。完全没有滞后、延迟或数据倒退,这使得 Launchpad 应用程序能够创建复杂但引人入胜的在线多人策略游戏,来自世界各地的用户可以在其中互相争夺榜首位置。
如果您想深入了解此应用程序是如何制作的,那么您可能需要查看此 YouTube 视频。
我们还应该告诉您,我们在 Redis Launchpad 上拥有各种改变游戏规则的应用程序(请原谅这个双关语)。在这里,您将发现许多应用程序,它们正在通过日常程序员影响日常生活。
所以一定要去看看!
Matteus Hemström
Matteus 是一位极具创新精神的软件工程师,目前在 Nuway 从事软件开发工作。
如果您想了解他参与的所有项目,请访问他的GitHub 页面。