HomeArchiveFeedShelf

将 Godot 无头服务器与 Nakama 集成

介绍

"Nakama",即仲間,在日语中意为"朋友",这暗示了它的多人游戏能力。Nakama 是一个开源的 BaaS 服务器软件。您可以在自己的服务器上部署它,并立即获得免费的 BaaS 框架。此外,Nakama 提供了多种客户端 SDK,包括 Unity、Unreal、Godot、Cocos2d、Defold 等。使用 Nakama 可以节省您在多人游戏开发中的时间。

不同的多人游戏有不同的游戏玩法。Nakama 允许开发者通过其插件系统编写服务器代码。目前,它提供 Lua 运行时、Go 运行时和 JavaScript 运行时。您可以编写插件或模块,Nakama 会在运行时加载它们。例如,您可以在 Nakama 每个服务器帧调用的 matchLoop 回调中编写简单的服务器逻辑。插件系统足以满足许多轻量级场景,如回合制卡牌游戏、简单的 2D 游戏、聊天室等。那么,对于复杂的服务器逻辑,如物理引擎呢?将 Nakama 与游戏引擎导出的专用服务器结合使用又如何呢?

好吧,Nakama 允许您这样做。

对于复杂的游戏玩法,您希望物理引擎在服务器端运行(例如 Unity 无头实例)。Nakama 可以通过一个编排层管理这些无头实例,并可用于匹配、在比赛完成时移动玩家以及报告比赛结果。

然而,文档没有详细涵盖这个主题。没有示例或指南说明如何确切地做到这一点。

本文讨论了如何将 Godot 引擎导出的专用服务器与 Nakama 集成。所有服务器代码均使用 TypeScript 编写。本文不会讨论导出专用服务器的细节(您可以在 Godot 文档中获取此类信息)、多人游戏开发的细节,或如何将您的 Godot 服务器容器化或如何设置 Nakama 服务器的细节。这些主题超出了本文的范围。如果您使用其他游戏引擎,本文也应该有所帮助。

通信概述

我们有两种类型的 Nakama 客户端,玩家设备上的客户端和服务器上的客户端。专用服务器本质上也是 Nakama 的一个客户端。所有通信通过四种方式实现:

  1. Nakama 客户端与 Nakama 服务器之间的 RPC 调用;
  2. Nakama 客户端与 Nakama 服务器之间的比赛状态消息广播;
  3. Godot 客户端与 Godot 服务器之间的 RPC 调用;
  4. Nakama 客户端与 Nakama 服务器之间的 Nakama HTTP API 调用。

创建比赛

这是通过调用 Nakama RPC 而不是直接的客户端 HTTP API 调用来完成的,因为我们需要将初始比赛数据插入存储中,例如房间状态(游戏服务器是否已经启动?我们是否在等待玩家准备好?)、容器主机和端口、玩家定义的房间名称等。我们插入数据,然后通过服务器运行时提供的 API 创建比赛,然后将比赛 ID 返回给客户端,以便玩家可以加入比赛。

用户 ID 是默认系统用户 ID 00000000-0000-0000-0000-000000000000,因为这些对象永远无法被任何玩家修改。

'room' 是模块名称,您可以注册不同的模块。如上所述,如何设置 Nakama 服务器的细节不包括在本文中。如果您不熟悉这一点,您应该阅读 Nakama 的文档。

// 服务器代码
export const createMatch: nkruntime.RpcFunction = function (
  ctx: nkruntime.Context,
  logger: nkruntime.Logger,
  nk: nkruntime.Nakama,
  payload: string
) {
  try {
    const match_name = JSON.parse(payload).match_name
    const match_id = nk.matchCreate('room')

    const newMatchInfo: TrodMatchInfo = {
      host: '',
      port: '',
      matchId: match_id,
      containerId: '',
      status: 'preparing',
      kickIds: [],
      roomOwnerId: ctx.userId,
      roomName: match_name,
    }

    nk.storageWrite([
      {
        collection: TrodMatchInfoCollectionKey,
        key: match_id,
        userId: SystemUserId,
        value: newMatchInfo,
      },
    ])

    logger.info('新比赛信息已写入')

    return JSON.stringify({ match_id, match_name })
  } catch (err) {
    logger.error(err.message)
    throw err
  }
}

使用比赛循环作为房间处理程序

加入比赛后,客户端和服务器可以通过比赛状态消息进行通信。

服务器代码

// 服务器代码
export const matchLoop = function (
  ctx: nkruntime.Context,
  logger: nkruntime.Logger,
  nk: nkruntime.Nakama,
  dispatcher: nkruntime.MatchDispatcher,
  tick: number,
  state: nkruntime.MatchState,
  messages: nkruntime.MatchMessage[]
): { state: nkruntime.MatchState } | null {
  logger.debug('房间比赛循环执行')

  messages.forEach(function (message) {
    logger.info('从 %v 收到 %v', message.data, message.sender.userId)

    if (message.opCode === MatchOpCode.ReadyStateUpdatedYes) {
      // ...
    }
    // ...
  })
  // ...
}

客户端代码

# 客户端代码
# ...某处
Network.socket.connect("received_match_state", self, "_on_match_state")
# ...

func _on_match_state(match_state: NakamaRTAPI.MatchData):
    if match_state.op_code == GameData.OpCode.ReadyStateUpdatedYes:
    pass
    # ...处理比赛状态消息

操作码必须在客户端和服务器上定义,值必须相同。以下是我在我的情况下定义的内容:

// 服务器代码
export enum MatchOpCode {
  StartGameServer = 0,
  GameServerInited,
  StartGameServerFailed,
  ReadyStateUpdatedYes,
  ReadyStateUpdatedNo,
  ReadyStatesBroadcast,
  Kicked,
  UsernameUpdated,
  RoomNameUpdated,
  GameServerStarting,
}

例如,当玩家在客户端点击准备按钮时,我们会发出比赛状态消息。

func _on_ready_button_clicked():
    AudioManager.play_click_sound()
    imready = not imready
    var op_code = GameData.OpCode.ReadyStateUpdatedYes if imready else GameData.OpCode.ReadyStateUpdatedNo
    yield(Network.socket.send_match_state_async(match_id, op_code, JSON.print({})), "completed")

然后服务器广播给其他客户端

// server code inside matchLoop
if (message.opCode === MatchOpCode.ReadyStateUpdatedYes) {
  state.readyStates[message.sender.userId] = true
  readyStatesUpdated = true
} else if (message.opCode === MatchOpCode.ReadyStateUpdatedNo) {
  state.readyStates[message.sender.userId] = false
  readyStatesUpdated = true
}
//...
if (readyStatesUpdated) {
  dispatcher.broadcastMessage(
    message.opCode,
    message.data,
    null,
    message.sender
  )
}

然后客户端可以在本地更新准备状态或在 UI 上显示 "我准备好了"。

当房主(在我的情况下,只有房主可以开始游戏)点击开始按钮时,我们也会像上面的示例一样广播消息。更重要的是,我们需要创建并启动游戏服务器容器。

启动服务器容器

我假设你已经将游戏服务器容器化,我担心运行游戏服务器的唯一方法是通过 Docker HTTP API。因为 Nakama JavaScript 运行时不支持运行 bash 命令。由于 Nakama 旨在可扩展,通常在容器内运行,因此可能永远不会支持。

查找可用端口

我们实际上不知道一个端口是否可以使用,因为 TypeScript 运行时的限制。因此,我将使用的端口存储在存储中。每次创建容器时,我们只使用未存储且未被服务器上其他服务占用的端口。在启动 Nakama 之前,我们分配这些端口(下面的 retainedPorts)。

// server code
const canUsePort = function (
  port: string,
  logger: nkruntime.Logger,
  nk: nkruntime.Nakama
): boolean {
  try {
    if (retainedPorts.indexOf(port) !== -1) {
      return false
    }

    const objects = nk.storageRead([
      { collection: TrodPortsKey, key: port, userId: SystemUserId },
    ])

    if (objects.length === 0) {
      return true
    }

    return false
  } catch (err) {
    logger.error(err.message)
    return false
  }
}

使用上面的 canUsePort 获取可用端口。

// server code inside startGameServer function
let port = getRandomPort()
let triedTimes = 0
const tryingCount = 20

while (!canUsePort(port, logger, nk)) {
  port = getRandomPort()
  triedTimes++

  if (triedTimes > tryingCount) {
    throw new Error('cannot allocate usable ports')
  }
}

这个解决方案并不完美,我们可能在启动 Nakama 后启动其他服务。意外地,我们可能会因为端口被占用而无法创建容器,玩家将看到错误消息。

将参数传递给 Godot 服务器

容器创建 API 的命令行可以用于将参数从 Nakama 传递到 Godot 服务器。这些参数可以通过游戏引擎提供的 API 在 Godot 服务器中检索,例如 Godot 中的 OS.get_cmdline_args()。在我的情况下,我传递以下值:

// server code inside startGameServer function
const cmd = [
  `--server-port=${port}`,
  `--match-id=${matchId}`,
  `--max-players=${maxPlayers}`,
  `--imposters-count=${maxImposters}`,
]
const containerId = createContainer(
  ImageName,
  port,
  cmd,
  `game-server-${matchId}-${port}`,
  options
)

通过 nk 对象进行 HTTP 调用

// server code
export const createContainer = function (
  image: string,
  port: string,
  cmd: string[],
  name: string,
  options: InternalCallOptions
) {
  const { nk, logger, ctx } = options

  const dockerAPIUrl = `${DockerAPI.scheme}${ctx.env['DOCKER_HOST']}:${DockerAPI.port}`
  const url = `${dockerAPIUrl}/v1.41/containers/create?name=${name}`
  const headers = { 'Content-Type': 'application/json' }
  const body = JSON.stringify({
    Image: image,
    Tty: true,
    ExposedPorts: {
      [`${port}/tcp`]: {},
      [`${port}/udp`]: {},
    },
    Cmd: cmd,
    HostConfig: {
      PortBindings: {
        [`${port}/tcp`]: [
          {
            HostIP: '0.0.0.0',
            HostPort: port,
          },
        ],
        [`${port}/udp`]: [
          {
            HostIP: '0.0.0.0',
            HostPort: port,
          },
        ],
      },
    },
  })

  try {
    const response = nk.httpRequest(url, 'post', headers, body)
    if (response.code === 201) {
      const dockerCreateResult: { Id: string; Warnings: Array<any> } =
        JSON.parse(response.body)
      logger.info('container %q created', dockerCreateResult.Id)
      return dockerCreateResult.Id
    }

    throw new Error(JSON.stringify(response.body))
  } catch (err) {
    logger.error('error occurred while creating container, %q', err.message)
    throw err
  }
}

等待服务器初始化

Godot 服务器可能有耗时的启动过程,例如加载关卡资源。Godot 服务器在准备好让玩家加入时会通知 Nakama。之后玩家才能连接到它。

# client code in godot server
func _on_server_init_completed():
    print("_on_server_init_completed")
    if GameData.server_match_id == "":
        push_error("server inited but no match id")
        return

    var result: NakamaAPI.ApiRpc = yield(Network.socket.rpc_async(
        "game_server_inited",
        JSON.print({ "match_id": GameData.server_match_id })
    ), "completed")

    print("game_server_inited rpc called")
    if result.is_exception():
        push_error(result.get_exception().message)

我们在存储中更改比赛信息的状态,然后在 matchLoop 中广播消息,这是我们唯一可以发出比赛状态消息的地方。我们通过存储将信息从 RPC 函数传递到 matchLoop。许多功能必须以这种方式实现,包括踢出玩家。> 注意:在使用 Nakama 的 JavaScript 运行时进行开发时,您必须了解 Goja 和 Nakama 带来的限制。例如,没有事件循环。JavaScript 运行时由 Nakama 进行池化,因此无法使用全局变量。

// 服务器代码
export const gameServerInited: nkruntime.RpcFunction = function (
  ctx: nkruntime.Context,
  logger: nkruntime.Logger,
  nk: nkruntime.Nakama,
  payload: string
) {
  try {
    if (!isPrivateIP(ctx.clientIp)) {
      throw new Error('无头服务器只能由 Nakama 运行')
    }

    const obj = JSON.parse(payload)
    const matchId = obj.match_id

    const objects: nkruntime.StorageObject[] = nk.storageRead([
      {
        collection: TrodMatchInfoCollectionKey,
        key: matchId,
        userId: SystemUserId,
      },
    ])

    if (objects.length !== 1) {
      throw new Error('读取的比赛信息结果不正确')
    }

    const value = objects[0].value as TrodMatchInfo
    value.status = 'inited'

    nk.storageWrite([
      {
        collection: TrodMatchInfoCollectionKey,
        key: matchId,
        userId: SystemUserId,
        value,
      },
    ])

    logger.info('游戏服务器 %q 已初始化', value.containerId)
  } catch (err) {
    logger.error(err.message)
    throw err
  }
}

在每个服务器帧的 matchLoop 中检查存储中的比赛信息状态。如果需要,告诉玩家的客户端连接到 Godot 服务器。

// matchLoop 中的服务器代码
// 从存储中获取比赛信息作为值...
if (value.status === 'inited' && value.host && value.port) {
  logger.info(JSON.stringify(value))
  dispatcher.broadcastMessage(
    MatchOpCode.GameServerInited,
    JSON.stringify({
      host: value.host,
      port: Number(value.port),
    })
  )

  value.status = 'broadcasted'
  nk.storageWrite([
    {
      collection: TrodMatchInfoCollectionKey,
      userId: SystemUserId,
      key: ctx.matchId,
      value,
    },
  ])
  logger.info('游戏服务器初始化广播完成')
}

在收到服务器准备就绪的通知后连接到 Godot 服务器。

# 客户端代码
# ...
elif match_state.op_code == GameData.OpCode.GameServerInited:
  var result := JSON.parse(match_state.data)
  if result.error_string == "":
    print(result.result)
    var host: String = result.result["host"]
    var port: int = result.result["port"]

    Network.room.connect_to_game_server(host, port)
  else:
    GameData.show_toast(result.error_string)
# ...

验证身份

当用户登录时,我们使用客户端 HTTP API 将其令牌存储到存储中。当玩家连接到服务器时,服务器通过玩家的令牌调用 RPC 到 Nakama,Nakama 在存储中检查令牌并将结果返回给服务器。具体步骤如下:

  1. Godot 客户端连接到 Godot 服务器
  2. Godot 服务器通过 Godot RPC 调用请求用户令牌
  3. 客户端回复
  4. Godot 通过 Nakama RPC 调用验证令牌
# Godot 服务器中的客户端代码
func _verify_peer_id(id: int) -> bool:
    print("验证中 ", id)
    if not _cached_player_session.has(id):
        print("没有缓存的会话,验证失败")
        return false

    var result: NakamaAPI.ApiRpc = yield(Network.socket.rpc_async("validate_token", JSON.print(({
        "user_id": _cached_player_session[id]["user_id"],
        "token": _cached_player_session[id]["token"],
    }))), "completed")

    if result.is_exception():
        print("令牌不匹配,验证失败")
        return false

    return true
  1. 如果成功,将角色添加到游戏世界
  2. 如果失败或超时,与客户端对等体断开连接。在此期间,不允许此客户端与服务器之间的任何其他 RPC

需要注意的一点是,我们还需要验证调用者是否为 Godot 服务器。我们不允许恶意的 Godot 客户端调用仅供 Godot 服务器使用的 RPC。如何做到这一点?我们只允许从 LAN 调用此 RPC,通过检查 ctx.clientIp

// 服务器代码
export const validateToken: nkruntime.RpcFunction = function (
  ctx: nkruntime.Context,
  logger: nkruntime.Logger,
  nk: nkruntime.Nakama,
  payload: string
) {
  try {
    if (!isPrivateIP(ctx.clientIp)) {
      throw new Error('无头服务器只能由 Nakama 运行')
    }

    const obj = JSON.parse(payload)
    const userId = obj.user_id
    const token = obj.token

    if (!userId || !token) {
      throw new Error('验证失败')
    }

    const objects = nk.storageRead([
      { collection: TrodTokensKey, key: userId, userId },
    ])

    if (objects.length !== 1) {
      throw new Error('验证失败')
    }

    if (objects[0].value['value'] !== token) {
      throw new Error('验证失败')
    }

    logger.info('验证用户 %q', userId)
    return JSON.stringify({
      success: true,
    })
  } catch (err) {
    logger.error(err)
    throw err
  }
}

游戏结束并收集游戏结果

在玩家加入 Nakama 后,所有通信应在 Godot 客户端和 Godot 服务器之间进行。无论是否使用 Nakama,这部分都不应有差异。

当游戏产生结果时,Godot 服务器可以通过 Nakama RPC 调用通知 Nakama,正如我多次向您展示的那样。Nakama 可以根据您的用例将游戏记录保存到存储中。

最重要的是,在适当的时候,通过 Docker HTTP API 移除容器并从存储中移除端口。

// 服务器代码
export const removeContainer = function (
  id: string,
  options: InternalCallOptions
) {
  const { nk, logger, ctx } = options

  const dockerAPIUrl = `${DockerAPI.scheme}${ctx.env['DOCKER_HOST']}:${DockerAPI.port}`
  const url = `${dockerAPIUrl}/v1.41/containers/${id}?force=true`
  const headers = { 'Content-Type': 'application/json' }

  try {
    logger.info(`removeContainer ${id}`)
    const response = nk.httpRequest(url, 'delete', headers)

    if (response.code !== 204 && response.code !== 404) {
      const body: { message: string } = JSON.parse(response.body)
      throw new Error(body.message)
    }

    logger.info('container %q removed', id)
  } catch (err) {
    logger.error('error occurred while removing container, %q', err.message)
    throw err
  }
}

注意:Nakama 从 3.15.0 版本开始支持 HTTP 删除方法

相关链接

https://heroiclabs.com

https://heroiclabs.com/docs/nakama/concepts/multiplayer/

https://heroiclabs.com/docs/nakama/client-libraries/godot/index.html

https://heroiclabs.com/docs/nakama/server-framework/typescript-runtime/

https://forum.heroiclabs.com/t/documentation-for-headless-instance-orchestration-layer/2638

https://forum.heroiclabs.com/t/multiplayer-game-with-physics/270/2

https://github.com/heroiclabs/nakama-docs/issues/161

https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html

https://stackoverflow.com/questions/13969655/how-do-you-check-whether-the-given-ip-is-internal-or-not

@2023-01-16 21:18