玩家匹配系统¶
Time:30 分钟
在竞技类游戏中,如果能把水平相当的玩家进行匹配,会给玩家带来更好的游戏体验。目前 Roblox 可以自动将玩家分配到不同的 Team
(队伍)中,但该功能并不包括玩家的水平匹配。在这篇文章中,我们将会学习如何在 Roblox 中实现 Elo 等级分制度,以及如何使用排名对两名玩家进行匹配。
Elo 等级分制度¶
Elo 等级分制度是衡量玩家水平的一种评级方法,被广泛运用于多种体育运动之中,其中尤为突出的是国际象棋方面的应用。这种评级制度不仅能够帮助玩家查看自己与其他玩家之间的差距,还可以按照玩家评级进行玩家匹配。由于同一评级的玩家水平大致相当,因此只需将同一评级的玩家互相进行匹配,极为简单直观。同时,通过这种制度也可以预估玩家在对战中的胜率等。
Elo 等级分制度只用对战胜利、失败与平手(算作半个胜利和半个失败)进行玩家评级排行。当两个玩家完成一场对战后,其评级也会进行相应的调整。调整数目与每个玩家的个人评级相关联。如果高排名的玩家打败了低排名的玩家,则高排名的玩家只会获得较小的评级提升(同理,低排名的玩家也只会获得较小的评级降低)。而如果低排名的玩家打败了高排名的玩家,将会获得大幅度的评级提升。
本文将不会赘述 Elo 等级分制度的具体理论与计算方法,有兴趣的开发者可以前往官方维基页面进行延伸阅读。我们对 Elo 等级分制度将会进行以下列三条重点为中心的运用:
每场对战结果都会为每个参战玩家生成一个分数。玩家胜利时分数为 1;平手时为 0.5;失败时为 0。
每个玩家都会拥有预期分数。这个分数参照玩家排行生成,代表了玩家的获胜概率,且也将在1和0之间。
玩家的排名将会根据对战结果分数与预期分数进行调整。同时一场对战能够进行的排名调整最大值也将被限制(这个限制将会被称为 K系数)。
玩家匹配大厅¶
游戏的第一个部分将会为游戏大厅。虽然大厅在 Roblox 中极为常见,但玩家将会在我们这个大厅中进行匹配。匹配完成后,配对的玩家将会被传送到另外的竞技场场景,在一个简单的游戏中互相对战。
获取玩家排名¶
当玩家第一次加入游戏时,需要给予其一个排名。排名可以选择任何数字,但在本示例中我们将会把初始排名设为 1500 。这样不管是上行或下移都有一定空间,不必担心其排名过于接近负数。如果玩家以前曾经加入过游戏,则需要从 DataStore 中获取其排名。无论是不是初次加入游戏,我们都会将把玩家排名显示在排名榜中。
local ratingData = game:GetService("DataStoreService"):GetDataStore("RatingData")
-- 处理玩家加入游戏。如果是新玩家,则需要给予其
-- 初始排名;如果是已有玩家,则需要从 DataStore
-- 中获取其排名。最后需要将排名显示在排行榜中
game.Players.PlayerAdded:connect(function (player)
local playerData = {}
-- 如果玩家数据不存在于 DataStore 中,则为其创建 1500 的初始排名
ratingData:UpdateAsync(player.userId, function(oldValue)
local newValue = oldValue
if not newValue then
newValue = { Rating = 1500 }
end
return newValue
end)
playerData.Score = ratingData:GetAsync(tostring(player.UserId)).Rating
-- 在排行榜中显示排名
local leaderstats = Instance.new("Folder", player)
leaderstats.Name = "leaderstats"
local displayRating = Instance.new("IntValue", leaderstats)
displayRating.Name = "Rating"
displayRating.Value = playerData.Score
end)
排名列表¶
在匹配玩家时,构建一个让寻找匹配的玩家快速在排名范围内找到匹配对象的结构会大幅提升玩家体验。虽然这个问题有许多解决方法,但在本示例中我们将会使用双链表。如果能确保链表的排序依据为玩家排名,则为玩家寻找匹配时只需在该玩家节点附近搜寻即可。
由于该列表的实现过程较长,为了更方便的查看示例,我们将把 Rank (排行)列表的所有代码放入一个 ModuleScript
中。
基础列表结构¶
链表乃是节点的结合(也就是一张表格)。其中每个节点中都含有对列表中下一个节点的引用。双链表中的节点同时也拥有对列表中上一个节点的引用。
local rankList = {}
rankList.Top = nil
rankList.Bottom = nil
-- 在列表中的 oldNode 前插入 newNode 。如果 oldNode 在列表顶端则重新分配 rankList.Top
function rankList:InsertBefore(newNode, oldNode)
if oldNode.Prev then
newNode.Prev = oldNode.Prev
oldNode.Prev.Next = newNode
end
newNode.Next = oldNode
oldNode.Prev = newNode
if rankList.Top == oldNode then
rankList.Top = newNode
end
return newNode
end
-- 从链表中移除节点。确保列表的 Top (顶部)和 Bottom (底部)都指向正确的节点。
-- 同时从查找字典中移除节点
function rankList:RemoveNode(node)
if node.Prev then
node.Prev.Next = node.Next
if rankList.Bottom == node then
rankList.Bottom = node.Prev
end
end
if node.Next then
node.Next.Prev = node.Prev
if rankList.Top == node then
rankList.Top = node.Next
end
end
keyTable[tostring(node.userId)] = nil
end
将玩家添加至列表¶
至此为止,我们只是编写了链表的基本结构。接下来需要将玩家数据添加至列表中。为了确保列表正常工作,每添加一个玩家后都需要确保将玩家按照排名添加到了列表中的正确位置。这样一来,列表的排序将不会出现混乱,对之后寻找匹配也会有帮助。
另外,我们也将添加将 userIds 连接至其相应节点的字典,这样就可以快速到达列表的任意位置。否则我们需要迭代列表才能找到玩家节点。
-- 将 userIds 连接到对应链表节点的字典,方便快速查找
local keyTable = {}
-- 添加玩家至列表。通过依照排名将玩家添加至列表的正确
-- 位置来确保列表的排序无误。同时将玩家添加至查找字典
function rankList:AddPlayer(userId, rank, startedWaiting)
local node = {UserId = userId, Rank = rank, Age = startedWaiting, Next = nil, Prev = nil}
keyTable[tostring(userId)] = node
-- 首先检查列表是否为空。如是则将列表的 Top (顶部)和 Bottom (底部)指向新增节点
if not rankList.Top then
rankList.Top = node
rankList.Bottom = node
return node
else
-- 如果列表非空,则寻找新增节点的添加位置。
-- 从 Top (顶部)节点开始,沿列表向下比较排名
local currentNode = rankList.Top
while currentNode do
if currentNode.Rank > rank then
return rankList:InsertBefore(node, currentNode)
end
currentNode = currentNode.Next
end
-- 如果代码运行至此,则新增节点需要被添加至列表最后
rankList.Bottom.Next = node
node.Prev = rankList.Bottom
rankList.Bottom = node
return node
end
end
-- 将玩家从列表中移除
function rankList:RemovePlayer(userId)
local playerNode = keyTable[tostring(userId)]
rankList:RemoveNode(playerNode)
end
寻找匹配¶
接下来的步骤可能会更为激动人心,我们将会使用刚刚制作的双链表为玩家寻找匹配。链表中不仅包含了玩家的排名,还包括了其等待对战的时长。当为玩家寻找匹配时,我们会尝试寻找玩家排名特定范围内的匹配。因此,匹配的对象应该是排名范围内等待时间最长的玩家。
进行搜索时,我们将会从寻找匹配的玩家节点开始向列表的上方和下方进行搜寻,同时也要追踪等待时间最长的玩家。
-- FindPlayerInRangeWithNode 的私人搜索函数。可基于
-- searchAscending 向列表的顶部或底部进行搜索
local function Search(current, rank, searchAscending, range, oldestWait)
local retNode = nil
while current do
if math.abs(rank - current.Rank) > range then
break
end
if current.Age < oldestWait then
oldestWait = current.Age
retNode = current
end
if searchAscending then
current = current.Next
else
current = current.Prev
end
end
return retNode, oldestWait
end
-- 返回处于 startNode 玩家排名范围内的玩家节点。
-- 返回的玩家同时也是范围内等待时间最长的。
function rankList:FindPlayerInRangeWithNode(startNode, range)
local oldestWait = math.huge
local rank = startNode.Rank
local current = startNode.Next
local retNode = nil
retNode, oldestWait = Search(startNode.Next, startNode.Rank, true, range, oldestWait)
retNode, oldestWait = Search(startNode.Prev, startNode.Rank, false, range, oldestWait)
return retNode
end
-- 返回在 userId 排名范围内找到玩家的用户 ID 。
-- 若未找到玩家则返回 nil 。
function rankList:FindPlayerInRange(userId, range)
local playerNode = keyTable[tostring(userId)]
if playerNode then
local otherPlayer = rankList:FindPlayerInRangeWithNode(playerNode, range)
if otherPlayer then
return otherPlayer.UserId
end
end
return nil
end
匹配队列¶
游戏中的玩家可能并不想马上进入游戏,不如为他们创造等待匹配的队列吧。由于我们想要优先匹配等待时间最长的玩家,队列在这方面也将会十分有用。在创建队列时,我们将会使用含有所有排队玩家 userId 和玩家进入队列时间的表格。在无位置参数的情况下使用 table.insert 将会确保最后一个进入队列的玩家总处于队列最末尾。同时我们也要确保玩家在找到匹配对象、离开游戏或想要停止排队时能够离开队列。
-- 含有排队寻找匹配玩家的表格
local matchMakingQueue = {}
-- 将玩家的 id 与进入队列时间添加至队列中
local addToMMQueue = function(playerId, enteredTime)
local data = {}
data.UserId = playerId
data.EnteredQueue = enteredTime
table.insert(matchMakingQueue, data)
end
-- 从队列中移除玩家
local removeFromMMQueue = function(playerId)
for i, playerData in pairs(matchMakingQueue) do
if playerData.UserId == playerId then
table.remove(matchMakingQueue, i)
return
end
end
end
将玩家添加至队列中¶
为了将玩家添加至队列中,我们将会在游戏中添加可以触发 RemoteEvent
的简单按钮。触发事件后,玩家将会被添加至队列与链表中。再按一次按钮就可以将玩家从队列和链表中移除了。
local button = script.Parent
local lookForGameEvent = game.ReplicatedStorage.LookForGameEvent
local lookForGameNextClick = true
-- 处理点击按钮,使其从队列中添加/移除玩家
button.MouseButton1Click:connect(function()
if lookForGameNextClick then
button.Text = "正在寻找匹配。再次点击取消匹配"
else
button.Text = "寻找匹配。"
end
lookForGameEvent:FireServer(lookForGameNextClick)
lookForGameNextClick = not lookForGameNextClick
end)
回到主脚本中:
local rankedList = require(game.ServerStorage.MatchmakingRankedListModule)
local lookForGameEvent = game.ReplicatedStorage.LookForGameEvent
-- 当玩家离开游戏时,将玩家从列表与队列中移除
game.Players.PlayerRemoving:connect(function(player)
removeFromMMQueue(player.userId)
rankedList:RemovePlayer(player.userId)
end)
game.Players.PlayerRemoving:connect(function(player)
removeFromMMQueue(player.userId)
rankedList:RemovePlayer(player.userId)
end)
-- 处理远程事件,使其从队列中添加/移除玩家
lookForGameEvent.OnServerEvent:connect(function(player, lookingForGame)
if lookingForGame then
print(player.Name .. "正在寻找匹配")
local enteredTime = os.time()
addToMMQueue(player.userId, enteredTime)
rankedList:AddPlayer(player.userId, player.leaderstats.Rating.Value, enteredTime)
else
print(player.Name .. "已经离开队列")
removeFromMMQueue(player.userId)
rankedList:RemovePlayer(player.userId)
end
end)
处理队列¶
获取将玩家添加至队列与链表中的能力后,我们现在可以循环遍历队列,对正在排队的玩家进行匹配。同时,我们还需要定义一个寻找匹配的排名范围。简单来讲,互相匹配的玩家应当排名较为靠近(例如排名差异在 100 之内)。但有时在范围内找不到合适的玩家,需要对范围进行扩张。我们将会使用玩家进入队列的时间来决定寻找匹配的排名范围。
需要注意的是,该循环使用了 spawn (生成)对函数进行调用,为两名互相匹配的玩家开始游戏。也就是说游戏进行设置时匹配循环仍将进行,可能会需要一段时间。
-- 根据排队等待时间返回搜寻匹配的排名范围。
-- 如果玩家等待时间过长则返回 math.huge,
-- 允许玩家与任何人进行匹配。
local getRange = function(timeWaiting)
if timeWaiting < 10 then
return 100
elseif timeWaiting >=10 and timeWaiting < 20 then
return 200
elseif timeWaiting >=20 and timeWaiting <= 35 then
return 300
end
return math.huge
end
-- 匹配循环。每 5 秒左右进行循环,为玩家寻找匹配。
while true do
local now = os.time()
-- 循环遍历队列,尝试寻找范围内的玩家
for _, mmData in pairs(matchMakingQueue) do
print("正在匹配。请稍候," .. mmData.UserId)
-- 获取搜索的排名范围
local range = getRange(now - mmData.EnteredQueue)
-- 使用列表寻找符合排名范围的匹配玩家
local otherPlayerId = rankedList:FindPlayerInRange(mmData.UserId, range)
if otherPlayerId then
-- 找到了另一名玩家。将两位玩家从队伍中移除,
-- 防止其与其他玩家匹配
print("找到玩家:" .. otherPlayerId)
rankedList:RemovePlayer(mmData.UserId)
rankedList:RemovePlayer(otherPlayerId)
removeFromMMQueue(mmData.UserId)
removeFromMMQueue(otherPlayerId)
-- 为两名玩家开始游戏。该函数运行可能需要一段时间,
-- 请在新线程中生成该函数,以便循环继续。
spawn(function() startGame(mmData.UserId, otherPlayerId) end)
end
end
wait(5)
end
创建竞技场与传送¶
通过匹配找到两名玩家后,我们可以使用 AssetService/CreatePlaceAsync
创建一个独立场景供其对战使用。创建完成后,我们可以使用 TeleportService/Teleport
将一名玩家传送进该场景。虽然可以再次运用 Teleport (传送)将另一名玩家传送进去,且两人很可能会处于同一实例,但我们将无法保证两人一定处于同一实例中。更为保险的方法是等待第一名玩家传送完毕后,使用 TeleportService/GetPlayerPlaceInstanceAsync
获取玩家所在的实例,然后使用 TeleportService/TeleportToPlaceInstance
将另一名玩家传送过去。
local teleportService = game:GetService("TeleportService")
local arenaPlaceTemplateId = 181238621
-- 为对战创建场所,并将两名玩家传送进去
local startGame = function(playerAId, playerBId)
local message = ""
print("即将开始对战:" .. playerAId .. "对" .. playerBId)
-- 获取两名玩家对象
local playerA = nil
local playerB = nil
for _, player in pairs(game.Players:GetPlayers()) do
if player.userId == playerAId then
playerA = player
end
if player.userId == playerBId then
playerB = player
end
end
-- 创建竞技场场所并获取其 id
local arenaPlaceId = game:GetService("AssetService"):CreatePlaceAsync(
"Arena place for " .. playerA.Name .. " and " .. playerB.Name, arenaPlaceTemplateId)
-- 绑定 OnTeleport 事件至将会被先传送至场景的 playerA 。
-- 若传送成功则将 playerB 传送至同一实例
local connection = playerA.OnTeleport:connect(function(teleportState, placeId)
if teleportState == Enum.TeleportState.Started then
local teleportStarted = os.time()
-- 持续检查 playerA 是否到达其它实例。
while true do
local success, error, placeId, arenaInstanceId = teleportService:GetPlayerPlaceInstanceAsync(playerAId)
-- 如果 playerA 传送场所正确,则将 playerB 也传送至同一场景
if placeId == arenaPlaceId then
teleportService:TeleportToPlaceInstance(arenaPlaceId, arenaInstanceId, playerB)
return
end
wait()
end
end
end)
wait(1)
-- 传送 playerA 至竞技场
teleportService:Teleport(arenaPlaceId, playerA)
end
调整玩家排名¶
目前,我们已经建立了一个大厅,可以让玩家们与排名相近的对手进行匹配,并让他们进行对战。现在让我们来看看玩家们用来对战的游戏,以及如何根据其在游戏中的表现调整排名。为了让示例简明易懂,我们将会使用极为简单的游戏:两个玩家都会获取一把剑,最先击败对方的玩家将会是胜者。如果出现两个玩家同时击败对方的少见情况,则比赛结果将为平手。
设置¶
在这个游戏中,我们需要持续关注两名玩家是否生存。同时,也需要了解两人的排名。当玩家进入对战游戏时,我们将会把这些信息都放入表中。
local players = {}
local ratingData = game:GetService("DataStoreService"):GetDataStore("RatingData")
local playerHasDied = false
-- 处理 PlayerAdded 事件,设置玩家表格
game.Players.PlayerAdded:connect(function(player)
-- 当玩家被击败时,需要更新玩家表格。同时也需要将
-- 全局变量 playerHasDied 设为 true 以结束对战游戏
player.CharacterAdded:connect(function(character)
character.Humanoid.Died:connect(function()
print(player.Name .. "已被击败")
players[tostring(player.userId)].Died = true
playerHasDied = true
end)
end)
-- 从 DataStore 获取玩家排名,将玩家表格内的击败状态设为 false
print("正在获取玩家数据:" .. player.Name)
local playerData = {}
playerData.Rating = ratingData:GetAsync(tostring(player.userId)).Rating
playerData.Died = false
players[tostring(player.userId)] = playerData
end)
计算排名变化¶
我们将会在这里使用 Elo 等级分制度计算每名玩家的排名变化。该函数将会根据每个玩家的当前排名、对战胜败与常数 K 系数来进行计算。请参见 Elo 等级分制度维基页面 查看所使用的函数。
local kfactor = 30
-- 使用 Elo 等级分制度计算每个玩家的排名变化
local calculateRatingChange = function(playerA, playerB)
-- 从玩家表格获取每个玩家的排名
local playerARating = players[tostring(playerA.userId)].Rating
local playerBRating = players[tostring(playerB.userId)].Rating
-- 从玩家表格获取每个玩家的胜败
local playerADied = players[tostring(playerA.userId)].Died
local playerBDied = players[tostring(playerB.userId)].Died
-- 计算每个玩家的胜率。需要注意的是: expectedA + expectedB = 1
local expectedA = 1 / (1 + math.pow(10,(playerBRating - playerARating)/400))
local expectedB = 1 - expectedA
-- 根据玩家对战结果计算分数。注意以下值:
-- 胜 = 1
-- 平 = .5
-- 败 = 0
-- 我们以 .5 开始(设想结果为平局)。如果玩家被击败则失去 .5 分数,
-- 胜利玩家获取 .5 分数
local scoreA = .5
local scoreB = .5
if playerADied then
scoreA = scoreA - .5
scoreB = scoreB + .5
end
if playerBDied then
scoreA = scoreA + .5
scoreB = scoreB - .5
end
-- 根据分数、胜率计算每个玩家的排名变化
-- 然后以 kfactor 对变化量加以限制
local playerAChange = kfactor * (scoreA - expectedA)
local playerBChange = kfactor * (scoreB - expectedB)
return playerAChange, playerBChange
end
检测对战结束¶
最后一件需要做的事情就是检测对战结束。对战结束之后,我们就可以计算玩家排名变化,更新 DataStore,然后将玩家传送回大厅了。
local teleportService = game:GetService("TeleportService")
local lobbyId = 181194460
-- 等待对战结束
while not playerHasDied do
wait()
end
print("玩家已被击败!即将调整分数")
-- 稍作等待,检查是否两人都被击败,以便算为平局
wait(1)
local playerA = nil
local playerB = nil
for _, player in pairs(game.Players:GetPlayers()) do
if playerA == nil then
playerA = player
else
playerB = player
end
end
-- 计算每个玩家的排名变化
local playerAchange, playerBchange = calculateRatingChange(playerA, playerB)
print("PlayerA points should change by " .. playerAchange)
print("PlayerB points should change by " .. playerBchange)
-- 修改每个玩家的点数与排名
adjustPlayerRating(playerA, playerAchange)
adjustPlayerRating(playerB, playerBchange)
print("即将传送回大厅")
wait(5)
-- 将玩家传送回大厅
for _, player in pairs(game.Players:GetPlayers()) do
teleportService:Teleport(lobbyId, player)
end
示例¶
大厅脚本¶
local lookForGameEvent = game.ReplicatedStorage.LookForGameEvent
local rankedList = require(game.ServerStorage.MatchmakingRankedListModule)
local ratingData = game:GetService("DataStoreService"):GetDataStore("RatingData")
local teleportService = game:GetService("TeleportService")
local arenaPlaceTemplateId = 181238621
-- 含有排队寻找匹配玩家的表格
local matchMakingQueue = {}
-- 将玩家的 id 与进入队列时间添加至队列中
local addToMMQueue = function(playerId, enteredTime)
local data = {}
data.UserId = playerId
data.EnteredQueue = enteredTime
table.insert(matchMakingQueue, data)
end
-- 从队列中移除玩家
local removeFromMMQueue = function(playerId)
for i, playerData in pairs(matchMakingQueue) do
if playerData.UserId == playerId then
table.remove(matchMakingQueue, i)
return
end
end
end
-- 处理玩家加入游戏。如果是新玩家,则需要给予其
-- 初始排名;如果是已有玩家,则需要从 DataStore
-- 中获取其排名。最后需要将排名显示在排行榜中
game.Players.PlayerAdded:connect(function(player)
local playerData = {}
-- 如果玩家数据不存在于 DataStore 中,则为其创建 1500 的初始排名
ratingData:UpdateAsync(player.userId, function(oldValue)
local newValue = oldValue
if not newValue then
newValue = {Rating = 1500}
end
return newValue
end)
playerData.Score = ratingData:GetAsync(tostring(player.userId)).Rating
-- 在排行榜中显示排名
local leaderstats = Instance.new("Model", player)
leaderstats.Name = "leaderstats"
local displayRating = Instance.new("IntValue", leaderstats)
displayRating.Name = "Rating"
displayRating.Value = playerData.Score
end)
-- 将玩家添加至队列和列表中寻找匹配
local function playerSearchingForMatch(userId, rank)
local now = os.time()
addToMMQueue(userId, now)
rankedList:AddPlayer(userId, rank, now)
end
-- 当玩家离开游戏时将其从队列和列表中移除
game.Players.PlayerRemoving:connect(function(player)
removeFromMMQueue(player.userId)
rankedList:RemovePlayer(player.userId)
end)
-- 处理远程事件,使其从队列中添加/移除玩家
lookForGameEvent.OnServerEvent:connect(function(player, lookingForGame)
if lookingForGame then
print(player.Name .. "正在寻找匹配")
local enteredTime = os.time()
playerSearchingForMatch(player.userId, player.leaderstats.Rating.Value)
else
print(player.Name .. "已经离开队列")
removeFromMMQueue(player.userId)
rankedList:RemovePlayer(player.userId)
end
end)
-- 根据排队等待时间返回搜寻匹配的排名范围。
-- 如果玩家等待时间过长则返回 math.huge,
-- 允许玩家与任何人进行匹配。
local getRange = function(timeWaiting)
if timeWaiting < 10 then
return 100
elseif timeWaiting >=10 and timeWaiting < 20 then
return 200
elseif timeWaiting >=20 and timeWaiting <= 35 then
return 300
end
return math.huge
end
-- 为对战创建场所,并将两名玩家传送进去
local startGame = function(playerAId, playerBId)
local message = ""
print("即将开始对战:" .. playerAId .. "对" .. playerBId)
-- 获取两名玩家对象
local playerA = nil
local playerB = nil
for _, player in pairs(game.Players:GetPlayers()) do
if player.userId == playerAId then
playerA = player
end
if player.userId == playerBId then
playerB = player
end
end
-- 创建竞技场场所并获取其 id
local arenaPlaceId = game:GetService("AssetService"):CreatePlaceAsync(
"Arena place for " .. playerA.Name .. " and " .. playerB.Name, arenaPlaceTemplateId)
-- 绑定 OnTeleport 事件至将会被先传送至场景的 playerA 。
-- 若传送成功则将 playerB 传送至同一实例
local connection = playerA.OnTeleport:connect(function(teleportState, placeId)
if teleportState == Enum.TeleportState.Started then
local teleportStarted = os.time()
-- 持续检查 playerA 是否到达其它实例。
while true do
local success, error, placeId, arenaInstanceId = teleportService:GetPlayerPlaceInstanceAsync(playerAId)
-- 如果 playerA 传送场所正确,则将 playerB 也传送至同一场景
if placeId == arenaPlaceId then
teleportService:TeleportToPlaceInstance(arenaPlaceId, arenaInstanceId, playerB)
return
end
wait()
end
end
end)
wait(1)
-- 传送 playerA 至竞技场
teleportService:Teleport(arenaPlaceId, playerA)
end
-- 匹配循环。每 5 秒左右进行循环,为玩家寻找匹配。
while true do
local now = os.time()
-- 循环遍历队列,尝试寻找范围内的玩家
for _, mmData in pairs(matchMakingQueue) do
print("正在匹配。请稍候," .. mmData.UserId)
-- 获取搜索的排名范围
local range = getRange(now - mmData.EnteredQueue)
-- 使用列表寻找符合排名范围的匹配玩家
local otherPlayerId = rankedList:FindPlayerInRange(mmData.UserId, range)
if otherPlayerId then
-- 找到了另一名玩家。将两位玩家从队伍中移除,
-- 防止其与其他玩家匹配
print("找到玩家:" .. otherPlayerId)
rankedList:RemovePlayer(mmData.UserId)
rankedList:RemovePlayer(otherPlayerId)
removeFromMMQueue(mmData.UserId)
removeFromMMQueue(otherPlayerId)
-- 为两名玩家开始游戏。该函数运行可能需要一段时间,
-- 请在新线程中生成该函数,以便循环继续。
local thread = coroutine.create(function() startGame(mmData.UserId, otherPlayerId) end)
coroutine.resume(thread)
end
end
wait(5)
end
大厅链表模块¶
-- 玩家匹配双链表模块
local rankList = {}
rankList.Top = nil
rankList.Bottom = nil
-- 将 userIds 连接到对应链表节点的字典,方便快速查找
local keyTable = {}
-- 在列表中的 oldNode 前插入 newNode 。如果 oldNode 在列表顶端则重新分配 rankList.Top
function rankList:InsertBefore(newNode, oldNode)
if oldNode.Prev then
newNode.Prev = oldNode.Prev
oldNode.Prev.Next = newNode
end
newNode.Next = oldNode
oldNode.Prev = newNode
if rankList.Top == oldNode then
rankList.Top = newNode
end
return newNode
end
-- 从链表中移除节点。确保列表的 Top (顶部)和 Bottom (底部)都指向正确的节点。
-- 同时从查找字典中移除节点
function rankList:RemoveNode(node)
if node.Prev then
node.Prev.Next = node.Next
if rankList.Bottom == node then
rankList.Bottom = node.Prev
end
end
if node.Next then
node.Next.Prev = node.Prev
if rankList.Top == node then
rankList.Top = node.Next
end
end
keyTable[tostring(node.userId)] = nil
end
-- 添加玩家至列表。通过依照排名将玩家添加至列表的正确
-- 位置来确保列表的排序无误。同时将玩家添加至查找字典
function rankList:AddPlayer(userId, rank, startedWaiting)
local node = {UserId = userId, Rank = rank, Age = startedWaiting, Next = nil, Prev = nil}
keyTable[tostring(userId)] = node
-- 首先检查列表是否为空。如是则将列表的 Top (顶部)和 Bottom (底部)指向新增节点
if not rankList.Top then
rankList.Top = node
rankList.Bottom = node
return node
else
-- 如果列表非空,则寻找新增节点的添加位置。
-- 从 Top (顶部)节点开始,沿列表向下比较排名
local currentNode = rankList.Top
while currentNode do
if currentNode.Rank > rank then
return rankList:InsertBefore(node, currentNode)
end
currentNode = currentNode.Next
end
-- 如果代码运行至此,则新增节点需要被添加至列表最后
rankList.Bottom.Next = node
node.Prev = rankList.Bottom
rankList.Bottom = node
return node
end
end
-- 将玩家从列表中移除
function rankList:RemovePlayer(userId)
local playerNode = keyTable[tostring(userId)]
rankList:RemoveNode(playerNode)
end
-- FindPlayerInRangeWithNode 的私人搜索函数。可基于
-- searchAscending 向列表的顶部或底部进行搜索
local function Search(current, rank, searchAscending, range, oldestWait)
local retNode = nil
while current do
if math.abs(rank - current.Rank) > range then
break
end
if current.Age < oldestWait then
oldestWait = current.Age
retNode = current
end
if searchAscending then
current = current.Next
else
current = current.Prev
end
end
return retNode, oldestWait
end
-- 返回处于 startNode 玩家排名范围内的玩家节点。
-- 返回的玩家同时也是范围内等待时间最长的。
function rankList:FindPlayerInRangeWithNode(startNode, range)
local oldestWait = math.huge
local rank = startNode.Rank
local current = startNode.Next
local retNode = nil
retNode, oldestWait = Search(startNode.Next, startNode.Rank, true, range, oldestWait)
retNode, oldestWait = Search(startNode.Prev, startNode.Rank, false, range, oldestWait)
return retNode
end
-- 返回在 userId 排名范围内找到玩家的用户 ID 。
-- 若未找到玩家则返回 nil 。
function rankList:FindPlayerInRange(userId, range)
local playerNode = keyTable[tostring(userId)]
if playerNode then
local otherPlayer = rankList:FindPlayerInRangeWithNode(playerNode, range)
if otherPlayer then
return otherPlayer.UserId
end
end
return nil
end
return rankList
大厅 LocalScript¶
local button = script.Parent
local lookForGameEvent = game.ReplicatedStorage.LookForGameEvent
local lookForGameNextClick = true
button.MouseButton1Click:connect(function()
if lookForGameNextClick then
button.Text = "正在寻找匹配。再次点击取消匹配"
else
button.Text = "寻找匹配。"
end
lookForGameEvent:FireServer(lookForGameNextClick)
lookForGameNextClick = not lookForGameNextClick
end)
Arena (竞技场)脚本¶
local players = {}
local kfactor = 30
local teleportService = game:GetService("TeleportService")
local ratingData = game:GetService("DataStoreService"):GetDataStore("RatingData")
local lobbyId = 181194460
local playerHasDied = false
-- 处理 PlayerAdded 事件,设置玩家表格
game.Players.PlayerAdded:connect(function(player)
-- 当玩家被击败时,需要更新玩家表格。同时也需要将
-- 全局变量 playerHasDied 设为 true 以结束对战游戏
player.CharacterAdded:connect(function(character)
character.Humanoid.Died:connect(function()
print(player.Name .. "已被击败")
players[tostring(player.userId)].Died = true
playerHasDied = true
end)
end)
-- 从 DataStore 获取玩家排名,将玩家表格内的击败状态设为 false
print("正在获取玩家数据:" .. player.Name)
local playerData = {}
playerData.Rating = ratingData:GetAsync(tostring(player.userId)).Rating
playerData.Died = false
players[tostring(player.userId)] = playerData
end)
-- 使用 Elo 等级分制度计算每个玩家的排名变化
local calculateRatingChange = function(playerA, playerB)
-- 从玩家表格获取每个玩家的排名
local playerARating = players[tostring(playerA.userId)].Rating
local playerBRating = players[tostring(playerB.userId)].Rating
-- 从玩家表格获取每个玩家的胜败
local playerADied = players[tostring(playerA.userId)].Died
local playerBDied = players[tostring(playerB.userId)].Died
-- 计算每个玩家的胜率。需要注意的是: expectedA + expectedB = 1
local expectedA = 1 / (1 + math.pow(10,(playerBRating - playerARating)/400))
local expectedB = 1 - expectedA
-- 根据玩家对战结果计算分数。注意以下值:
-- 胜 = 1
-- 平 = .5
-- 败 = 0
-- 我们以 .5 开始(设想结果为平局)。如果玩家被击败则失去 .5 分数,
-- 胜利玩家获取 .5 分数
local scoreA = .5
local scoreB = .5
if playerADied then
scoreA = scoreA - .5
scoreB = scoreB + .5
end
if playerBDied then
scoreA = scoreA + .5
scoreB = scoreB - .5
end
-- 根据分数、胜率计算每个玩家的排名变化
-- 然后以 kfactor 对变化量加以限制
local playerAChange = kfactor * (scoreA - expectedA)
local playerBChange = kfactor * (scoreB - expectedB)
return playerAChange, playerBChange
end
-- 用玩家的新排名值更新 DataStore
local adjustPlayerRating = function(player, rankingChange)
ratingData:UpdateAsync(tostring(player.userId), function(oldValue)
local newValue = oldValue
newValue.Rating = newValue.Rating + rankingChange
return newValue
end)
end
-- 等待两名玩家就绪后再降低屏障
print("正在等待玩家")
while game.Players.NumPlayers < 2 do
wait()
end
print("玩家已就绪")
-- 玩家进入游戏后,降低屏障
for _, barrier in pairs(game.Workspace.Barriers:GetChildren()) do
barrier.CanCollide = false
barrier.Transparency = 1
end
print("正在等待玩家被击败")
while not playerHasDied do
wait()
end
print("玩家已被击败!即将调整分数")
-- 稍作等待,检查是否两人都被击败,以便算为平局
wait(1)
local playerA = nil
local playerB = nil
for _, player in pairs(game.Players:GetPlayers()) do
if playerA == nil then
playerA = player
else
playerB = player
end
end
-- 计算每个玩家的排名变化
local playerAchange, playerBchange = calculateRatingChange(playerA, playerB)
print("PlayerA points should change by " .. playerAchange)
print("PlayerB points should change by " .. playerBchange)
-- 修改每个玩家的点数与排名
adjustPlayerRating(playerA, playerAchange)
adjustPlayerRating(playerB, playerBchange)
print("即将传送回大厅")
wait(5)
-- 将玩家传送回大厅
for _, player in pairs(game.Players:GetPlayers()) do
teleportService:Teleport(lobbyId, player)
end