ClusterScriptでも高階関数を使うのです
この記事は「Cluster Script Advent Calendar 2024」の10日目です
昨日はa.satoさんの「第二回ゆるゲームジャムトライ」でした!
send/receiveは本当こんがらがって大変ですよね…。
こんばんは!かおもです!!!!!!
まずはこちらのコードをご覧ください。
インタラクトされたら実行する(
Playerのジャンプ力を指定倍にする(2)
);
毎Frame実行する(
Playerのジャンプ力を指定秒後に元に戻す(10)
);
そうです、これはClusterScriptのコードです。
これ、なんと動きます!!!
これを動くものにできるのが、高階関数の力なのです!!!!!
高階関数とは関数を返す関数
詳細は「高階関数」で検索してください。
(関数を引数に取る関数も、高階関数です)
具体的にはこういうものです。
const CountingMessage = () => {
let count = 0;
return (body) => { //bodyを引数にする関数を返している。
count++;
return `${count}:${body}`; //bodyを使ってstringを返している。
};
};
const message = CountingMessage();
$.log(message("OK")); //"1:OK"と表示される。
$.log(message("OK")); //"2:OK"と表示される。
CountingMessage
がreturn
で関数を返しているのが分かります。
つまり message
はbody
を引数に取る関数となっています。
関数を返す、ということはもちろんこうも書けます。
const CountingMessage = () => {
let count = 0;
const message = (body) => { //関数を定義
count++;
return `${count}:${body}`;
};
return message; //定義した関数を返す
};
そのまま書くとちょっと面倒な関数になる
高階関数の利点を見る前に、まず高階関数を使わずに同様な関数を書いてみます。
先の例と同じようなメッセージを返せる関数を考えてみます。
let messageCount = 0;
const message = (body) => {
messageCount++;
return `${messageCount}:${body}`;
};
$.log(message("OK")); //"1:OK"と表示される。
$.log(message("OK")); //"2:OK"と表示される。
あまり規模が大きくないプログラムであれば、これで良さそうに思います。
しかしまず、この処理のために messageCount
をグローバルスコープに置くのが面倒です。
さらに、例えば warningCount
でログを作りたい時にも面倒です。
このあたりの面倒を解決するのに高階関数が使えます。
利点:変数を抱え込める
「高階関数」で検索されると色々利点が出てくると思います。
そのうちのこれは「クロージャ」です。
上の例をもう一度見てみます。
let messageCount = 0;
const message = (body) => {
messageCount++;
return `${messageCount}:${body}`;
};
カウンター用の変数messageCount
が処理message
の(スコープの)外に出ています。
大抵の場合、この変数はグローバルスコープ相当のスコープにいることでしょう。
関数ならまだしも、変数がグローバルスコープ相当に居座られるのは面倒です。
では高階関数で書いた場合と比較してみます。
const CountingMessage = () => {
let count = 0;
return (body) => { //bodyを引数にする関数を返している。
count++;
return `${count}:${body}`; //bodyを使ってstringを返している。
};
};
こちらもカウンター用の変数count
が処理の外に出ています。
しかし CountingMessage
の関数のスコープ内に収まっています。
さらにreturn
で返される関数は、このcount
を抱えたまま返されます(クロージャです)。
そのため、以下のように返された関数message
を実行する度にcount
が増えていきます。
const message = CountingMessage();
$.log(message("OK")); //"1:OK"と表示される。
$.log(message("OK")); //"2:OK"と表示される。
利点:使い回すこともできる
先の例で、例えばmessageCount
とwarningCount
のログを作りたい場合を考えます。
let warningCount = 0;
const messageWaning = (body) => {
warningCount++;
return `${warningCount}:${body}`;
};
大抵の場合、こんな感じで関数が一つ増えるでしょう。
しかし先程の高階関数であれば、こう書けてしまいます。
const message = CountingMessage();
const messageWarning = CountingMessage();
$.log(message("OK")); //"1:OK"と表示される。
$.log(message("OK")); //"2:OK"と表示される。
$.log(messageWarning("Warn")); //"1:Warn"と表示される。3:Warnではない。
$.log(messageWarning("Warn")); //"2:Warn"と表示される。
これは、CountingMessage
を実行する度に新しいcount
が作られ、その新しいcount
を抱えた関数が返ってくるため、このように書けるというわけです。
あれ?つまりこれは劣化classでは?
そうとも言えそうです。
しかし、内部の状態を変更しない高階関数となってくると、だいぶ違う使い方ができるので、適材適所でしょうか?
視点:準備と実行を切り離せる
先程の例ではあまりはっきりとしていませんでしたが、筆者は、「準備と実行を切り離せる」 という視点が気に入っています。
これは同様にclassにもあります。
一つの関数を書いてみます。
const IntervalRunner = (stateElapseKey, interval, runner) => {
return (deltaTime) => {
if(($.state[stateElapseKey] += deltaTime) < interval) return;
$.state[stateElapseKey] = 0;
runner();
};
};
これは$.onUpdate
内で一定時間毎に実行する時に見かける雰囲気のやつです。
これを実際に$.onUpdate
で使ってみます。
const logPer1sec = IntervalRunner(
"logPer1secElapse", 1, () => $.log("1秒")
);
const logPer10sec = IntervalRunner(
"logPer10secElapse", 10, () => $.log("10秒")
);
$.onUpdate(deltaTime => {
logPer1sec(deltaTime); //1秒間隔で"1秒"と表示する。
logPer10sec(deltaTime); //10秒間隔で"10秒"と表示する。
});
$.onUpdate
内がかなりスッキリしています。
logPer1sec
が deltaTime
だけで実行できているのがポイントです。
これはIntervalRunner
を呼び出すタイミングで実行に必要な引数が渡され、実行する準備を完了させているためです。
$.onUpdate
外で準備を行い、$.onUpdate
内で実行する、という風に切り離せるのも高階関数ならではです。
これはclassでも大事な視点だと思っています。
冒頭の日本語謎ClusterScriptを動かす
高階関数について確認してきました。
これで準備が整いました!
インタラクトされたら実行する(
Playerのジャンプ力を指定倍にする(2)
);
毎Frame実行する(
Playerのジャンプ力を指定秒後に元に戻す(10)
);
冒頭のこのスクリプトを動かします。
早速ですがスクリプト全文はこうなります。改変はご自由にどうぞ!
スクリプト全文
/**
* @returns {(...ops: ((player: PlayerHandle) => void)[]) => void}
*/
const OnInteract = () => {
return (...ops) => {
$.onInteract(player => {
for(let op of ops){ op(player); }
});
};
};
/**
* @typedef {Object.<string, ChangedPlayer>} ChangedPlayers
*
* @typedef {Object} ChangedPlayer
* @property {PlayerHandle} handle
* @property {number} elapse
*
* @returns {(rate: number) => ((player: PlayerHandle) => void)}
*/
const PlayerJumpRateChanger = (key) => {
return (rate) => {
return (player) => {
player.setJumpSpeedRate(rate);
/** @type {ChangedPlayers} */
const changed = {};
changed[player.id] = {
handle: player,
elapse: 0,
};
$.state[key] = {
...$.state[key],
...changed
};
};
};
};
/**
* @returns {(...ops: ((dt: number) => void)[]) => void}
*/
const OnUpdate = () => {
return (...ops) => {
$.onUpdate(deltaTime => {
for(let op of ops){ op(deltaTime); }
});
};
};
/**
* @returns {(delay: number) => ((dt: number) => void)}
*/
const PlayerJumpRateRestorer = (key) => {
return (delay) => {
return (deltaTime) => {
/** @type {ChangedPlayers} */
const players = $.state[key];
for(let key in players){
if((players[key].elapse += deltaTime) < delay) continue;
players[key].handle.setJumpSpeedRate(1);
delete players[key];
}
$.state[key] = players;
};
};
};
const statekey = "changedPlayers";
const インタラクトされたら実行する = OnInteract();
const Playerのジャンプ力を指定倍にする = PlayerJumpRateChanger(statekey);
const 毎Frame実行する = OnUpdate();
const Playerのジャンプ力を指定秒後に元に戻す = PlayerJumpRateRestorer(statekey);
インタラクトされたら実行する(
Playerのジャンプ力を指定倍にする(2)
);
毎Frame実行する(
Playerのジャンプ力を指定秒後に元に戻す(10)
);
スクリプトの末尾をご覧ください。
冒頭の日本語スクリプトになっています!
各関数について確認していきます。
OnInteract、OnUpdate
日本語名にするためだけに作られています。
これ自体を日本語名にしてもよかったのですが、スクリプト末尾の粒度を揃える意味でこうしています。
もちろん2回呼び出せません。
PlayerJumpRateChanger
const PlayerJumpRateChanger = (key) => {
return (rate) => {
return (player) => {
player.setJumpSpeedRate(rate);
//略
};
};
};
関数を返す関数を返す関数になっています。
最初は$.state
で使用するキーkey
を指定する関数です。
これは単にカスタマイズ性を確保しているだけです。
次でシャンプ力rate
を指定できる関数となります。
このタイミングでOnInteract
と共に準備されることを想定しています。
つまりこういう書き方をすることを想定しています。
インタラクトされたら実行する(
Playerのジャンプ力を指定倍にする(2)
);
最後にplayer
が渡される関数となります。
これは$.onInteract
内で実行されることを想定しています。
前述のOnInteract
がplayer
を引数として渡すように組んであります。
PlayerJumpRateRestorer
const PlayerJumpRateRestorer = (key) => {
return (delay) => {
return (deltaTime) => {
//略
};
};
};
これも関数を返す関数を返す関数になっています。
最初も同じく$.state
で使用するキーkey
を指定する関数です。
PlayerJumpRateChanger
と粒度を揃えています。
次で元に戻すまでの時間delay
を指定できる関数となります。
先と同じくOnUpdate
と共に準備されることを想定しています。
毎Frame実行する(
Playerのジャンプ力を指定秒後に元に戻す(10)
);
最後にdeltaTime
が渡される関数となります。
これも$.onUpdate
内で実行されることを想定しています。
前述のOnUpdate
がdeltaTime
を引数として渡すように組んであります。
関数冒頭の@typedefとか
詳細は「JSDoc」で検索して下さい。
VSCodeで型チェックやコード補完してくれるようになるので便利ですよ!
弊記事の「ClusterScriptでも型チェック&コード補完」でも簡単に紹介しています。
「ClusterScript+MVVMで作るマインスイーパー」ではガッツリ使い込んでいます。
よければご覧ください!
グローバルスコープで組み立てるのです
$.onInteract
や$.onUpdate
の仕様の関係上、グローバルスコープで組み立てる必要があります。
またScriptableItem
での仕様の関係上、動的にOnUpdate
への処理の追加削除は危険です。
onwer切り替えやスクリプトリロードで動的処理分は消し飛びます($.state
は関数を持てない)。
やるならPlayerScript
でやりましょう。
日本語化の制限、弊害
まあまあ結構あります。
- 文頭に数字を使えない。(JavaScriptの仕様由来)
- IMEをONして書くのだいぶ面倒。
他にもありますし、検索するといくらでも出てきますし、ほぼ戦争の様相を呈しています。
関数ではなくオブジェクトを返す関数
関数を返せるならオブジェクトを返してもいいじゃない、ということです。
詳細は「モジュールパターン」で検索して下さい。
もうほぼclassのように使えます。筆者はこれも好きです。
再掲で恐縮ですが「ClusterScriptでも型チェック&コード補完」でも少し使っています。
「ClusterScript+MVVMで作るマインスイーパー」ではガッツリ使い込んでいます。
よければご覧ください…!
これはModularCSの元ネタでした
弊ツール「ModularCS」の元の発想はこれでした。
こういう感じに、日本語で、簡単にコピペできて、余計な変数を見ない、そういう世界を作りたかったのです。
おわり
よきClusterScriptライフを!!!!!!!!
明日の記事はそ~ださんの「初めてクラフトアイテムにClusterScriptを入れたお話」です!!
クラフトアイテムはClusterScriptを手に入れて、ものすごい自由を得ましたよね…!!
Discussion