【Roblox】常にサーバー側のデータが同期されるModuleのつくりかた

2025/02/28に公開

1. はじめに

Robloxでゲーム制作を行う際、データを保持しておくだけのModuleScriptを作成して利用した事がある方は多いと思います。
例えば、キャラクターの下にCloneしたModuleScriptで、キャラクター毎のパラメータをテーブルにして保持しておく、といった使い方です。
ただしこれには欠点があり、ModuleScriptの中の情報は同期されないため、クライアントでrequireしたModuleScriptではサーバー側から書き込んだデータは反映されません。

通常の対応の場合、クライアント側でデータが必要な時はRemoteEventやRemoteFunctionを用いて情報を取得できるようにするわけですが、キャラクター下には使えないModuleScriptが残ったままでrequireはできてしまうので、うっかりクライアント側で取得しようとして間違いに気付くのが遅れてしまったり…
じゃあクライアント側で毎回削除する仕組みを作る? それともGet関数をRunServiceのIsClientで呼び分けて、クライアントの場合は事前に用意しておいたRemoteFunctionでサーバーから情報取得する? 等、地味に手間がかかります。
場合によっては、情報を取得するタイミングで毎回通信したくない、といった事情もあるかもしれません。

データを置いてるだけのModuleScriptなんだから、プロパティみたいにクライアントでも常に同期されてそのまま取得できれば便利なのに!
というわけで今回は、この問題に対する簡易的な解決策の一つをご紹介します。

バージョン:0.658.0.6580461

2. つくりかた

ModuleScriptの作成

まずは、適当な所に新しいModuleScriptを追加して、ごくごく単純なデータ保持Moduleを作成します。
名前はSynchronizationDataModuleとしておきましょう。

SynchronizationDataModule
--!strict

local SynchronizationDataModule = {}

-- 型を定義.
export type Data = {
	Name: string,
	Attack: number,
	Defense: number
}

-- 保持データ.
local data_ = {
	Name = "",
	Attack = 0,
	Defense = 0
}::Data

-- 保持データを返します.
function SynchronizationDataModule:GetData():Data
	-- tableの中にtableが無いのでtable.cloneで問題無くコピーできる.
	return table.clone(data_)
end

-- 保持データを設定します.
function SynchronizationDataModule:SetData(data: Data)
	data_ = table.clone(data)
end

return SynchronizationDataModule

これを改造して、同期の仕組みを作っていきましょう。

属性を利用する

同期の仕組みにはInstanceに付けることのできる属性を利用します。
サーバー側で生成されたInstanceの属性は、プロパティと同様にサーバー側で変更されるとクライアントにもその変更が複製されます。
これを利用すれば、プロパティと同じ基準でデータを同期させることができます。

ではまず、string型の属性を一つ追加してください。
今回はただのサンプルですので、名前は「Data」にしておきます。

「これだとstring型のデータが一つ入れられるだけなのでは?」と思うかもしれませんが、色んなデータを一つのstringにまとめてしまう方法があります。

JSON形式を利用する

それがJSON形式です。
JSON形式そのものについては詳しくは説明しませんが、RobloxはHttpService:JSONEncodeHttpService:JSONDecodeを用いて簡単にJSON形式のstringとtableの変換を行うことができます。
これを利用して、string型の属性と保持データを一致させる処理を作成していきます。

SynchronizationDataModule
--!strict
local SynchronizationDataModule = {}

-----------------------------------ここから追記.
-- HttpServiceを取得.
local HttpService = game:GetService("HttpService")

-- 属性名を定数で保持しておく.
local ATTRIBUTE_NAMES = {
	Data = "Data"::"Data"
}
-----------------------------------ここまで追記.

-- 型を定義.
type Data = {
	Name: string,
	Attack: number,
	Defense: number
}

-- 保持データ.
local data_ = {
	Name = "",
	Attack = 0,
	Defense = 0
}::Data

-- 保持データを返します.
function SynchronizationDataModule:GetData():Data
	-- tableの中にtableが無いのでtable.cloneで問題無くコピーできる.
	return table.clone(data_)
end

-----------------------------------ここから追記.
-- 保持データを設定します.
function SynchronizationDataModule:SetData(data: Data)
	-- JSONに変換する.
	local success, json = pcall(HttpService.JSONEncode, HttpService, data)
	if not success then
		return
	end

	-- 直接値を入れるのではなく、属性設定を介する.
	script:SetAttribute(ATTRIBUTE_NAMES.Data, json)
end

-- 属性を保持データに変換します.
local function attributeToData(attributeName: string)
	-- 想定している属性かチェック.
	if not ATTRIBUTE_NAMES[attributeName] then
		return
	end

	-- JSONからtableに変換.
	local success, newData = pcall(HttpService.JSONDecode, HttpService, script:GetAttribute(attributeName))

	-- 変換に成功したらデータを保持.
	if success then
		-- 保持しているDataのキーと同じキーが存在する場合のみ、データを反映.
		for key in pairs(data_) do
			if newData[key] then
				data_[key] = newData[key]
			end
		end
	else
		-- 失敗したら、不正な値として現在の情報で属性を設定しなおす.
		script:SetAttribute(attributeName, HttpService:JSONEncode(data_))
	end
end

-- AttributeChangedイベントで、属性に変更があったら保持データに反映する.
script.AttributeChanged:Connect(function(attributeName: string)
	if not ATTRIBUTE_NAMES[attributeName] then
		return
	end

	attributeToData(attributeName)
end)
-----------------------------------ここまで追記.

return SynchronizationDataModule

これで、属性と保持データが一致するようになりました!

ただし、JSONに変換できるデータ型には制約があり、Luaの基本データ型であるnumber, string, boolean, tableのみとなっています。
また、tableについても、文字列をキーとしたいわゆるDictionaryか、1から始まる連番のnumberをキーとしたいわゆるArrayのどちらかである必要があります。
今回は簡易的な対応なのでこのままで進めますが、例えばVector3として扱いたいデータがあるなら3つのnumberの配列で変換したり、個別にVector3型の属性を用意したりする、Objectの参照が欲しい場合はObjectValueを利用する等、なんらかの対応が必要になります。

初期化する

一見これでうまく機能しそうに見えますが、このままだとクライアント側でrequireするより前にサーバー側で属性に情報が書き込まれている場合、まだAttributeChangedイベントに処理が接続されていないため、その属性の内容が保持データには反映されません。
そこで、初めてrequireされた時に実行する初期化処理を追加します。

SynchronizationDataModule
--!strict
local SynchronizationDataModule = {}

-- HttpServiceを取得.
local HttpService = game:GetService("HttpService")

-- 属性名を定数で保持しておく.
local ATTRIBUTE_NAMES = {
	Data = "Data"::"Data"
}

-- 型を定義.
type Data = {
	Name: string,
	Attack: number,
	Defense: number
}

-- 保持データ.
local data_ = {
	Name = "",
	Attack = 0,
	Defense = 0
}::Data


-- 保持データを返します.
function SynchronizationDataModule:GetData():Data
	-- tableの中にtableが無いのでtable.cloneで問題無くコピーできる.
	return table.clone(data_)
end

-- 保持データを設定します.
function SynchronizationDataModule:SetData(data: Data)
	-- JSONに変換する.
	local success, json = pcall(HttpService.JSONEncode, HttpService, data)
	if not success then
		return
	end

	-- 直接値を入れるのではなく、属性設定を介する.
	script:SetAttribute(ATTRIBUTE_NAMES.Data, json)
end

-- 属性を保持データに変換します.
local function attributeToData(attributeName: string)
	-- 想定している属性かチェック.
	if not ATTRIBUTE_NAMES[attributeName] then
		return
	end

	-- JSONからtableに変換.
	local success, newData = pcall(HttpService.JSONDecode, HttpService, script:GetAttribute(attributeName))

	-- 変換に成功したらデータを保持.
	if success then
		-- 保持しているDataのキーと同じキーが存在する場合のみ、データを反映.
		for key in pairs(data_) do
			if newData[key] then
				data_[key] = newData[key]
			end
		end
	else
		-- 失敗したら、不正な値として現在のデータで属性を設定しなおす.
		script:SetAttribute(attributeName, HttpService:JSONEncode(data_))
	end
end

-- AttributeChangedで、属性に変更があったら保持データに反映する.
script.AttributeChanged:Connect(function(attributeName: string)
	if not ATTRIBUTE_NAMES[attributeName] then
		return
	end

	attributeToData(attributeName)
end)

-----------------------------------ここから追記.
-- 現在の属性の情報で保持データを初期化します.
local function init()
	for _, name in pairs(ATTRIBUTE_NAMES) do
		attributeToData(name)
	end
end

-- 初期化.
init()
-----------------------------------ここまで追記.

return SynchronizationDataModule

これで、サーバー側の情報が常に同期されるModuleができました!

3. まとめ

  • 属性はサーバー側の変更が常にクライアントに複製される
  • 属性に保持データを一致させることで、クライアント側の保持データをサーバーと同期させられる
  • クライアントでrequireする前に属性が変更されていた場合に備えて、初期化処理が必要

今回は同期するだけが目標なのでJSON形式でひとまとめにしましたが、パラメータ毎に個別に属性を用意するパターンも、プロパティウィンドウから情報を見たり編集したりしやすくなるのでアリだと思います。
また、BindableEventを持たせて保持している値の変更が完了した時に発火するようにしておくのも便利です。
必要に応じて、カスタマイズして利用してみてください!
最後までお読みいただき、ありがとうございました!

4. 参考

https://create.roblox.com/docs/studio/properties#instance-attributes
https://create.roblox.com/docs/scripting/attributes
https://create.roblox.com/docs/reference/engine/classes/HttpService

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

Discussion