🌐

【Roblox】MemoryStore SortedMapを利用したグローバルランキング表示

に公開

はじめに

今回は、MemoryStoreのデータ構造の一つであるSortedMapを利用して、エクスペリエンス全体(サーバーの隔たりがない)のランキングの作成方法を紹介しようと思います。

MemoryStoreの概要については以下の記事をご覧ください。
https://zenn.dev/landho_roblox/articles/4d3fcbc648beb1

DataStoreを利用したランキングの実装もあります。それぞれ一長一短なので、ぜひご覧ください。
https://zenn.dev/landho_roblox/articles/6c36cf133ce333

Roblox バージョン: 0.661.0.6610708

MemoryStoreSortedMapについて

MemoryStoreのデータ構造は、SortedMapQueueHashMapと3種類あります。
共通して、設定するデータには有効期限があり最大で45日です。

利用するにあたって各種制限などもありますが、これは概要編にて記載したので省きます。
詳しく知りたい方は、冒頭の「MemoryStoreの概要」リンクからご覧いただけます。

このデータ構造の一つであるSortedMapでは、MemoryStoreSortedMapインスタンスを利用してデータをソートした状態で一時保存することができます。

また、Queueとは異なりSortedMapは登録順ではなくソートキーの順序で管理されるため、ランキング作成に適しています。

SortedMapを利用したランキング機能の実装の一例を紹介します。

SortedMapを管理するモジュールスクリプトを作成する

まずは、SortedMapを管理するモジュールスクリプトを作成します。

MemoryStoreSortedMapの取得

モジュールスクリプトを作成して、MemoryStoreSortedMapを取得します。

SortedMapManager
-- MemoryStore SortedMap の管理

-- Service
local MemoryStoreService = game:GetService("MemoryStoreService")

-- 保存期間 一週間
local EXPIRATION = 86400 * 7

-- 使用するSortedMap
local sortedMap = MemoryStoreService:GetSortedMap("ScoreRanking")

local SortedMapManager = {}

return SortedMapManager

MemoryStoreSortedMapのインスタンスを作成、取得するにはMemoryStoreService:GetSortedMap() を使用します。

GetSortedMap function 名称 説明
引数 name string MemoryStoreSortedMap
戻り値 MemoryStoreSortedMap MemoryStoreSortedMap 指定された名前のMemoryStoreSortedMapインスタンス。

これに加え、今回はデータの保存期間を仮として1週間とし定数で保持します。

データの取得

データを取得するにはMemoryStoreSortedMap:GetAsyncを利用します。

SortedMapManager
-- SortedMapに登録された値とソートキーを返す
function SortedMapManager:GetScore(player: Player): (string?, number?)
	local key = tostring(player.UserId)

	local success, value, sortKey = pcall(function()
		return sortedMap:GetAsync(key)
	end)
	
	if success and value and sortKey then
		print("GetAsync Success", key, value, sortKey)
	else
		print("GetAsync Error or No Data", key)
	end
	
	return value, sortKey
end
GetAsync function 名称 説明
引数 key string 取得したいデータのキー
戻り値 value, sortKey Tuple(Variant, Variant) キーに対して保存された値とソートキーを返す。キーに対して保存されたデータがない場合は、それぞれnilになる。

戻り値は設定された値とソートキーが返されます。

データを範囲で取得

データを範囲指定で取り出したい場合は、MemoryStoreSortedMap:GetRangeAsync() を使用します。

SortedMapManager
-- SortedMapに登録されたキー、値、ソートキーを範囲で取り出しテーブルで返す
function SortedMapManager:GetScoreInRange(
	direction: Enum.SortDirection, 
	count: number
): { { key: string, value: string, sortKey: number } }

	local exclusiveLowerBound = { sortKey = 0 }
	local exclusiveUpperBound = nil

	local success, items = pcall(function()
		return sortedMap:GetRangeAsync(direction, count, exclusiveLowerBound, exclusiveUpperBound)
	end)

	if success and items then
		print("GetRangeAsync Success", items)
		return items
	else
		print("GetRangeAsync Error or No Data")
		return {} -- 失敗時は空のテーブルを返す
	end
end
GetRangeAsync function 名称 説明
引数 direction Enum.SortDirection 取得する方向。昇順or降順。
引数 count number 取得したい個数。最大値は200。
引数 exclusiveLowerBound { key:string, sortKey: Variant } or nil 返されるキー、ソートキーの下限を設定できる。(自身は含まれない。)
引数 exclusiveUpperBound { key:string, sortKey: Variant } or nil 返されるキー、ソートキーの上限を設定できる。 (自身は含まれない。)
戻り値 key, value, sortKeyの配列 Array キー、値、ソートキーを1要素としたカウント数分の配列

戻り値の配列は下記のような形になります。

{
    [1] = {
        ["key"] = "1",
        ["sortKey"] = 8352,
        ["value"] = "Player3"
    },
    [2] = {
        ["key"] = "2",
        ["sortKey"] = 7518,
        ["value"] = "Player1"
    },
    [3] = {
        ["key"] = "3",
        ["sortKey"] = 3397,
        ["value"] = "Player2"
    },
    -- ... (指定した個数分データが続く)
}

引数のexclusiveLowerBoundexclusiveUpperBound を指定すると、取得するデータの範囲を制限できます。指定したキー・ソートキー自体は取得結果に含まれません。
例)local exclusiveLowerBound = { sortKey = 0 } と設定した場合、1以上のデータが対象となる。

特に指定しない場合はnilを指定しましょう。

データを設定・保存する

データを設定したい場合は、MemoryStoreSortedMap:SetAsync() を利用します。

SortedMapManager
-- SortedMapにスコアを登録する
function SortedMapManager:SetScore(player: Player, score: number): boolean
	local key = tostring(player.UserId)
	local value = player.Name

	local success, result = pcall(function()
		return sortedMap:SetAsync(key, value, EXPIRATION, score)
	end)

	if success and result then
		print("SetAsync Success", key, value, score)
		return true
	else
		print("SetAsync Error", key)
		return false
	end
end
SetAsync function 名称 説明
引数 key string 設定したいキー。
引数 value Variant 設定したい値。
引数 expiration number 設定したアイテムの有効期限(秒単位)。有効期限が切れると、アイテムは自動的に削除される。最大有効期限は 45日間(3,888,000 秒)。
引数 sortKey Variant(number or string)
戻り値 boolean boolean 処理が成功したかどうか。

expirationとして秒単位で有効期限を設定する必要があります。
有効期限を長くしてメモリサイズを抑えるか、有効期限を短くしてフルサイズに近いサイズを使用するか、ニーズによって決めましょう。
詳しくは前述した「MemoryStoreの概要編」の記事をご覧ください。

今回は、キーにUserId、値として表示用にプレイヤー名、ソート用にスコアを設定しています。
ここは場合によって変わると思うので臨機応変に対応しましょう。

他サーバーとの競合を考慮したデータの設定・保存

MemoryStoreSortedMap:SetAsyncはキーに対してデータを上書きします。
同一のMemoryStoreSortedMapが他サーバーから呼ばれた際に、同じキーに対して同時に上書きが行われた時片方のデータが考慮されない場合があります。
これを解消すべく、DataStoreService同様MemoryStoreSortedMap:UpdateAsync() が用意されています。

SortedMapManager
-- SortedMapにUpdateAsyncを利用してスコアを登録する
function SortedMapManager:UpdateScore(player: Player, score: number): boolean
	local key = tostring(player.UserId)
	local value = player.Name

	local success, newValue, newSortKey = pcall(function()
		return sortedMap:UpdateAsync(
			key, 
			function(oldValue: string?, oldSortKey: number?)
				
				if oldValue == nil and oldSortKey == nil then
					print("Save New Data")	
				end
				return value, score
			end, 
			EXPIRATION)
	end)
	
	if success and newValue and newSortKey then
		print("UpdateAsync Success", key, newValue, newSortKey)
		return true
	else
		print("UpdateAsync Error", key)
		return false
	end
end
UpdateAsync function 名称 説明
引数 key string 設定したいキー。
引数 transformFunction function 変換関数。キーの古い値と古いソートキーを引数として受け取り、新しい値と新しいソートキーを返す。
引数 expiration number 設定したアイテムの有効期限(秒単位)。有効期限が切れると、アイテムは自動的に削除される。最大有効期限は 45日間(3,888,000 秒)。
戻り値 value, sortKey Tuple(Variant, Variant) 変換関数によって返された、最新の値とソートキーを返す。キーに対して保存されたデータがない場合は、それぞれnilになる。

transformFunctionは引数として更新前の古い値とソートキーを渡し、戻り値として設定した新しい値とソートキーを返します。

この時、キーが読み取られた後に他サーバーなどから値やソートキーが変更されると、UpdateAsyncはリトライを試みます。
このループは正しく値とソートキーが設定されるか、変換関数内からnilを返すまで続きます。

このリトライはAPIのアクセス制限数を消費します。
APIのアクセス制限などに不安がある場合は、ループ回数を計測し制限をかけるなどを行ってもよいかもしれません。

インベントリのような単独のプレイヤーからのみ呼ばれる場合はSetAsync、ランキングのような複数のサーバーから呼ばれる場合はUpdateAsyncと使い分けましょう。

データの削除

データを削除したい場合は、MemoryStoreSortedMap:RemoveAsync() を利用します。

SortedMapManager
-- SortedMapからデータを削除する
function SortedMapManager:RemoveScore(player: Player): boolean
	local key = tostring(player.UserId)
	
	local success, errorMessage = pcall(function()
		sortedMap:RemoveAsync(key)
	end)
	
	if not success and errorMessage then
		print("RemoveAsync Error:", errorMessage)
	else
		print("Removed Data", key)
	end
	
	return success
end
RemoveAsync function 名称 説明
引数 key string 削除したいキー。
戻り値 - - なし。

データを設定する際に有効期限を設定していますが、それが経過する前に削除したい場合は利用しましょう。
エクスペリエンス全体で使用できるメモリ量が決まっているので無駄なデータを省くことで最適化できます。

全てのデータの削除が必要な場合は、GetRangeAsyncを利用してキーを取得し削除しましょう。

SortedMapを管理するModuleの完成

主にpcallで囲んだくらいですが、これでSortedMapを管理するModuleが完成しました。
必要に応じて呼び出すことで、MemoryStoreへの保存・呼び出しが可能です。
最終的なコードは下記です。

SortedMapManager
SortedMapManager
-- MemoryStore SortedMap の管理

-- Service
local MemoryStoreService = game:GetService("MemoryStoreService")

-- 保存期間 一週間
local EXPIRATION = 86400 * 7

-- 使用するSortedMap
local sortedMap = MemoryStoreService:GetSortedMap("ScoreRanking")


local SortedMapManager = {}

-- SortedMapに登録された値とソートキーを返す
function SortedMapManager:GetScore(player: Player): (string?, number?)
	local key = tostring(player.UserId)

	local success, value, sortKey = pcall(function()
		return sortedMap:GetAsync(key)
	end)
	
	if success and value and sortKey then
		print("GetAsync Success", key, value, sortKey)
	else
		print("GetAsync Error or No Data", key)
	end
	
	return value, sortKey
end

-- SortedMapに登録されたキー、値、ソートキーを範囲で取り出しテーブルで返す
function SortedMapManager:GetScoreInRange(
	direction: Enum.SortDirection, 
	count: number
): { { key: string, value: string, sortKey: number } }

	local exclusiveLowerBound = { sortKey = 0 }
	local exclusiveUpperBound = nil

	local success, items = pcall(function()
		return sortedMap:GetRangeAsync(direction, count, exclusiveLowerBound, exclusiveUpperBound)
	end)

	if success and items then
		print("GetRangeAsync Success", items)
		return items
	else
		print("GetRangeAsync Error or No Data")
		return {} -- 失敗時は空のテーブルを返す
	end
end

-- SortedMapにスコアを登録する
function SortedMapManager:SetScore(player: Player, score: number): boolean
	local key = tostring(player.UserId)
	local value = player.Name

	local success, result = pcall(function()
		return sortedMap:SetAsync(key, value, EXPIRATION, score)
	end)

	if success and result then
		print("SetAsync Success", key, value, score)
		return true
	else
		print("SetAsync Error", key)
		return false
	end
end

-- SortedMapにUpdateAsyncを利用してスコアを登録する
function SortedMapManager:UpdateScore(player: Player, score: number): boolean
	local key = tostring(player.UserId)
	local value = player.Name

	local success, newValue, newSortKey = pcall(function()
		return sortedMap:UpdateAsync(
			key, 
			function(oldValue: string?, oldSortKey: number?)
				
				if oldValue == nil and oldSortKey == nil then
					print("Save New Data")	
				end
				return value, score
			end, 
			EXPIRATION)
	end)
	
	if success and newValue and newSortKey then
		print("UpdateAsync Success", key, newValue, newSortKey)
		return true
	else
		print("UpdateAsync Error", key)
		return false
	end
end

-- SortedMapからデータを削除する
function SortedMapManager:RemoveScore(player: Player): boolean
	local key = tostring(player.UserId)
	
	local success, errorMessage = pcall(function()
		sortedMap:RemoveAsync(key)
	end)
	
	if not success and errorMessage then
		print("RemoveAsync Error:", errorMessage)
	else
		print("Removed Data", key)
	end
	
	return success
end

return SortedMapManager

実際に使用する

実際に一定時間ごとに呼び出し表示してみます。
MemoryStoreSortedMap:GetRangeAsync()を利用してデータを取得しUIへの表示を行っています。
長くなるので実際にランキングボードに表示する仕組みは省きます。

SetUp
-- SortedMap を利用したランキング表示

-- Service
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

-- Module
local SortedMapManager = require(script.Parent:WaitForChild("SortedMapManager"))

-- BindableEvent
local UpdateRankingBindableEvent = script:WaitForChild("UpdateRanking")

-- ReadOnly
local UPDATE_INTERVAL = 60	-- 更新頻度(秒)
local DISPLAY_MAX_NUM = 100 	-- 表示する最大個数

-- 次回更新時間
local nextUpdateTimestamp = DateTime.now().UnixTimestamp + UPDATE_INTERVAL


-- Player入場時の処理
local function onPlayerAdded(player: Player)

	-- 乱数をスコアとして登録
	local score = Random.new():NextInteger(0, 10000)

	local saveSuccess = SortedMapManager:UpdateScore(player, score)	
	
	if saveSuccess then
		
		local rankingItems = SortedMapManager:GetScoreInRange(Enum.SortDirection.Descending, DISPLAY_MAX_NUM)

		-- ランキングボードの更新
		UpdateRankingBindableEvent:Fire(rankingItems)	
	end
end

-- 更新処理
local function onUpdate(deltaTime: number)
	
	local nowTimestamp = DateTime.now().UnixTimestamp
	
	if nowTimestamp < nextUpdateTimestamp then
		return
	end
	
	nextUpdateTimestamp = nowTimestamp + UPDATE_INTERVAL
	
	local rankingItems = SortedMapManager:GetScoreInRange(Enum.SortDirection.Descending, DISPLAY_MAX_NUM)
	
	-- ランキングボードの更新
	UpdateRankingBindableEvent:Fire(rankingItems)	
end


Players.PlayerAdded:Connect(onPlayerAdded)
RunService.Heartbeat:Connect(onUpdate)


よくあるランキングボードができた

まとめ

  • MemoryStoreSortedMapを利用することで、データをソートした状態で一時保存することができる。
  • MemoryStoreService:GetSortedMap()を使用することで、MemoryStoreSortedMapを取得することができる。
  • MemoryStoreSortedMap:GetAsync()を使用することで、設定されているデータ・ソートキーを取得できる。
  • MemoryStoreSortedMap:GetRangeAsync()を使用することで、指定の範囲内のデータをキー・データ・ソートキーを要素としたテーブルの配列で取得できる。
  • MemoryStoreSortedMap:SetAsync()を使用することで、キーに対応したデータ・ソートキーを保存できる。
  • MemoryStoreSortedMap:UpdateAsync()MemoryStoreSortedMap:SetAsync()に比べて、他サーバーとの競合を回避ししつつ値を変更・追加できる。
    ただし、APIのアクセス制限が肥大する可能性があるので注意が必要。
  • MemoryStoreSortedMap:RemoveAsync()を使用することで、指定のキーに対応したデータ・ソートキーを削除できる。不要なデータは削除することで、エクスペリエンス内で利用できるデータ量を最適化できる。

長い記事になりましたが、MemoryStoreServiceのSortedMapについては以上になります。
Robloxでのグローバルなランキング表示は多く見られます。
ぜひ実装してプレイヤー間の競争が盛り上がるゲームを作りましょう!!!

参考

https://create.roblox.com/docs/players/leaderboards
https://create.roblox.com/docs/cloud-services/memory-stores/sorted-map
https://create.roblox.com/docs/reference/engine/classes/MemoryStoreService
https://create.roblox.com/docs/reference/engine/classes/MemoryStoreSortedMap

ランド・ホー Roblox開発チーム

Discussion