客户端中继多人游戏
#
借助实时多人游戏引擎,用户可以方便地设置和加入比赛,他们可以在其中与对手快速交换数据。这种中继多人游戏(也称为客户端权威)模式适用于许多游戏类型,例如简单的 1 对 1 或合作游戏,其中服务器上的权威控制并不重要(例如欺骗无关紧要)。
在中继多人游戏中,无论消息大小或内容如何,Nakama 都能促进数据交换。通过比赛传送的任何数据立即转发给请求比赛对手的客户端。Nakama 维护的仅有比赛数据是比赛 ID 和此比赛中状态的列表。
发往其他连接客户端的客户端消息由服务器转发,而不进行检查。由于 Nakama 不跟踪中继多人游戏比赛中转发的数据量或内容,因此没有欺骗检测、错误纠正或其他此类功能。此方法依赖每个比赛中的某个客户端(由客户端自己决定)充当主机。此主机将协调对等机之间的状态更改,并对从不良客户端发送的不明确或恶意消息执行仲裁。
任何用户都可以参加与其他用户的比赛,但无法对比赛的访问进行密码保护或以其他方式加以限制。对于比赛中的玩家数量没有明确限制,只有游戏设计(消息大小和频率)和可用资源(服务器硬件和网络能力)施加的实际限制。
用户可从客户端发送消息,创建、加入和退出比赛。
比赛始终停留在服务器上,直到最后一个参与者离开。它们保存在内存内,无法永远存在。
创建比赛
#
任何用户可调用“比赛创建”操作,创建新的比赛。在创建比赛时,创建者无法提供比赛状态。
服务器将向新比赛分配唯一 ID。可将此 ID 分享给其他用户,让他们加入比赛。
Client
1
2
| var response = await socket.createMatch();
console.log("Created match with ID:", response.match.match_id);
|
Client
1
2
| var match = await socket.CreateMatchAsync();
Console.WriteLine("New match with id '{0}'.", match.Id);
|
Client
1
2
3
4
| rtClient->createMatch([](const NMatch& match)
{
std::cout << "Created Match with ID: " << match.matchId << std::endl;
});
|
Client
1
2
| Match match = socket.createMatch().get();
System.out.format("Created match with ID %s.", match.getId());
|
Client
1
2
3
4
5
6
7
| var created_match : NakamaRTAPI.Match = yield(socket.create_match_async(), "completed")
if created_match.is_exception():
print("An error occurred: %s" % created_match)
return
print("New match with id %s.", created_match.match_id)
|
Client
1
2
3
4
5
6
7
8
| local result = socket.match_create()
if result.error then
print(result.error.message)
return
end
print("Created match with ID", result.match.match_id)
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
在创建新比赛时,用户可以选择提供 name
。此名称用于生成比赛 ID,这意味着两名创建同名比赛的玩家将得到相同的比赛 ID,结果处于同一场比赛中。
Client
1
2
| var matchName = "Heroes";
var match = await socket.CreateMatchAsync(matchName);
|
Client
1
2
| var match_name = "Heroes";
var match : NakamaRTAPI.Match = yield(socket.create_match_async(matchName), "completed");
|
Client
1
2
| local match_name = "Heroes"
local result = socket.match_create(match_name)
|
Code snippet for this language C++/Unreal/Cocos2d-x has not been found. Please choose another language to show equivalent examples.
Code snippet for this language JavaScript/Cocos2d-js has not been found. Please choose another language to show equivalent examples.
Code snippet for this language Java/Android has not been found. Please choose another language to show equivalent examples.
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
用户可以随时退出比赛,所有其他用户将得到通知。所有用户都离开后,比赛将不再存在。
加入比赛
#
用户可用特定比赛的 ID 加入其中。无法用密码保护或关闭比赛。如果用户有比赛 ID,则他们能够加入其中。用户可随时加入比赛,直到最后一名参与者退出。
Client
1
2
3
4
5
6
7
8
9
10
11
| var id = "<matchid>";
var match = await socket.joinMatch(id);
var connectedOpponents = match.presences.filter((presence) => {
// Remove your own user from list.
return presence.user_id != match.self.user_id;
});
connectedOpponents.forEach((opponent) => {
console.log("User id %o, username %o.", opponent.user_id, opponent.username);
});
|
Client
1
2
3
4
5
6
7
| var matchId = "<matchid>";
var match = await socket.JoinMatchAsync(matchId);
foreach (var presence in match.Presences)
{
Console.WriteLine("User id '{0}' name '{1}'.", presence.UserId, presence.Username);
}
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
| string matchId = "<matchid>";
rtClient->joinMatch(matchId, {}, [](const NMatch& match)
{
std::cout << "Joined Match!" << std::endl;
for (auto& presence : match.presences)
{
if (presence.userId != match.self.userId)
{
std::cout << "User id " << presence.userId << " username " << presence.username << std::endl;
}
}
});
|
Client
1
2
3
4
5
6
| String matchId = "<matchid>";
Match match = socket.joinMatch(matchId).get();
for (UserPresence presence : match.getPresences()) {
System.out.format("User id %s name %s.", presence.getUserId(), presence.getUsername());
}
|
Client
1
2
3
4
5
6
7
8
9
| var match_id = "<matchid>"
var joined_match = yield(socket.join_match_async(match_id), "completed")
if joined_match.is_exception():
print("An error occurred: %s" % joined_match)
return
for presence in joined_match.presences:
print("User id %s name %s'." % [presence.user_id, presence.username])
|
Client
1
2
3
4
5
6
7
8
9
10
11
| local match_id = "<matchid>"
local result = socket.match_join(match_id)
if result.error then
print(result.error.message)
return
end
for _,user in ipairs(result.match.presences) do
print("User id", user.user_id, "name", user.name)
end
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
在加入比赛时,将在成功回调中返回一个比赛对手列表。记住此表可能并不包含所有用户,只包含_在那个时间点_连接到比赛的用户。
列出对手
#
当用户创建或加入一个新的比赛时,他们会接收到一个已连接对手的初始列表。在这个初始列表之后,服务器将事件推送到发生比赛加入和比赛退出的已连接客户端 – 如果状态列表没有发生变化,则不发送服务器更新。为了提高效率,对事件进行批处理,这意味着任何事件都可以包含多次加入和/退出。
这些事件可用于更新连接对手的列表,以便您的玩家可以准确地看到所有比赛参与者。
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| var connectedOpponents = [];
socket.onmatchpresence = (presences) => {
// Remove all users who left.
connectedOpponents = connectedOpponents.filter(function(co) {
var stillConnectedOpponent = true;
presences.leaves.forEach((leftOpponent) => {
if (leftOpponent.user_id == co.user_id) {
stillConnectedOpponent = false;
}
});
return stillConnectedOpponent;
});
// Add all users who joined.
connectedOpponents = connectedOpponents.concat(presences.joins);
};
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| var connectedOpponents = new List<IUserPresence>(2);
socket.ReceivedMatchPresence += presenceEvent =>
{
foreach (var presence in presenceEvent.Leaves)
{
connectedOpponents.Remove(presence);
}
connectedOpponents.AddRange(presenceEvent.Joins);
// Remove yourself from connected opponents.
connectedOpponents.Remove(self);
Console.WriteLine("Connected opponents: [{0}]", string.Join(",\n ", connectedOpponents));
};
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
| rtListener->setMatchPresenceCallback([](const NMatchPresenceEvent& event)
{
for (auto& presence : event.joins)
{
std::cout << "Joined user: " << presence.username << std::endl;
}
for (auto& presence : event.leaves)
{
std::cout << "Left user: " << presence.username << std::endl;
}
});
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
| List<UserPresence> connectedOpponents = new ArrayList<UserPresence>();
public void onMatchPresence(final MatchPresenceEvent matchPresence) {
connectedOpponents.addAll(matchPresence.getJoins());
for (UserPresence leave : matchPresence.getLeaves()) {
for (int i = 0; i < connectedOpponents.size(); i++) {
if (connectedOpponents.get(i).getUserId().equals(leave.getUserId())) {
connectedOpponents.remove(i);
}
}
};
});
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| var connected_opponents = {}
func _ready():
# First, setup the socket as explained in the authentication section.
socket.connect("received_match_presence", self, "_on_match_presence")
func _on_match_presence(p_presence : NakamaRTAPI.MatchPresenceEvent):
for p in p_presence.joins:
connected_opponents[p.user_id] = p
for p in p_presence.leaves:
connected_opponents.erase(p.user_id)
print("Connected opponents: %s" % [connected_opponents])
|
Client
1
2
3
4
5
6
7
8
9
10
| local connected_opponents = {}
socket.on_matchpresence(function(message)
for _,p in ipairs(message.match_presence_event.leaves) do
connected_opponents[p.user_id] = nil
end
for _,p in ipairs(message.match_presence_event.joins) do
connected_opponents[p.user_id] = p
end
end)
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
记住列出对手的一些最佳做法:
- 在加入或创建比赛_之前_注册客户端状态事件侦听器
- 对于同一状态同时具有加入和退出的批处理的事件,对于列表中已存在的状态,先处理退出,然后再处理加入;对于列表中没有的状态,处理加入,然后再处理退出
发送数据消息
#
比赛中的用户可以发送数据消息,所有其他对手都会接收到这些消息。这些消息实时流式传输到目标客户端,可以包含任何二进制内容。Nakama 以接收顺序而不一定是发送顺序广播消息。
每条数据消息中的二进制内容应当尽量小,不超出最大传输单位 (MTU) 1500
字节数。通常使用的是 JSON,最好使用 Protocol Buffers 或 FlatBuffers 等紧凑二进制格式。
在无法进一步减少消息大小和/或频率的情况下,最好优先发送更少的消息。例如,1 条每秒 1000
字节的消息优于 5 条每秒 200
字节消息。
为了将每条消息标识为特定的“命令”,它包含一个操作码以及有效负载。
Client
1
2
3
4
| var id = "<matchid>";
var opCode = 1;
var data = { "move": {"dir": "left", "steps": 4} };
socket.sendMatchState(id, opCode, data);
|
Client
1
2
3
4
5
| // using Nakama.TinyJson;
var matchId = "<matchid>";
var opCode = 1;
var newState = new Dictionary<string, string> {{"hello", "world"}}.ToJson();
socket.SendMatchStateAsync(matchId, opCode, newState);
|
Client
1
2
3
4
| string id = "<matchid>";
int64_t opCode = 1;
NBytes data = "{ \"move\": {\"dir\": \"left\", \"steps\" : 4} }";
rtClient->sendMatchData(id, opCode, data);
|
Client
1
2
3
4
| String id = "<matchid>";
int opCode = 1;
String data = "{\"message\":\"Hello world\"}";
socket.sendMatchData(id, opCode, data);
|
Client
1
2
3
4
| var match_id = "<matchid>"
var op_code = 1
var new_state = {"hello": "world"}
socket.send_match_state_async(match_id, op_code, JSON.print(new_state))
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| local match_id = "<matchid>"
local op_code = 1
local data = json.encode({
move = {
dir = "left",
steps = 4
}
})
local result = socket.match_data_send(match_id, op_code, data)
if result.error then
print(result.error.message)
return
end
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
默认情况下消息会广播到所有其他比赛状态,但用户可以选择指定所需的比赛参与者子集(即他们的好友、队友等)以独占方式接收消息。
Client
1
2
| // Only send data to the first presence in the match presences array
socket.sendMatchState(id, opCode, data, [match.presences[0]]);
|
Client
1
2
| // Only send data to the first presence in the match presences array
await socket.SendMatchStateAsync(matchId, opCode, newState, new [] { match.presences.First() });
|
Client
1
2
| // Only send data to the first presence in the match presences array
rtClient->sendMatchData(id, opCode, data, { match.presences[0] });
|
Client
1
2
| // Only send data to the first presence in the match presences array
socket.sendMatchData(id, opCode, data, match.getPresences().get(0));
|
Client
1
2
| // Only send data to the first presence in the match presences array
socket.send_match_state_async(match_id, op_code, JSON.print(new_state), [current_match.presences[0]])
|
Client
1
2
| -- Only send data to the first presence in the match presences array
local result = socket.match_data_send(match_id, op_code, data, { match.presences[0] })
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
操作码
#
操作码是发送的消息类型的数值标识符。操作码可以让用户在对消息解码之前了解消息的目的和内容。
可以将其用于定义游戏中属于某些用户操作的命令,例如:
- 初始状态同步
- 就绪状态
- Ping / Pong
- 游戏状态更新
- 表情
有关示例实现,请参阅 Fish Game 教程。
接收数据消息
#
服务器按照处理来自客户端的数据消息的顺序交付数据。客户端可以为传入的比赛数据消息添加回调。这应该在他们创建(或加入)和退出比赛之前完成。
Client
1
2
3
4
5
6
7
8
9
10
11
| socket.onmatchdata = (result) => {
var content = result.data;
switch (result.op_code) {
case 101:
console.log("A custom opcode.");
break;
default:
console.log("User %o sent %o", result.presence.user_id, content);
}
};
|
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);
}
};
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
| rtListener->setMatchDataCallback([](const NMatchData& data)
{
switch (data.opCode)
{
case 101:
std::cout << "A custom opcode." << std::endl;
break;
default:
std::cout << "User " << data.presence.userId << " sent " << data.data << std::endl;
break;
}
});
|
Client
1
2
3
4
5
6
| SocketListener listener = new AbstractSocketListener() {
@Override
public void onMatchData(final MatchData matchData) {
System.out.format("Received match data %s with opcode %d", matchData.getData(), matchData.getOpCode());
}
};
|
Client
1
2
3
4
5
6
| func _ready():
# First, setup the socket as explained in the authentication section.
socket.connect("received_match_state", self, "_on_match_state")
func _on_match_state(p_state : NakamaRTAPI.MatchData):
print("Received match state with opcode %s, data %s" % [p_state.op_code, parse_json(p_state.data)])
|
Client
1
2
3
4
| socket.on_matchdata(function(message)
local data = json.decode(message.match_data.data)
local op_code = tonumber(message.match_data.op_code)
end)
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
退出比赛
#
用户可随时退出比赛。这可能通过客户端操作(退出比赛)自愿发生,也可能非自愿发生(例如因网络连接的原因),但在这两种情况下,都必须在游戏逻辑中进行适当的考虑和处理。
Client
1
2
| var id = "<matchid>";
socket.leaveMatch(id);
|
Client
1
2
| var matchId = "<matchid>";
await socket.LeaveMatchAsync(matchId);
|
Client
1
2
| string matchId = "<matchid>";
rtClient->leaveMatch(matchId);
|
Client
1
2
| String matchId = "<matchid>";
socket.leaveMatch(matchId).get();
|
Client
1
2
3
4
5
6
| var match_id = "<matchid>"
var leave : NakamaAsyncResult = yield(socket.leave_match_async(match_id), "completed")
if leave.is_exception():
print("An error occurred: %s" % leave)
return
print("Match left")
|
Client
1
2
3
4
5
6
| local match_id = "<matchid>"
local result = socket.match_leave(match_id)
if result.error then
print(result.error.message)
return
end
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
所有用户退出后,游戏结束。此时其 ID 失效,无法再次用于重新加入。
示例
#
比赛主机轮换
#
您必须确定如何从连接的客户端中选择比赛主机。最好的实现方式是不需要在比赛参与者之间进行任何“协商”,同时确保所有客户端都承认同一主机。
您可以通过对比赛状态进行决定性的排序,并根据任何所需的因素选择主机来实现这一点。在下面的示例中,我们对状态列表进行排序,并选择最低的索引会话 ID 作为主机:
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Declare a variable to store which presence is the host
var hostPresence;
// Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
socket.onmatchmakermatched = (matchmakerMatched) => {
var hostSessionId = matchmakerMatched.users.map(user => user.presence.session_id).sort();
hostPresence = matchmakerMatched.users.filter(user => user.presence.session_id == hostSessionId)[0];
};
// When receiving a match presence event, check if the host left and if so recalculate the host presence
socket.onmatchpresence = (matchPresence) => {
if (matchPresence.leaves.find(presence => presence.user_id === hostPresence.user_id))
{
var hostSessionId = match.presences.map(presence => presence.session_id).sort();
hostPresence = match.presences.filter(presence => presence.session_id === hostSessionId)[0];
}
};
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Declare a variable to store which presence is the host
IUserPresence hostPresence;
// Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
socket.ReceivedMatchmakerMatched += matched =>
{
hostPresence = matched.Users.OrderBy(x => x.Presence.SessionId).First().Presence;
};
// When receiving a match presence event, check if the host left and if so recalculate the host presence
socket.ReceivedMatchPresence += matchPresenceEvent =>
{
if (matchPresenceEvent.Leaves.Any(x => x.UserId == hostPresence.UserId))
{
hostPresence = match.Presences.OrderBy(x => x.SessionId).First();
}
};
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // Declare a variable to store which presence is the host
NUserPresence hostPresence;
// Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
listener.setMatchmakerMatchedCallback([&done, &hostPresence](NMatchmakerMatchedPtr matchmakerMatched) {
std::sort(matchmakerMatched->users.begin(), matchmakerMatched->users.end(), [](const NMatchmakerUser lhs, const NMatchmakerUser rhs) {
return lhs.presence.sessionId < rhs.presence.sessionId;
});
hostPresence = matchmakerMatched->users[0].presence;
});
// When receiving a match presence event, check if the host left and if so recalculate the host presence
listener.setMatchPresenceCallback([&match, &hostPresence](NMatchPresenceEvent matchPresenceEvent) {
for (int i = 0; i < matchPresenceEvent.leaves.size(); i++) {
if (matchPresenceEvent.leaves[i].sessionId == hostPresence.sessionId) {
std::sort(match.presences.begin(), match.presences.end(), [](const NUserPresence lhs, const NUserPresence rhs) {
return lhs.sessionId < rhs.sessionId;
});
hostPresence = match.presences[0];
}
}
});
|
Client
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
| // Declare a variable to store which presence is the host (as a final 1 length array so we can access it correctly)
final UserPresence[] hostPresence = new UserPresence[1];
SocketListener socketListener = new SocketListener() {
// Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
@Override
public void onMatchmakerMatched(MatchmakerMatched matchmakerMatched) {
List<MatchmakerUser> users = matchmakerMatched.getUsers();
users.sort((a, b) -> String.CASE_INSENSITIVE_ORDER.compare(a.getPresence().getSessionId(), b.getPresence().getSessionId()));
hostPresence[0] = users.get(0).getPresence();
}
// When receiving a match presence event, check if the host left and if so recalculate the host presence
@Override
public void onMatchPresence(MatchPresenceEvent e) {
if (e.getLeaves() != null) {
e.getLeaves().forEach(presence -> {
if (presence.getSessionId() == hostPresence[0].getSessionId()) {
List<UserPresence> matchPresences = match.getPresences();
matchPresences.sort((a, b) -> String.CASE_INSENSITIVE_ORDER.compare(a.getSessionId(), b.getSessionId()));
hostPresence[0] = matchPresences.get(0);
}
});
}
}
}
|
Client
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
| # Declare a variable to store which presence is the host
var host_presence : NakamaRTAPI.UserPresence
# Define comparer functions
func _presence_comparer(a : NakamaRTAPI.UserPresence, b : NakamaRTAPI.UserPresence):
return a.session_id < b.session_id
func _user_comparer(a : NakamaRTAPI.MatchmakerUser, b : NakamaRTAPI.MatchmakerUser):
return a.presence.session_id < b.presence.session_id
# Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
func _on_matchmaker_matched(matchmaker_matched : NakamaRTAPI.MatchmakerMatched):
matchmaker_matched.users.sort_custom(self, "_user_comparer")
host_presence = matchmaker_matched.users[0].presence
current_match = yield(socket.join_match_async(matchmaker_matched.match_id), "completed")
# When receiving a match presence event, check if the host left and if so recalculate the host presence
func _on_match_presence(match_presence_event : NakamaRTAPI.MatchPresenceEvent):
for presence in match_presence_event.leaves:
if presence.session_id == host_presence.session_id:
current_match.presences.sort_custom(self, "_presence_comparer")
if len(current_match.presences) < 1:
host_presence = current_match.self_user
else:
host_presence = current_match.presences[0]
|
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| local host = nil
-- Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
socket.on_matchmaker_matched(function(matched)
table.sort(matched.users, function(a, b) return a.presence.session_id < b.presence.session_id end)
host = matched.users[1]
end)
-- When receiving a match presence event, check if the host left and if so recalculate the host presence
socket.on_match_presence_event(function(presence)
for i,presence in ipairs(presence.leaves) do
if presence.session_id == host.session_id then
table.sort(match.presences, function(a, b) return a.session_id < b.session_id end)
host = match.presences[1]
end
end
end)
|
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.
Related Pages