dot Redis 8 已发布——它是开源的

了解更多

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

多人游戏在游戏行业仍然占有举足轻重的地位。为什么不是呢?为了解决旧怨、平息争端,甚至满足竞争的渴望,与在线其他用户一较高下既是一种宣泄,也是一种娱乐。 

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

尽管富有创意,但如果没有 Redis 高效传输组件间数据的能力,此应用将无法为用户提供实时游戏体验。任何延迟都会使实时游戏变得不可能。 

让我们看看这个应用是如何创建的。但在深入探讨之前,我们想指出,我们在 Redis Launchpad 上有一系列出色的应用,它们正在影响着日常生活,供您了解。 

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

1. 您将构建什么?

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

准备好开始了吗?好的,让我们直接深入了解。 

2. 您需要什么?

  • Typescript用作 JavaScript 语言的超集
  • Golang用于构建高效软件的首选编程语言
  • RedisTimeSeries提供时间序列数据
  • RedisJSON从 Redis 键中存储、更新和获取 JSON 值

3. 架构 

在客户端-服务器通信方面,该应用采用了非传统的方法。这是因为它严重依赖 WebSocket 来执行通常由 HTTP 请求/响应完成的任务。 

以下是典型的 WebSocket 流程示例

  1. Web App 通过 WebSocket 发送命令
  2. Web API 将命令入队到 Redis 队列 wsin (RPUSH)
  3. 一个 Worker(工作进程)
    1. 从 Redis 队列 wsin 拉取命令 (BLPop)
    2. 执行命令
    3. 可能会将响应推送到另一个 Redis 队列 wsout (RPUSH)
  4. Web API
    1. 从 Redis 队列 wsout 拉取响应 (BLPOP)
    2. 将响应发送回相应的 WebSocket

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 来实现。更新器随后拉取有序集合中由时间确定的顶部记录。如果时间已过,它将从集合中移除该记录,然后更新该用户的游戏状态。

以下是简化的典型流程

  1. Web App 发送开始建造建筑的命令
  2. 一个 worker(工作进程)通过执行一系列命令来处理该命令

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

4. 入门

先决条件

  • Docker 
  • Docker Compose

步骤 1:在本地运行应用

您可以通过两种方式在本地运行应用。您可以选择在 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 应用和 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 ‘RedisInsight’ 通过 Docker 运行在 8001
  • Web 应用运行在主机系统上,端口 3000
  • API 通过主机操作系统运行在 8080

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

步骤 2:注册新玩家

用户作为哈希集存储在键 user:{user_id} 中,包含以下字段:

  • id
  • username
  • hashed_password

可以使用 username:{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}
  • 验证 hashed_password

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

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

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

游戏状态根据用例以不同方式访问。但对于完整检索,使用以下命令:

  • 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 页面。