🖇️

ClusterScriptでも高階関数を使うのです

2024/12/13に公開

この記事は「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"と表示される。

CountingMessagereturnで関数を返しているのが分かります。
つまり messagebodyを引数に取る関数となっています。

関数を返す、ということはもちろんこうも書けます。

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"と表示される。

利点:使い回すこともできる

先の例で、例えばmessageCountwarningCountのログを作りたい場合を考えます。

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内がかなりスッキリしています。

logPer1secdeltaTimeだけで実行できているのがポイントです。
これは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内で実行されることを想定しています。
前述のOnInteractplayerを引数として渡すように組んであります。

PlayerJumpRateRestorer

const PlayerJumpRateRestorer = (key) => {
    return (delay) => {
        return (deltaTime) => {
            //略
        };
    };
};

これも関数を返す関数を返す関数になっています。

最初も同じく$.stateで使用するキーkeyを指定する関数です。
PlayerJumpRateChangerと粒度を揃えています。

次で元に戻すまでの時間delayを指定できる関数となります。
先と同じくOnUpdateと共に準備されることを想定しています。

毎Frame実行する(
    Playerのジャンプ力を指定秒後に元に戻す(10)
);

最後にdeltaTimeが渡される関数となります。
これも$.onUpdate内で実行されることを想定しています。
前述のOnUpdatedeltaTimeを引数として渡すように組んであります。

関数冒頭の@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