🎉

チートシート:Charmで書くRoblox TSの状態管理

に公開

はじめに

ambrでRobloxクリエイターをしているeisukeです。
今回は、roblox-tsでの状態管理をシンプルに組み立てられるライブラリCharmを、簡単なサンプルコードでさくっと紹介します。

本文

Charmとは

Charmは、Robloxroblox-ts向けに作られた軽量の状態管理ライブラリです。基本単位のatomを中心に、派生値を計算するcomputed、状態の変化に反応して差分を監視するeffect / observeを組み合わせて使います。これにより、UIやゲームロジックの状態を、安全で予測しやすい形で扱えます。さらに、配列の要素を追従して変換するmapped、依存関係を張らずに値を参照できるpeek、複数の更新をまとめて適用するbatch、変更を購読するsubscribeといったAPIも備えています。作者のlittensyはroblox-ts製のライブラリを複数公開しており、公式サンプル(charm-example)も整っています。特に、クライアント側で多くの状態を扱う大型UI(インベントリ管理やHUDなど)で真価を発揮しそうですね。

個人的には@rbxts/reactを習得してroblox-ts界隈のモダンな開発に追従していきたいと思っています。

atomの基本

roblox-tsのセットアップが終わったら、次のコマンドでCharmを追加します。

pnpm add @rbxts/charm

atomは「getterとsetterを兼ねた関数」を返します。引数なしで現在値を取得し、値または更新関数を渡すと更新できます。まずは最小の例から。

以下はコンテナを宣言し、直接代入と更新関数の両方で値を変えて、そのたびに読み取る例です。

import { atom } from "@rbxts/charm";

const nameAtom = atom("");

task.wait(2);

nameAtom("John");

print(nameAtom());

task.wait(2);

nameAtom("Jane");

print(nameAtom());

task.wait(2);

nameAtom("Jim");

print(nameAtom());

更新関数は「前の値を受け取り、新しい値を返す」形なので、複数の更新が同時に走っても安全に合成できます。

subscribeの活用

subscribeはatomの変更を購読し、現在値と前回値を受け取ります。
UIの更新、ログ、計測イベントの送出など「変更に反応する処理」の入口として使いやすいAPIです。

以下ではnameAtomを購読し、変更のたびに現在値と前回値をログします。返り値のcleanupを呼ぶと購読が解除され、その後の更新(例: "Jill"への変更)は通知されません。

import { atom, subscribe } from "@rbxts/charm";

const nameAtom = atom("");

const cleanup = subscribe(nameAtom, (value: string, previous: string) => {
	print(`Subscribe: ${value} - ${previous}`);
});

task.wait(2);

nameAtom("John");

task.wait(2);

nameAtom("Jane");

task.wait(2);

nameAtom("Jim");

cleanup();

task.wait(2);

nameAtom("Jill");

不要になったらcleanup()を呼んで購読を解除します。

effectの活用

effectは依存するatomを自動で追跡し、変更時に副作用を実行します。
GUIの更新やアニメーション開始、ネットワーク呼び出しなど「外部作用」をまとめるのに向いています。

以下はnameAtomcountAtomをコールバック内で参照し、両方を追跡する例です。

import { atom, effect } from "@rbxts/charm";

const nameAtom = atom("empty");
const countAtom = atom(0);

const cleanup = effect(() => {
	print(`Effect: ${nameAtom()} - ${countAtom()}`);
});

task.wait(2);

nameAtom("John");

task.wait(2);

countAtom(countAtom() + 1);

task.wait(2);

nameAtom("Jane");

task.wait(2);

countAtom(countAtom() + 1);

task.wait(2);

nameAtom("Jim");

task.wait(2);

countAtom(countAtom() + 1);

cleanup();

task.wait(2);

nameAtom("Jill");

依存関係はコールバック内で参照したatomから自動的に収集されます。
同じeffect内で参照したatomを即時に書き換えるとループになることがあるため、条件分岐やbatchで再実行を抑えるのが無難です。

computedで派生値を作る

computedは依存するatomから派生値を作り、依存が変わると再計算されます。
重い計算やフォーマット処理をUI側で毎回書かずに済むので、表示ロジックの簡潔化とパフォーマンスの両立に役立ちます。

以下ではbasePriceAtomdiscountAtomtaxRateAtomを元にfinalPriceAtomを計算し、effectで最終価格をログ出力します。値を更新すると派生値が自動で再計算されます。

import { atom, computed, effect } from "@rbxts/charm";

const basePriceAtom = atom(100);
const discountAtom = atom(0.15);
const taxRateAtom = atom(0.1);

const finalPriceAtom = computed(() => {
	const discounted = basePriceAtom() * (1 - discountAtom());
	return math.round(discounted * (1 + taxRateAtom()));
});

const cleanup = effect(() => {
	print(`Final price is ${finalPriceAtom()} Robux`);
});

task.wait(2);
basePriceAtom(120);
task.wait(2);
discountAtom(0.2);
task.wait(2);
taxRateAtom(0.08);

cleanup();

返り値の関数で要素のクリーンアップ処理を行えます。

observeで差分検知

observeはコレクションの追加/削除を要素単位で検知できます。
NPCのスポーン/デスポーンや、プレイヤーのインベントリ項目の追加/削除のような「入れ替わり」を伴う状態に適しています。

以下ではtodosAtomidをキーにした辞書)を監視し、追加時にログし、返り値のクリーンアップ関数で削除時の処理を行います。差分はaddTodoremoveTodoが発生させます。

import { atom, observe } from "@rbxts/charm";

type Todo = {
	id: string;
	name: string;
};

const todosAtom = atom<Record<string, Todo>>({});

const cleanup = observe(todosAtom, (todo, key) => {
	print(`Added ${key}: ${todo.name}`);

	return () => {
		print(`Removed ${key}`);
	};
});

const addTodo = (id: string, name: string) => {
	const nextTodos = table.clone(todosAtom());
	nextTodos[id] = { id, name };
	todosAtom(nextTodos);
};

const removeTodo = (id: string) => {
	const nextTodos = table.clone(todosAtom());
	delete nextTodos[id];
	todosAtom(nextTodos);
};

task.wait(2);
addTodo("todo-1", "Collect coins");

task.wait(2);
addTodo("todo-2", "Upgrade tool");

task.wait(2);
removeTodo("todo-1");

task.wait(2);
removeTodo("todo-2");

cleanup();

返り値の関数で要素のクリーンアップ処理を行えます。

mappedで派生配列を扱う

mappedは配列atomから派生配列を作成し、元配列の変更に追従します。
UIの表示用ラベルやサマリ値の生成など、「読み取り専用の派生配列」を作る用途に向いています。

以下では、配列のtodosAtomからmappedでラベル配列todoLabelsAtomを作り、logTodosで出力します。要素の追加や更新に応じてラベルも追従します。

import { atom, mapped } from "@rbxts/charm";

type Todo = {
	id: string;
	name: string;
};

const todosAtom = atom<Array<Todo>>([
	{ id: "todo-1", name: "Collect coins" },
	{ id: "todo-2", name: "Upgrade tool" }
]);

const todoLabelsAtom = mapped(todosAtom, (todo, index) => `${index + 1}. ${todo.name}`);

const logTodos = () => {
	print("mapped → labels:");
	todoLabelsAtom().forEach((label) => print(label));
};

task.wait(2);
logTodos();

task.wait(2);
todosAtom([...todosAtom(), { id: "todo-3", name: "Explore cave" }]);

task.wait(2);
todosAtom(todosAtom().map((todo) => (todo.id === "todo-1" ? { ...todo, name: `${todo.name} (updated)` } : todo)));

task.wait(2);
logTodos();

マッピング関数はできるだけ純粋に保つと、挙動の予測が容易でデバッグもしやすくなります。

peekで依存関係を切り離す

peekは依存関係に含めずに値を一時参照します(effectの再実行条件に影響しません)。
「現在の値は見たいが、リアクティブな依存にはしたくない」という補助的な読み取りに適しています。

以下はeffect内でnameAtomは依存として追跡し、ageAtompeekで非依存参照します。年齢の変更では再実行されず、名前の変更でのみ再実行されます。

import { atom, effect, peek } from "@rbxts/charm";

const nameAtom = atom("John");
const ageAtom = atom(25);

const cleanupPeek = effect(() => {
	const name = nameAtom();
	const age = peek(ageAtom);
	print(`Peek effect → name=${name}, age=${age}`);
});

task.wait(2);

ageAtom(26);

task.wait(2);

nameAtom("Jane");

cleanupPeek();

依存として追跡されないので、動的な再計算を期待する場面ではpeekではなく通常の参照を使いましょう。

batchでまとめて更新

batchは複数の更新を1つの反応サイクルにまとめ、無駄な再計算を抑えます。
フォーム入力の一括反映や、複数のatomを同時に変更するケースで体感のカクつきを抑えられます。

以下のコードではaAtombAtomを個別更新後、batchで同時に加算しています。batch内の更新は1回の反応サイクルにまとまり、effectの再評価が抑制されます。

import { atom, effect, batch } from "@rbxts/charm";

const aAtom = atom(0);
const bAtom = atom(0);

const cleanup = effect(() => {
	print(`Batch effect → a=${aAtom()} b=${bAtom()}`);
});

task.wait(2);
aAtom((prev) => prev + 1);
bAtom((prev) => prev + 1);

task.wait(2);
batch(() => {
	aAtom((prev) => prev + 1);
	bAtom((prev) => prev + 1);
});

cleanup();

多数のatom更新が連鎖する処理では、batchの有無でcomputedeffectの再評価回数が大きく変わります。

おわりに

本稿では atom / subscribe / effect / computed / observe / mapped / peek / batch の基本を駆け足で紹介しました。

Charmはクライアント主導で状態が増えやすい場面に相性がよさそうですね。サーバー/クライアント同期など拡張も用意されているのでまたの機会に紹介しようと思います。

より実践的な使い方や規模の大きい構成は、公式サンプルを参照してください(charm-example)。

GitHubで編集を提案
ambr Tech Blog

Discussion