随着游戏行业规模的不断增长,创造独特且动态的用户体验变得更加必要。由于其庞大的粉丝群,企业必须最大限度地优化多人游戏体验,以推动客户获取和留存。然而,公司在尝试扩展多人游戏时面临诸多障碍,而 Redis 可以解决所有这些问题。
个性化互动和高速反应是创造独特用户体验的核心。Redis 为游戏发行商提供了一个强大的数据库,可以支持低延迟的游戏用例。最近,一个 Launchpad 应用构建了一个独特的应用程序,由于 Redis 在数据传输方面无与伦比的速度,该应用只能通过 Redis 部署。
这至关重要,因为参与者来自世界各地。玩家之间的互动必须实时执行以支持游戏玩法,要求延迟低于一毫秒。
让我们深入了解这是如何实现的。在此之前,请务必浏览一下我们在 Launchpad 上提供的各种精彩应用。
你将使用 Redis 构建一个实时地理分布式多人俯视街机射击游戏。应用程序的核心是 Redis,因为它是一个高效的实时数据库,使你能够存储和分发数据。
随着我们按时间顺序推进每个阶段,我们将详细解释重要的术语以及每个 Redis 函数。
让我们确定构建此游戏所需的各种组件。该应用程序包含 3 个主要组件
这款多人游戏的主要思想是保持实时性和地理分布式。这意味着集群中的所有实例都应该得到更新,以避免不同步。现在让我们来看看架构。
现在让我们看看架构流程。
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
有三个 RedisGears 函数,每个函数都有自己的子函数集
用户开始游戏后,首先会使用 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
)
用户创建新游戏后,其他玩家将加入,然后大家就可以一起玩了。
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 创建新游戏。
用户创建新游戏后,其他玩家将加入,然后大家就可以一起玩了。
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 来创建新用户
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)。
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 来促成此过程。这也会自动减少玩家计数,并自动创建通知以确认此操作已完成。
在游戏过程中,玩家会发射导弹淘汰其他竞争对手。当玩家发射导弹时,会触发以下子函数
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
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 索引在容器启动时注册于 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 个人资料。