dot Redis 8 已发布,并且是开源的

了解更多

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

随着游戏行业规模的不断增长,创造独特且动态的用户体验变得更加必要。由于其庞大的粉丝群,企业必须最大限度地优化多人游戏体验,以推动客户获取和留存。然而,公司在尝试扩展多人游戏时面临诸多障碍,而 Redis 可以解决所有这些问题。 

个性化互动和高速反应是创造独特用户体验的核心。Redis 为游戏发行商提供了一个强大的数据库,可以支持低延迟的游戏用例。最近,一个 Launchpad 应用构建了一个独特的应用程序,由于 Redis 在数据传输方面无与伦比的速度,该应用只能通过 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 引擎进行渲染和物理模拟 
  • NodeJS 后端WebSocket:用户与 Redis 之间的通信接口。主要用于构建游戏逻辑和启用 Redis API

3. 架构

这款多人游戏的主要思想是保持实时性和地理分布式。这意味着集群中的所有实例都应该得到更新,以避免不同步。现在让我们来看看架构。 

  • NodeJS 构成了玩家与 Redis 数据库之间的通信接口。
  • JavaScript 和 Phaser 3 库用于渲染游戏状态,并通过 NodeJS 将用户输入传递给 Redis
  • 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 访问在线 WebServer

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 函数。这也会增加 game_instance 的玩家计数 (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 Stream Reader 说明

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

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

MESSAGE_EVENT_HANDLERS说明
p (姿势) 参数: [user_id, x, y, 方向]; 客户端收到 user_id 位置更新
c(点击) 参数: [user_id, x (点击位置的 x 坐标), y (点击位置的 y 坐标), 角度 (从玩家位置到点击位置的角度)]; 客户端收到 user_id 点击事件
r (复活) 参数: [user_id, x, y]; 客户端收到 user_id 已复活
l (离开) 参数: [user_id]; 客户端收到 user_id 已离开游戏
j (加入) 参数: [user_id, x, y]客户端收到 user_id 已加入游戏,并且 user_id 已在 (x, y) 位置生成
uid (用户 ID) 参数: [是否有效];客户端收到是否可以查找“登录用户”的响应
gid (游戏 ID) 参数: [是否有效];客户端收到用户是否是游戏的一部分(用户是否已授权)
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 数据库一样,其最受欢迎的优势之一是能够以无与伦比的效率在组件之间传输数据。然而在此应用中,如果没有 Active-Active 提供的卓越低延迟速度,游戏将根本无法运行。 

游戏玩法是纯粹的互动,来自世界各地的用户反应并向其他玩家发射导弹。从开始到结束,RedisGears 根据应用程序设置的时间顺序部署一系列函数。 

得益于 RedisGears 的高效性,部署一个活跃-活跃的地理分布式俯视街机射击应用变得轻松。 

如果你想了解更多关于这个精彩应用的信息,可以在 Redis Launchpad 上查看完整的应用。另外,也一定要看看我们为你提供的所有其他精彩应用。

谁创建了这个应用程序?  

Jānis Vilks

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

如果你想了解更多关于他的工作和参与的项目,请务必访问他的 GitHub 个人资料。