ClusterScriptで「リアル時刻連動ギミック」をつくる

この記事は[Cluster Script Advent Calendar 2024](https://adventar.org/calendars/10580)の4日目の記事です。
昨日はt_furuさんの「[cluster 外部通信のためのAPIサーバーを簡単に作成できるプロジェクト](https://github.com/tfuru/EasyApiHub)」でした。外部通信機能を使うとできることがめちゃくちゃ増えますが、サーバを用意するのが大変なのでこういったものを用意してもらえるとかなり助かりますね。

---

こんにちは、滝竜三といいます。[clusterでは主にライブステージ系のワールドや、一発ネタ系のワールドなどをつくっていることが多いです](https://cluster.mu/u/ryu3taki)。

さて、ClusterScriptアドカレということで、僕からはスクリプトで使えるテクニックやノウハウを紹介していこうと思います。

CCKのコンポーネントだけでは難しく、スクリプトを使うと比較的簡単に実装できるギミックのひとつに、「**実際の時刻に連動したギミック**」があります。リアル時刻との連動ギミックは、特定の時刻まで待たなければいけないという「不便さ」をあえて取り入れることで深みのある体験を演出することができます。みなさんも**特定の曜日にしか現れないモンスターを捕まえ損ねて、また一週間待った思い出とかありますよね**。~~そこ、リセットとか本体時刻操作とか野暮なこと言わない~~。
今回は僕が自分のワールドで実際に利用した時刻連動ギミックの作例を2つ紹介します。

# 一定条件の時刻ごとにSignalを発火する
「[銀河鉄道かささぎ号 天の川方面ゆき](https://cluster.mu/w/f2d35eec-2d34-4e30-8b0c-fc8e3d0e57cc)」で使った、「**時刻表の時間ごとにTimelineを再生する**」ギミックです。
このワールドではワールド内にも掲示している時刻表に合わせて、毎時00, 10, 20, ... , 50分に、10分おきに自動で列車が発車するようにしてあります。
![1290a6d4-b448-451f-a431-eaa3c32e50db.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2398274/283472fc-6eba-0c8a-0b1d-54b63897386f.png)

こちらが実際に使っているスクリプトです。
```js
$.onUpdate(deltaTime => {
    let date = new Date();

    if($.state.beforeMinute == null)$.state.beforeMinute = date.getMinutes();
    let minute = date.getMinutes();

    if(Math.floor(minute * 0.1) != Math.floor($.state.beforeMinute * 0.1))$.sendSignalCompat("this", "Start");

    $.state.beforeMinute = minute;
});
```
だいぶ前につくったものなのでstateをnullチェックで初期化していますが、今なら`$.state.beforeMinute = date.getMinutes();`の部分は`$.onStart()`に入れてしまうのが良さそうです。
このスクリプトでは「**時刻の分の値の10の位が直前のフレームと変わっていたら○○時×0分になった瞬間である**」として発車時刻を判定しています。
`date.getMinutes()`で時刻の「分」の数字を取得し、それを0.1倍した値の小数部を切り捨てる（`Math.floor()`）ことによって10の位を求めます。

こうした計算で発車時刻を判定し、Signalを発行してPlayTimelineGimmickを発火させることで、列車を定時運行させています。

このワールドは1周およそ8分程度ということもあり、10分おきでシンプルに判定できる×0分にしていますが、ここの数式を工夫すればいろんな時刻表がつくれると思います。例えば20分おきとか、分の1の位が5のときとか、深夜の時間帯は運行しないとか。

# 初日の出ギミック
通常公開しているワールドではないんですが、今年と去年の元旦に[初日の出イベント](https://cluster.mu/e/0ab9cd26-0aa1-4c3b-b82b-b650f577fa13)のお手伝いをして、「**初日の出の時刻に合わせてワールド内でも日が昇る**」ギミックをつくりました。
詳しい内容としては、指定の日時の1時間前から指定時刻にかけて0～1に変化するトリガーを発行し、それによってSetAnimatorValueGimmickを通してBlendTreeを変化させるものです。
スクリプトとしては以下のような感じです。なおこちらは実際に使ったものではなく、今回の記事に当たって~~雑な実装だったのを書き直し~~サンプルとして調整しています。
```js
// 指定する日時
// "yyyy-MM-dd T hh:mm:ss +9" の形式
// +9は日本時間（UTC+9）の意味
const dateTime = new Date("2025-01-01 T 06:50:54 +9").getTime();

// 指定日時の何秒前から動き始めるか
const margine = 3600;

$.onUpdate(deltaTime => {
    const now = Date.now();
    const diffSec = (dateTime - now) * 0.001;

    $.setStateCompat("this", "timer", saturate(1 - diffSec / margine));
})

function saturate(value, min = 0, max = 1) {
    return Math.min(Math.max(value, min), max);
}
```
冒頭の`const dateTime = new Date("2025-01-01 T 06:50:54 +9").getTime();`で、変化を完了する時刻（初日の出なら日が上る時刻）を指定しています。ちなみにサンプルとして2025年の東京の初日の出の時刻にしてあります。
この形式で日時指定することで、タイムゾーンを考慮して時刻を扱うことができます。+9とあるのが日本標準時（UTC+9）の意味です。

次の`const margine = 3600;`はその時刻の何秒前から変化を始めるかを指定します。サンプルでは1時間前＝3600秒前を指定しています。

`$.onUpdate()`の処理では、`Date.now()`で現在の時刻を取得し、それを`dateTime`から引くことで現在時刻と指定時刻の差分を取っています。時刻の差分はそのままだとミリ秒単位なので、0.001をかけて秒単位に直しています。
その差分の値を`margine`で割ったり1から引いたりすることにより0→1の範囲で変化する値に変換して`$.setStateCompat()`に渡しています。

なくてもいいと言えばいいんですが、`saturate()`という関数を用意して「0以下なら切り上げて0、1以上なら切り捨てて1」にする処理もはさんでいます。こうすると指定範囲より前なら常に0、指定時刻以降なら常に1になって安全な範囲に収めることができます。こういう場面ではなるべく想定した範囲外の数字が出ないようにしておいた方が変なバグを踏みにくくなります。

今回はスクリプト記事なのでギミック側について詳しくは書きませんが、ここでsetStateした値をsetAnimatorValueGimmickで受け取り、図のようにBlendTreeで「変化前」「変化後」の1フレームのアニメーションをブレンドすれば、指定の時刻の範囲で変化するギミックになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2398274/24ba1def-6489-c173-fab2-5d788d7bc192.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2398274/40d939d6-ad13-69e7-53cf-47b8de49fa7e.png)

---
というわけで、Dateオブジェクトを使った時刻連動ギミックの作例紹介でした。
リアル時刻との連動は物理世界とのつながりを感じさせ、味わいのある体験をつくることができます。ぜひ試してみてください。

明日はinaba（イナバ）さんの「ワープ系アイテムの話とか。」です。スクリプトの機能を使うとクラフトアイテムでもプレイヤーのワープ機能を実現できたり、CCKワールドでも柔軟にワープ先を変えたりできて面白いですよね。