权威多人游戏 #

除了中继多人游戏之外,Nakama还支持服务器权威的多人模式,让您可以自由灵活地决定最适合您游戏的方法。

在服务器权威的多人游戏中,服务器验证并广播所有游戏数据交换。在这个模式中,您可以为游戏规则编写自定义服务器运行时代码(即多少玩家可以加入,是否可以加入进行中的比赛等等),交由 Nakama 强制执行。

没有强有力的决定因素限制采取中继还是权威的方法,这是基于所需游规则的设计决定。权威多人游戏更适合于依赖于游戏服务器管理中央状态的游戏、每场比赛玩家数量更高的游戏,以及您不想信任游戏客户端,而是希望对游戏规则进行更严格的控制,以尽量减少作弊等游戏。

为了支持需要数据消息来改变服务器上维护的状态的多人游戏设计,权威多人引擎使您能够以固定的节拍率运行自定义匹配逻辑。消息可以验证,状态更改可以广播到连接的对等方。这样您就能构建:

  1. 异步实时权威多人游戏:快节奏实时多人游戏。消息被发送到服务器,服务器计算环境和玩家的变化,数据被广播到相关的对等方。这通常需要较高的数据接收和计算率才能让游戏流畅。
  2. 主动回合制多人游戏:例子有Stormbound或Clash Royale,两个或多个玩家连接在一起,进行快速回合比赛。玩家需要立即响应回合。服务器接收输入,对其验证,然后广播给玩家。预期的节拍率非常低,因为发送和接收消息的速率很低。
  3. 被动回合制多人游戏:一个很好的例子是手机上的Words With Friends,其中游戏可以持续数小时至数周。服务器接收并验证输入,将其存储在数据库中,并在关闭服务器循环之前将变化广播到任何连接的对等方,直到下一个游戏序列为止。
  4. 会话制多人游戏:对于您希望物理运行在服务器端的复杂游戏(例如Unity无头实例)。Nakama可以通过编排层管理这些无头实例,并可以用于匹配、在比赛结束时移动玩家以及报告比赛结果。

注意,在构建服务器权威的多人游戏时,没有现成或通用的场景。您必须通过编写自定义运行时代码来定义游戏玩法——每场比赛有多少玩家、是否允许中途加入、比赛如何结束等等。

在决定实施权威多人游戏功能时,您需要熟悉几个概念。

比赛处理程序 #

匹配处理程序表示所有服务器端功能,这些功能组合在一起以处理游戏输入并对其进行操作。将其视为实例化比赛的“蓝图”。比赛处理程序为比赛建立游戏规则,因为一场比赛可能有多种玩法模式(例如夺旗、死亡竞赛、完全免费等),您可能需要多个比赛处理程序 — 每个游戏模式一个。

任何比赛处理程序都需要使用7个函数。只有Nakama调用这些函数,客户端或其他运行时代码无法直接调用。

  • Match Init
  • Match Join Attempt
  • Match Join
  • Match Leave
  • Match Loop
  • Match Terminate
  • Match Signal

请参阅Match HandlerMatch Runtime函数参考了解详情。

这些函数定义了给定比赛的状态和生命周期,任何一个Nakama节点都可以根据硬件和玩家数量运行数千场比赛。给定比赛的比赛处理程序和状态存储在特定的Nakama实例上,该实例成为该比赛的_主机_。

单个节点负责实现该功能,以确保访问和更新状态的最高水准的一致性,并避免协调分布式状态的潜在延迟。

Nakama Enterprise Only

对比赛状态进行复制,因此群集中的所有节点都可以立即访问比赛列表和比赛参与者的详细信息。节点之间的平衡自动完成,在最合适的节点上但从不在已进入关闭状态的节点上创建新的比赛。

从Nakama开源迁移到Nakama Enterprise是无缝操作,不需要更改客户端或服务器端的任何代码。状态复制、集群间数据交换和消息路由对比赛处理程序都是透明的,如同在单个实例集群上运行。

每一场运行中的比赛都是独立的,不能与任何其他比赛通信或影响任何其他比赛。比赛通信通过客户端发送比赛数据而完成。Nakama在内部管理每场比赛的CPU调度和内存分配,确保在集群中的单个实例或所有实例上公平和平衡地分配负载。

比赛信号功能可以在一定限度上实现这一点:在比赛中为给定的玩家保留一个位置,或者将玩家/数据切换到另一场比赛。应当仅将此用作极少数异常而不是标准做法。

即使没有连接的或活动的状态,比赛处理程序也可以运行。在比赛运行时逻辑中,必须考虑空闲或空的比赛的处理。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const matchInit = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, params: {[key: string]: string}): {state: nkruntime.MatchState, tickRate: number, label: string} {
  return {
    state: { presences: {}, emptyTicks: 0 },
    tickRate: 1, // 1 tick per second = 1 MatchLoop func invocations per second
    label: ''
  };
};

const matchJoin = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, presences: nkruntime.Presence[]) : { state: nkruntime.MatchState } | null {
  presences.forEach(function (p) { 
    state.presences[p.sessionId] = p;
  });

  return {
    state
  };
}

const matchLeave = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, presences: nkruntime.Presence[]) : { state: nkruntime.MatchState } | null {
  presences.forEach(function (p) {
    delete(state.presences[p.sessionId]);
  });

  return {
    state
  };
}

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 {
  // If we have no presences in the match according to the match state, increment the empty ticks count
  if (state.presences.length === 0) {
    state.emptyTicks++;
  }

  // If the match has been empty for more than 100 ticks, end the match by returning null
  if (state.emptyTicks > 100) {
    return null;
  }

  return {
    state
  };
}

比赛无法从外部停止,只有在生命周期函数之一返回nil状态时才会结束。

必须注册比赛处理程序才能使用它。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let InitModule: nkruntime.InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  initializer.registerMatch('lobby', {
    matchInit,
    matchJoinAttempt,
    matchJoin,
    matchLeave,
    matchLoop,
    matchSignal,
    matchTerminate
  });
}

节拍率 #

虽然大多数比赛处理程序函数是由于用户行为或内部服务器进程而调用的,但即使没有等待处理的输入,服务器也会定期调用比赛循环函数。该逻辑能够根据需要推进游戏状态,还可以验证输入并踢出非活动玩家。

您的节拍率表示服务器调用比赛循环函数的期望频率(每秒),即比赛更新的频率。例如10的节拍率表示每秒10次表示循环。

服务器总是试图保持均匀的_起点_间距。使用10的节拍率例子,每个循环将在最后一个_启动_后启动100ms。最佳实践是在循环之间留出尽可能多的时间,允许不规则调用的非循环函数在循环之间的间隙中执行。

注意,您的游戏循环逻辑和配置的节拍率不会导致服务器落后,即每个循环必须能够在下一个循环计划之前完成(在本示例中少于100ms)。如果比赛循环确实落后,服务器将首先尝试尽快启动下一个循环来“赶上”。如果太多的循环落后(通常是循环逻辑设计不佳的结果),服务器将结束比赛。

可以对节拍率进行配置,常见的频率范围从回合制游戏的每秒一次到快节奏游戏的每秒几十次。选择节拍率时的一些注意事项:

  • 应选择提供可接受的玩家体验(无延迟等)的最低可能节拍率。
  • 更高的节拍率意味着比赛循环之间的差距更小,玩家“感觉”到反应更灵敏
  • 应始终以低速率开始,并以小增量(1-2)增加,直到获得所需的体验
  • 节拍率越低,每个CPU核可以并发运行的比赛数越多
  • 每个比赛处理程序可以有不同的节拍率,例如对于不同的游戏模式

比赛状态 #

Nakama公开了一个内存区域,供权威比赛在比赛期间使用,以存储其状态。这可以包括在比赛过程中跟踪比赛数据和客户行为所需的任何信息。

每场比赛维护自己单独的隔离状态。这种状态是基于验证后的用户输入循环应用于初始状态的连续转换的结果。请注意,这些状态不会被自动发送到客户端。您必须广播合适的操作代码和数据,在自己的比赛处理程序逻辑内手动完成此任务。

发送数据消息 #

在中继多人游戏中发送消息不同,在权威比赛中,接收到的消息不会被自动重播到所有其他连接的客户端。比赛逻辑必须显式调用broadcast函数来发送消息。

每条消息包含操作码以及有效负载。

Server
1
2
3
4
5
6
7
8
const matchStartOpcode = 7

const matchStartData = {
  started: true,
  roundTimer: 100
};

dispatcher.broadcastMessage(matchStartOpcode, json.stringify(matchStartData), null, null, true);

每条数据消息的二进制内容(有效负载)应当尽可能地小,不超过1500字节的最大传输单位 (MTU)。通常使用的是 JSON,最好使用 Protocol BuffersFlatBuffers 等紧凑二进制格式。

在无法进一步减少消息大小和/或频率的情况下,最好优先发送更少的消息。例如,1 条每秒 1000 字节的消息优于 5 条每秒 200 字节消息。

客户端消息由服务器按照接收的顺序进行缓冲,当下一个比赛循环运行时,将作为一批发出。最佳做法是尝试对服务器维护不超过每次 1 条消息、每个状态,并且从服务器到每个状态都相同。

如果您配置的节拍率的消息太多,服务器可能会丢弃一些消息,并记录一个错误。为避免连续丢弃消息,请尝试:

  • 降低从客户端到服务器的消息发送速率
  • 增加节拍率,更频繁地消耗消息
  • 增加缓冲大小

操作码 #

操作码是发送的消息类型的数值标识符。操作码可以让用户在对消息解码之前了解消息的目的和内容。

可以将其用于定义游戏中属于某些用户操作的命令,例如:

  • 初始状态同步
  • 就绪状态
  • Ping / Pong
  • 游戏状态更新
  • 表情

使用按位操作对数据进行编码,还可以在操作码字段中包含其他信息。

有关示例实现,请参阅 Fish Game 教程

Broadcast与BroadcastDeferred #

借助传入比赛处理程序函数dispatcher类型,可将来自比赛的数据发送到_此比赛_中的一个或多个状态。

可通过两种方法发送数据:BroadcastBroadcastDeferred

Broadcast 对于每个函数可以调用多次,但最佳做法是将传出数据限制为每个循环中每个状态一条消息。仅当您需要向每个状态发送不同的消息时,才建议在每个循环中使用多个调用。

使用BroadcastBroadcastDeferred只有一个区别 — 前者在被调用时将数据立即发送出去,后者在循环结束前不发送数据。

请注意,如果发送/广播的数据太多,并且连接到客户端的下行速率低于比赛的数据发送速率,将会填满客户端连接的发送缓冲区队列,并迫使服务器断开连接以防止内存溢出。

接收数据消息 #

服务器按照处理来自客户端的数据消息的顺序交付数据。客户端可以为传入的比赛数据消息添加回调。应当在他们加入退出比赛前完成此任务。

Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Use whatever decoder for your message contents.
var enc = System.Text.Encoding.UTF8;
socket.ReceivedMatchState += newState =>
{
    var content = enc.GetString(newState.State);

    switch (newState.OpCode)
    {
        case 101:
            Console.WriteLine("A custom opcode.");
            break;
        default:
            Console.WriteLine("User '{0}'' sent '{1}'", newState.UserPresence.Username, content);
    }
};

比赛标签 #

使用比赛标签突出显示比赛想要向Nakama和玩家群宣传的内容。这可以包括游戏模式、游戏是开放还是关闭的、玩家人数、比赛状态等细节。

比赛标签可以是简单字符串JSON值。可通过Match Listing API将其用于筛选比赛。

记住,对于带有JSON值的比赛标签,只能使用**搜索查询。对于带有简单字符串值**(例如"team-deathmatch")的比赛标签,只能使用label参数,执行精确匹配

索引查询对于列出比赛中更有效、更实用,因此,建议使用JSON比赛标签。需要注意的其他最佳做法:

  • 比赛标签的大小应限制为2kb
  • 尽量避免频繁地更新标签(即每节拍不超过一次)
  • 标签的更新成批进行,产生时间点视图

管理比赛 #

创建比赛 #

手动或通过匹配程序均可在服务器上创建权威比赛。

手动 #

您可以使用一个RPC函数,此函数将一些用户ID提交给服务器并创建比赛。

将创建一个比赛ID,该ID可以通过应用内通知或推送消息(或两者)发送给玩家。当您想要手动创建比赛并与特定用户竞争时,这种方法非常实用。

Server
1
2
3
4
function rpcCreateMatch(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  var matchId = nk.matchCreate('pingpong', payload);
  return JSON.stringify({ matchId });
}

匹配程序 #

使用匹配程序查找对手,并使用服务器上匹配程序匹配的回调创建权威比赛,并返回比赛ID。这使用客户端上的帮助匹配程序API。

客户端将正常接收带有比赛ID的匹配程序回调。

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function matchmakerMatched(context: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, matches: nkruntime.MatchmakerResult[]): string {
  matches.forEach(function (match) {
    logger.info("Matched user '%s' named '%s'", match.presence.userId, match.presence.username);

    Object.keys(match.properties).forEach(function (key) {
      logger.info("Matched on '%s' value '%v'", key, match.properties[key])
    });
  });

  try {
    const matchId = nk.matchCreate("pingpong", { invited: matches });
    return matchId;
  } catch (err) {
    logger.error(err);
    throw (err);
  }
}

// ...

initializer.registerMatchmakerMatched(matchmakerMatched);

如果比赛应按中继多人游戏继续,匹配程序匹配的挂钩必须返回比赛ID或nil

传递到比赛创建函数的字符串取决于使用的服务器运行时语言:

  • 对于_Lua_,这应当是模块名称。在本例中,这是名称为pingpong.lua的文件,因此比赛模块是pingpong
  • 对于_Go_和_TypeScript_,这必须是比赛处理程序函数的注册名称。在上例中,在InitModule函数中的调用initializer.RegisterMatch时,我们将其注册为pingpong

加入比赛 #

即使经过匹配程序的匹配,玩家也不在比赛中,直到他们加入为止。这使得球员可以选择退出他们决定不参加的比赛。

这可以由客户端以与中继多人游戏相同的方式完成。此处是一个如何完成此任务的完整示例。

退出比赛 #

用户可随时退出比赛。这可以由客户端以与中继多人游戏相同的方式完成。此处是一个如何完成此任务的完整示例。

在退出比赛时,将调用LeaveMatch生命周期比赛处理程序函数,并添加退出的原因:玩家是离开比赛还是断开连接。在断开连接的情况下,您可以决定暂时保留他们的席位。

记住,与中继比赛不同的是,即使所有玩家都退出了,权威比赛也不结束。这是正常的,旨在允许您支持这样的用例:允许玩家在游戏世界继续前进时暂时断开连接。

权威比赛处理程序仅在任何回调返回nil状态时才停止。你可以选择在比赛期间的任何时间点这样做,无论是否还有玩家连接到比赛。

比赛迁移 #

Nakama Enterprise Only

当比赛因Nakama实例的正常关闭而终止时,可用此宽限期将玩家迁移到新的比赛。

首先您需要为他们创建新的比赛,或找到他们可以通过比赛列表加入的现有比赛。然后向受影响的客户端发送带有此新比赛信息的调度程序广播。最后,您可以等待他们退出目前的比赛,必要时,可以强行将其踢出比赛:

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Define an op code for sending a new match id to remaining presences
const newMatchOpCode = 999;

const matchTerminate = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, graceSeconds: number) : { state: nkruntime.MatchState} | null {
  logger.debug(`Match will terminate in ${graceSeconds} seconds.`);

  let matchId = null;
  
  // Find an existing match for the remaining connected presences to join
  const limit = 1;
  const authoritative = true;
  const label = "";
  const minSize = 2;
  const maxSize = 4;
  const query = "*";
  const availableMatches = nk.matchList(limit, authoritative, label, minSize, maxSize, query);
  
  if (availableMatches.length > 0) {
    matchId = availableMatches[0].matchId;
  } else {
    // No available matches, create a new match instead
    matchId = nk.matchCreate("match", { invited: state.presences });
  }

  // Broadcast the new match id to all remaining connected presences
  dispatcher.broadcastMessage(newMatchOpCode, JSON.stringify({ matchId }), null, null, true);

  return {
    state
  };
}

最佳做法 #

存储比赛状态数据 #

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const matchInit = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, params: {[key: string]: string}): {state: nkruntime.MatchState, tickRate: number, label: string} {
  return {
    state: { presences: {}, started: false },
    tickRate,
    label: ''
  };
};

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 {
   if (state.presences.length > 2) {
    state.started = true;
  }

  return {
    state
  };
}

Related Pages