dot 快速未来的趋势即将出现在您所在的城市。

加入我们参加 Redis 发布会

使用 Redis 构建实时地理分布式多人俯视角街机射击游戏

随着游戏行业规模的不断扩大,创造独特的动态用户体验变得越来越重要。由于其粉丝群,企业必须最大限度地提升多人游戏体验,以推动客户获取和留存。然而,企业在尝试扩展多人游戏时面临着许多障碍,所有这些障碍都可以通过 Redis 解决。

个性化的交互和高速反应是创造独特用户体验的核心。Redis 为游戏发行商提供强大的数据库,可以支持低延迟的游戏用例。最近,一个 Launchpad 应用程序构建了一个独特的应用程序,由于其在数据传输方面的无与伦比的速度,该应用程序只能通过 Redis 部署。

这一点至关重要,因为参与者来自世界各地。玩家之间的交互必须实时执行才能支持游戏玩法,要求延迟小于一毫秒。

让我们深入探讨一下这是如何实现的。但在我们这样做之前,请务必浏览我们Launchpad上激动人心的各种不同应用程序。

https://www.youtube.com/embed/EOwTOVoIEp4

  1. 您将构建什么?
  2. 您需要什么?
  3. 架构
  4. 入门
  5. 如何使用 RedisGears 函数列表
  6. 结论:克服地理障碍,实现非凡的活跃

1. **您将构建什么?**

您将使用 Redis 构建一个实时地理分布式多人俯视角街机射击游戏。应用程序的核心是 Redis,因为它充当高效的实时数据库,使您能够存储和分发数据。

随着我们按时间顺序逐步完成每个阶段,我们将解释重要的术语以及每个 Redis 函数。

2. **您需要什么?**

让我们确定创建此游戏所需的不同组件。应用程序包含 3 个主要组件

  • Redis: 用作数据库、缓存和消息代理。
  • RedisGears: 定义游戏功能,并支持与游戏状态和用户事件进行交互
  • RediSearch: 支持强大的查询体验
  • JavaScript 客户端: 捕获用户输入并将输入发送到后端。还使用 phaser 3 引擎进行渲染和物理模拟 3 个游戏引擎
  • NodeJS 后端WebSocket: 用户和 Redis 之间的通信接口。主要用于构建游戏逻辑和启用 Redis API

3. **架构**

这个多人游戏的核心思想是使其实时并地理分布。这意味着集群中的所有实例都应保持更新,以避免出现不同步情况。现在让我们看一下架构。

  • NodeJS 构成玩家和 Redis 数据库之间的通信接口。
  • JavaScript 和 Phaser 3 库用于渲染游戏状态并将用户输入传递给 Redis(通过 NodeJS)
  • RedisGears 用于支持与游戏状态和用户事件进行交互

4. **工作原理**

现在让我们看一下架构的流程。

  • 一切都始于使用 Javascript,然后流经 Node JS(或 WebSocket),在用户和后端(Redis)之间进行通信。
  • 玩家使用方向键控制角色在地形中移动
  • 通过点击鼠标按钮向敌人发射导弹
  • 所有这些命令都通过 WebSocket 发送到 Redis
  • RedisGears 解析这些命令并更新状态,确定命令是否有效
  • 这将把命令分发回其他用户和玩家
  • 如果命令错误,RedisGears 将从游戏中删除某些玩家
  • 用户使用方向键在地形中导航,并通过点击鼠标向对手发射导弹
  • 当用户执行操作时,这些命令将通过 WebSocket 发送到 RedisGears。
  • 有效的命令将通过 Redis Streams 重新分发给其他用户。
  • NodeJS 构成用户和 Redis 之间的通信通道
  • 用户可以找到游戏并加入游戏。
  • 允许用户邀请其他用户加入游戏
  • 如果游戏不可用,它允许用户创建新游戏。
  • RedisGears 反过来支持与游戏状态和用户事件进行交互
  • RedisGears 解析数据并根据用户命令确定应将其分发到哪里。
  • RediSearch 允许用户搜索游戏

5. **入门**

先决条件

  • Docker
  • Docker-compose

克隆存储库

git clone https://github.com/redis-developer/online_game/

在存储库的根目录下,您会找到一个 Docker compose YAML 文件

version: '3.8'
services:
  redis:
    build:
      dockerfile: ./dockerfiles/Dockerfile_redis
      context: .
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
      - DISABLE_COMMANDS=FLUSHDB,FLUSHALL,CONFIG,HSCAN
    volumes: 
      - ./redis/redis_data:/data
      - ./redis/redis.conf:/usr/local/etc/redis/redis.conf
    ports:
      - 6379:6379
    restart: always
  backend:
    build:
      dockerfile: ./dockerfiles/Dockerfile_node_backend
      context: .
    environment:
      - NODE_ENV=development
    volumes: 
      - ./game_conf.json:/game_config/game_conf.json
    ports:
      - 8080:8080
      - 8082:8082
    restart: always

在此 YAML 文件中,定义了两个主要服务 - redis 和 backend。

以下是 Redis 的 Dockerfile 的样子

FROM redislabs/redismod:latest
COPY ./redis/redis_functions /functionsCOPY ./redis/start_redis.sh /start_redis.sh
RUN chmod +x /start_redis.sh
ENTRYPOINT ["bash"]CMD ["/start_redis.sh"]

以下是 NodeJS 后端的 Dockerfile 的样子

FROM node:15.14WORKDIR /home/node/app
COPY ./app/package*.json ./RUN npm install --only=production
COPY ./app .CMD [ "npm", "start" ]

启动服务

从 online_game 目录运行以下命令

docker-compose up

您可以通过 http://127.0.0.1:8080 访问在线 Web 服务器

6. **如何使用 RedisGears 函数列表**

有三个 RedisGears 函数,每个函数都有一组子函数

find_game 函数

当用户启动游戏时,用户将首先使用 RediSearch 搜索游戏。如果游戏存在,那么该用户将尝试加入游戏。否则,将触发 RedisGears 创建新游戏。请查看以下函数

def find_game(user_id):
    game = query()
     if game != [@] and type(game) == list:
         return game[1].split(":") [1]

     # CREATE A NEW GAME IF THERE ARE NO GAMES
     game = execute("RG.TRIGGER", "create_new_game", f"USER:{user_id}")

     if game:
     return game[@]

   (
        GB( 'CommandReader ' )
        .map(lambda x: find_game(*x[1:]))
        .register(trigger=self.command_name
   )

当用户创建新游戏后,其他玩家将加入,每个人都可以玩游戏。

create_new_game 函数

class CreateNewGameFunctionBuilder(BaseFunctionBuilder):
    def __init__(self):
        super().__init__(command_name='create_new_game')


    def register_command(self):
        """
            Registers create_new_game redis gears fucntion to redis.
            For each generate_new_game call creates a new HASH under game namespace:
                GAME:[game_id] owner [user_id], secret [hash], private [bool], playercount [int]
            Returns:
                redis key [GAME:game_id]
            Trigger example:
                RG.TRIGGER create_new_game USER:123 1 secret123
        """

        def subcall(user, private=0, secret=""):
            game_id = uuid.uuid4().hex
            key = f"GAME:{game_id}"

            execute("HSET", key, "owner", user, "secret", str(secret), "private", int(private), "playercount", 0)
            execute("EXPIRE", key, SECONDS_IN_DAY)

            return game_id
        (
            GB('CommandReader')
            .map(lambda x: subcall(*x[1:]))
            .register(trigger=self.command_name, mode='sync')
        )

如果不存在游戏,则将触发 RedisGears 创建新游戏。

当用户创建新游戏后,其他玩家将加入,每个人都可以玩游戏。

create_new_user 函数

class CreateUserFunctionBuilder(BaseFunctionBuilder):
    def __init__(self):
        super().__init__(command_name='create_new_user')
        

    def register_command(self):
        """
            Registers create_new_user redis gears fucntion to redis.
            For each create_new_user call creates a new HASH under user namespace:
                USER:[u_id] name [str], settings [str], secret [str]
            Returns:
                redis key [USER:u_id]
            Trigger example:
                RG.TRIGGER create_new_user hhaa Player1 '' aahh
        """

        def subcall(user_id, name, settings='{}', secret=""):
            key = f"USER:{user_id}"

            execute("HSET", key, "name", name, "setttings", settings, "secret", str(secret))
            execute("EXPIRE", key, SECONDS_IN_DAY * 30)

            return key
        (
            GB('CommandReader')
            .map(lambda x: subcall(*x[1:]))
            .register(trigger=self.command_name)
        )

此函数将新玩家添加到游戏中,其方法与 Create_new_game 函数相同。同样,将触发 RedisGears,然后创建新用户

join_game 函数

class JoinGameFunctionBuilder(BaseFunctionBuilder):
    def __init__(self):
        super().__init__(command_name='join_game')

    def register_command(self):
        """
            Determines best public server to join to.
                 - Assings User to the Game.
                 - Increments playercount
            Arguments:
                user, game, secret (optional)
            Returns:
                redis key [GAME:game_id]
            Trigger example:
                RG.TRIGGER join_game user1 game1
                RG.TRIGGER join_game user1 game1 secret123
        """

当用户加入游戏时,将触发 RedisGears 以启用 Join_game 函数。这还会增加游戏实例的玩家数量 (HINCRBY)。

leave_game 函数

class LeaveGameFunctionBuilder(BaseFunctionBuilder):
    def __init__(self):
        super().__init__(command_name='leave_game')

    def register_command(self):
        """
            Determines best public server to join to.
                 - Removes USER to the ROOM.
                 - Decrements playercount
                 - Publishes a notification
            Arguments:
                user, game
            Returns:
                None
            Trigger example:
                RG.TRIGGER leave_game user1 game1
        """

        def subcall(user_id, game_id, secret=None):
            execute("HDEL", f"GAME:{game_id}", f"USER:{user_id}")
            execute("HINCRBY", f"GAME:{game_id}", "playercount", -1)

        (
            GB('CommandReader')
            .map(lambda x: subcall(*x[1:]))
            .register(trigger=self.command_name, mode='sync')
        )

当用户被淘汰出游戏或选择离开时,将触发 RedisGears 以促进该过程。这还会自动减少玩家数量,并自动创建通知以确认此操作已完成。

player_actions 函数

在游戏过程中,玩家将发射导弹来消灭其他对手。当玩家发射导弹时,将触发以下子函数

def click(self, game_id, user_id, x, y, o):
        """
        Handle player main key pressed event.
        """
        player = self.games_states[game_id]["players"][user_id]

        self.games_states[game_id]["projectiles"].append({
            "timestamp": self.ts,   # server time
            "x": player["x"] if player['x'] is not None else 9999,
            "y": player["y"] if player['y'] is not None else 9999,
            "orientation": o,       # radians
            "ttl": 2000,            # ms
            "speed": 1,             # px/ms
            "user_id": user_id
        })

        return True

如果导弹击中其他玩家,那么该用户将被淘汰出游戏。以下代码确定玩家是否被导弹击中。

def hit(self, game_id, user_id, enemy_user_id):
        """
        Determines if the projectile has hit a user [user_id]
        Extrapolates projectile position based on when projectile has spawned, and the time now.
        Publishes a hit even if target is hit.
        """
        projectiles = self.games_states[game_id]["projectiles"]
        player = self.games_states[game_id]["players"][enemy_user_id]

        for projectile in projectiles:
            time_diff = self.ts - projectile['timestamp']
            orientation = float(projectile["orientation"])
            x = projectile['x'] + ( math.cos(orientation) * (projectile['speed'] * time_diff) )
            y = projectile['y'] + ( math.sin(orientation) * (projectile['speed'] * time_diff) )


            if abs(player['x'] - x < 50) and abs(player['y'] - y < 50):
                self.games_states[game_id]['players'][projectile['user_id']]['score'] += 1
                execute('PUBLISH', game_id, f"hit;{enemy_user_id}")
                return False
        return False

    def respawn(self, game_id, user_id, x, y):
        player = self.games_states[game_id]["players"][user_id]

        player["respawns"] = player["respawns"] + 1
        player["x"] = x
        player["y"] = y

        return True

Player_actions 流阅读器说明

  1. 客户端连接到 node.js WebSocket 服务器。
  2. 然后用户订阅游戏 ID Redis PubSub 通道。
  3. 从那时起,用户订阅的所有通道消息(在后端)也转发到 WebSocket(到前端)。
  4. MESSAGE_EVENT_HANDLERS 对象存储事件 -> 函数映射,在收到传入消息时,将调用其中一个消息事件函数(请参见下面的函数列表)。

客户端消息事件处理程序列表:

MESSAGE_EVENT_HANDLERS说明
p (pose) 参数:[user_id, x, y, orientation];客户端收到 user_id 位置更新
c(click) 参数:[user_id, x (点击位置), y (点击位置), angle (从玩家位置到点击位置的角度)];客户端收到 user_id 点击事件
r (respawn) 参数:[user_id, x, y];客户端收到 user_id 已重生
l (leave) 参数:[user_id];客户端收到 user_id 已离开游戏
j (join) 参数:[user_id, x, y]客户端收到 user_id 已加入游戏,user_id 已在 (x, y) 位置生成
uid (user id) 参数:[is_valid];客户端收到响应,确认是否可以“登录用户
gid (game id) 参数:[is_valid];客户端收到响应,确认用户是否参与游戏(用户是否已授权)
hit 参数:[user_id];客户端收到消息,确认 user_id 已被击中/客户端可以从渲染中删除 user_id

RediSearch

RediSearch 索引在容器启动时在 redis/start_redis.sh 中注册

已创建 Redis Search 索引

FT.CREATE GAME ON HASH PREFIX 1 GAME: SCHEMA owner TEXT secret TEXT private NUMERIC SORTABLE playercount NUMERIC SORTABLE

FT.CREATE USER ON HASH PREFIX 1 USER: SCHEMA name TEXT settings TEXT secret TEXT

查询以查找游戏

FT.SEARCH "GAME" "(@playercount:[0 1000])" SORTBY playercount DESC LIMIT 0 1

结论:通过非凡的活动-活动克服地理障碍

与任何 Redis 数据库一样,其最受欢迎的资产之一是其以无与伦比的效率在组件之间传输数据的能力。但是,在此应用程序中,如果没有活动-活动提供的出色延迟速度,游戏将无法正常运行。

游戏玩法纯粹是交互式的,来自世界各地的用户会做出反应并向其他玩家发射导弹。从头到尾,RedisGears 根据应用程序设置的时序顺序部署一系列函数。

由于 RedisGears 的效率,这很容易实现,它使活动-活动地理分布式自上而下的街机射击游戏应用程序能够部署。

如果您想详细了解此激动人心的应用程序,您可以在 Redis Launchpad 上查看完整的应用程序。还要确保查看我们为您提供的 所有其他激动人心的应用程序

谁创建了此应用程序?

Jānis Vilks

Jānis 是一位在 Shipping Technology 工作的大数据工程师。

如果您想了解更多关于他的工作以及他参与的项目,请确保 访问他的 GitHub 个人资料