dot 速度的未来正在你所在的城市举办的活动中。

加入我们参加 Redis 发布会

如何使用 Redis 创建实时在线多人策略游戏

多人游戏在游戏行业中仍然占据主导地位。为什么不呢?为了解决旧的恩怨,解决争端,甚至满足竞争的渴望,在线与其他用户进行战斗,既令人愉悦又令人放松。 

这就是为什么 这个 Launchpad 应用程序 创建了自己的实时策略游戏 Pizza Tribes,其中涉及……等等……老鼠!游戏玩法包括训练一群老鼠烘焙和出售披萨以获取金币,最终目标是比任何其他玩家生成更多金币。 

尽管它很有创意,但如果没有 Redis 的能力,这个应用程序将无法为用户提供实时游戏体验,因为 Redis 可以有效地在组件之间传输数据。任何延迟都会导致实时游戏体验无法实现。 

让我们看看这个应用程序是如何创建的。但在我们继续之前,我们想指出,我们拥有各种各样的应用程序,这些应用程序正在影响你的日常生活,你可以 在 Redis Launchpad 上查看。 

https://www.youtube.com/embed/_yhE_bhmF-g
  1. 你将构建什么?
  2. 你需要什么?
  3. 架构 
  4. 入门
  5. 游戏状态更新

1. 你将构建什么?

你将使用 Redis 构建一个多人基于浏览器的实时策略游戏。下面我们将按时间顺序介绍每个步骤,并概述创建此应用程序所需的所有组件。 

准备好了吗?好的,让我们直接进入。 

2. 你需要什么?

3. 架构 

该应用程序在客户端-服务器通信方面采用了非传统方法。这是因为它严重依赖于网络套接字来执行通常由 HTTP 请求/响应完成的职责。 

下面是一个典型的网络套接字流程示例

  1. Web 应用程序通过网络套接字发送命令
  2. Web API 将命令排队到 Redis 队列 wsin (RPUSH)
  3. 工作线程
    1. 从 Redis 队列 wsin 中提取命令 (BLPop)
    2. 执行命令
    3. 可以将响应推送到另一个 Redis 队列 wsout (RPUSH)
  4. Web API
    1. 从 Redis 队列 wsout 中提取响应 (BLPOP)
    2. 将响应发送回相应的网络套接字

网络套接字

由于此应用程序依赖于 Web-Sockets,因此我们将花更多时间讨论它们所扮演的角色。首先,网络套接字通信严重依赖于 Redis 来发送和检索消息。 

API 不执行任何游戏逻辑,而只是在将客户端消息推送到 Redis 之前验证这些消息。同时,工作线程可以水平扩展,同时依赖 Redis 通过系统有效地推送消息。 

由于 Web Socket 是通过单个 TCP 连接进行的双向轻量级通信协议,因此很难扩展 Web API(持有套接字)。 

此解决方案试图最大程度地减少 Web API 的负载,以便它可以专注于将数据发送到客户端。
注意:消息不是使用 Redis 发布/订阅在 API 和工作线程之间发送。相反,使用 Redis 列表 (RPush 和 BLPop)。这样做的一个优点是工作线程或 API 可以重新启动,而不会丢失消息,而使用 Redis 发布/订阅,所有内容都将被遗忘。

更新程序(延迟任务)

工作线程需要延迟某些任务(例如,在 5 分钟后完成建筑物的建设)。这是通过对排序集 user_updates 进行更新来实现的。然后,更新程序会拉取排序集的顶层记录,该记录由时间决定。如果时间已过,它将从集合中删除该记录,然后更新该用户的游戏状态。

下面是一个简化的典型流程

  1. Web 应用程序发送命令以开始建筑物的建造
  2. 工作线程处理命令,执行多个命令

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

4. 入门

先决条件

  • Docker 
  • Docker Compose

步骤 1:在本地运行应用程序

有两种方法可以在本地运行应用程序。你可以将所有内容(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 应用程序和 Caddy 前端
  • 运行所有内容:包括 Redis 和 RedisInsight 

执行此命令后,将实现以下操作: 

  • Redis 服务器在端口 6379 上运行
  • Redisinsight  在端口 8001 上运行
  • Web 应用程序在端口 8080 上运行

选择更快地开发

如果你想进行更改,那么你将受益于 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

执行此命令后,你应该得到以下结果

  • Redis 服务器通过 Docker 在 6379 上运行
  • Redis GUI“Reedisinsight”通过 docker 在 8001 上运行
  • Web 应用程序在主机系统上的 3000 上运行
  • API 通过主机操作系统在 8080 上运行

注意:Web 应用程序将代理对 /api 的调用到http://localhost:8080(参见 webapp/vite.config.ts)。

步骤 2:注册新玩家

用户存储为键 user:{user_id} 中的哈希集,其中包含字段。这些字段包括

  • id
  • 用户名
  • 哈希密码

可以使用用户名通过 username:{username} 查找 user_id。 

注册通过以下命令实现

  • 生成唯一 ID (rs/xid)
  • redis 命令
SET username:{username} user_id
  • redis 命令
HSET user:{user_id} "id" user_id "username" username "hashed_password" hash

身份验证如下所示

  • redis 命令(获取用户 ID)
GET username:{username}
  • redis 命令
HGETALL user:{user_id}
  • 验证哈希密码

注意:如果哈希值匹配,则创建 JWT

步骤 3:存储用户游戏状态

使用 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;
}

根据用例,以不同的方式访问游戏状态。但是为了完整检索,使用以下方法

  • redis 命令
JSON.GET user:{user_id}.gamestate

在其他情况下,使用路径仅检索数据的子集

  • redis 命令(检索地块 5 上的建筑信息)
JSON.GET user:{user_id}.gamestate '.lots["5"]'
  • redis 命令(检索人口数据)
JSON.GET user:{user_id}.gamestate .population

5. 游戏状态更新

游戏状态更新是使游戏运行的关键,也是游戏中最重要的流程之一。其目的是

  • 推断资源(即,自上次更新以来,用产生的数量增加资源)
  • 完成建筑物
  • 完成角色的训练课程
  • 完成旅行(例如盗贼在城镇之间移动)

此外,游戏状态更新还将:

  • 插入 RedisTimeSeries 的资源数据点
  • 更新排行榜(因为资源已更改)

发现哪个用户需要更新

更新程序在循环中运行,查询一个名为 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
  • 运行游戏状态进程以确定如何转换游戏状态
  • MULTI
  • 运行所有修改命令以转换为上一步中计算的游戏状态
  • EXEC

有关简单游戏状态更新的更多详细信息,请参见以下跟踪

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 插入数据点

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 将一切都保持实时

从性能角度来看,实现实时游戏是创建成功的在线多人游戏最重要的目标之一。未能实现这一点将极大地影响用户的体验,无论游戏的其他质量如何先进。

尽管架构复杂,但 Redis 通过其轻松在不同组件之间压缩数据的能力消除了这一障碍。完全没有滞后、延迟或数据倒退,这使得 Launchpad 应用程序能够创建复杂但引人入胜的在线多人策略游戏,来自世界各地的用户可以在其中互相争夺榜首位置。

如果您想深入了解此应用程序是如何制作的,那么您可能需要查看此 YouTube 视频

我们还应该告诉您,我们在 Redis Launchpad 上拥有各种改变游戏规则的应用程序(请原谅这个双关语)。在这里,您将发现许多应用程序,它们正在通过日常程序员影响日常生活。

所以一定要去看看!

谁创建了此应用程序?

Matteus Hemström

Matteus 是一位极具创新精神的软件工程师,目前在 Nuway 从事软件开发工作。

如果您想了解他参与的所有项目,请访问他的GitHub 页面