多人游戏在游戏行业仍然占有举足轻重的地位。为什么不是呢?为了解决旧怨、平息争端,甚至满足竞争的渴望,与在线其他用户一较高下既是一种宣泄,也是一种娱乐。
这就是为什么 这个 Launchpad 应用 创建了自己的实时策略游戏《披萨部落》(Pizza Tribes),其中涉及……等等…… 老鼠!游戏玩法包括训练一群老鼠烘烤和出售披萨以赚取金币,最终目标是比任何其他玩家生成更多的金币。
尽管富有创意,但如果没有 Redis 高效传输组件间数据的能力,此应用将无法为用户提供实时游戏体验。任何延迟都会使实时游戏变得不可能。
让我们看看这个应用是如何创建的。但在深入探讨之前,我们想指出,我们在 Redis Launchpad 上有一系列出色的应用,它们正在影响着日常生活,供您了解。
您将使用 Redis 构建一个基于浏览器的多人实时策略游戏。下面我们将按时间顺序逐步介绍,并概述创建此应用所需的所有组件。
准备好开始了吗?好的,让我们直接深入了解。
在客户端-服务器通信方面,该应用采用了非传统的方法。这是因为它严重依赖 WebSocket 来执行通常由 HTTP 请求/响应完成的任务。
以下是典型的 WebSocket 流程示例
由于此应用依赖于 WebSocket,我们将花更多时间介绍它们的作用。首先,WebSocket 通信严重依赖 Redis 来发送和检索消息。
API 不执行任何游戏逻辑,而只是在将客户端消息推送到 Redis 之前进行验证。同时,工作进程可以水平扩展,并依靠 Redis 高效地在系统中推送消息。
由于 WebSocket 是在单个 TCP 连接上的双向轻量级通信协议,因此扩展 Web API(持有 Socket)并不容易。
此解决方案试图最小化 Web API 的负载,使其能够专注于将数据传输给客户端。
注意:API 和工作进程之间不是使用 Redis Pub/Sub 发送消息。而是使用 Redis 列表 (RPush 和 BLPop)。这样做的一个优点是,工作进程或 API 可以重启而不会丢失消息,而使用 Redis Pub/Sub 则会丢失所有内容。
工作进程需要延迟一些任务(例如,在 5 分钟后完成建筑建造)。这通过更新有序集合 user_updates 来实现。更新器随后拉取有序集合中由时间确定的顶部记录。如果时间已过,它将从集合中移除该记录,然后更新该用户的游戏状态。
以下是简化的典型流程
i. 验证命令
ii. 更新用户游戏状态
JSON.ARRAPPEND user:$user_id:gamestate .constructionQueue $constructionItem
iii. 发现下次需要更新用户游戏状态的时间(例如,建造完成时)
iv. 设置用户游戏状态的下次更新时间
ZADD user_updates $timestamp $user_id
3. 一个 updater(更新器)在下次更新时间通过执行一系列命令来更新用户游戏状态
i) 运行以下命令以获取下一个需要更新的用户(以及何时更新)
ZRANGE user_updates 0 0 WITHSCORES
ii) 如果分数(时间戳)已过
a) 移除下次更新时间
REM user_updates $user_id
b) 执行游戏状态更新
c) 查找下次需要再次更新用户游戏状态的时间
d) 设置下次更新时间
ZADD user_updates $timestamp $user_id
Protocol Buffers 用于定义客户端/服务器之间以及服务器/客户端之间发送的消息。
以下是完整定义。
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
先决条件
您可以通过两种方式在本地运行应用。您可以选择在 Docker 容器中运行所有内容(Redis、服务、Web 应用),或者选择性运行以加快开发速度。
克隆仓库
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} 通过 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,用户游戏状态以 JSON 值形式存储在键 user 中。
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 页面。