ClusterScriptで「連続値入力」ギミックをつくる

この記事は[Cluster Script Advent Calendar 2024](https://adventar.org/calendars/10580)の7日目の記事です。
昨日はかおもさんの「[ClusterScript＋MVVMで作るマインスイーパー](https://zenn.dev/vkao/articles/bcc83c1139dd4f)」でした。「仕様に依存してコードがゴチャつきやすい部分」と「メインのロジックを丁寧につくり込みたい部分」をしっかり分けるのは読みやすいコードを書く上で結構重要なテクニックだったりするので、こういったデザインパターンも意識していきたいですね。

---

こんにちは、ClusterScriptアドカレ2人目の滝竜三です。
枠に余裕があったので3枠取ってみました。完走できるか見守っていてください。

さて今回は、僕が「[Live Stage [CORE]](https://cluster.mu/w/68fbc218-50ae-4705-b9ec-5e64f572b058)」というワールドで使った「**連続値入力ギミック**」について解説しようと思います。
ここでいう連続値入力というのは、何かの値を増減したりするときにボタンをポチポチして1ずつ（固定の単位ずつ）動かすのではなく、スライダーやツマミなどを動かしてスムーズに変化させるような操作のことを言います。
※厳密にはコンピュータの世界はすべてデジタル＝離散的であり本当の意味での連続値ではないので、あくまで連続値「風」です。

演出などのギミックを連続値的に操作できるようになると、例えばフワッと色を変えたり、次第にスピードを速くしたりといった細やかな制御ができ、特にVRの動きの自由度と組み合わせることで、cluster界隈の用語で言ういわゆる「EJ」の表現力の幅が大きく広がります。

昔からライブステージ系のワールドでの連続的な照明・演出操作をどうにかできないか考えてたびたび実験してきたんですが、スクリプトを使うとアイテムの「位置」や「回転」を取得し、そこからさまざまな計算をおこなうこともできるので、こういった操作も自然に実装できるようになりました。
<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="ja" dir="ltr">Live Stage [CORE]の操作システムの動画（記事埋め込み用） <a href="https://t.co/sXk3kgkQBZ">pic.twitter.com/sXk3kgkQBZ</a></p>&mdash; 滝 竜三 (@ryu3taki) <a href="https://twitter.com/ryu3taki/status/1865404419917193608?ref_src=twsrc%5Etfw">December 7, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 1軸での操作
「Live Stage [CORE]」では「テンポ操作用の1軸コントローラ」「照明カラー制御用の2軸コントローラ」のふたつを実装しました。
まずはシンプルな方の1軸コントローラから解説していきます。

実際のスクリプトがこちら。なお、もともと見せるつもりで書いたコードではないのでクオリティとかは保証しません。
```js
const text = $.subNode("Text");

$.onStart(() => {
    $.state.initialPosition = $.getPosition();
    $.state.initialRotation = $.getRotation();
    $.state.isUsing = false;
    $.state.user = null;
    $.state.startPosition = $.getPosition();
    $.state.value = $.state.initialValue = $.getStateCompat("this", "init", "float");
    $.state.amplify = $.getStateCompat("this", "amplify", "float");
});

$.onGrab((isGrab, isLeftHand, player) => {
    if (isGrab) {
        $.state.user = player;
    }
    else {
        $.state.isUsing = false;
        $.setPosition($.state.initialPosition);
        $.setRotation($.state.initialRotation);
    }
});

$.onUse((isDown, player) => {
    $.state.isUsing = isDown;
    if (isDown) {
        $.state.startPosition = $.getPosition();
    }
    else {
        $.state.value = calcValue();
    }
});

$.onUpdate((deltaTime) => {
    if ($.state.isUsing) {
        let value = calcValue();
        publishValue(value);
    }
});

function publishValue(value) {
    let tempo = Math.floor(value);
    $.setStateCompat("this", "speed", tempo / 60);

    text.setText(tempo);
}

function calcValue() {
    let startPosition = $.state.startPosition;
    let position = $.getPosition();
    let differenceY = position.y - startPosition.y;
    let value = $.state.value + differenceY * $.state.amplify;
    return Math.max(1, value);
}
```

肝になるのは`calcValue()`で「**基準位置（`$.state.startPosition`）と現在位置（`$.getPosition()`）の差から値の変化量を計算する**」部分です。高さの変化を参照するのでY座標の差のみを利用しています。
```js
let startPosition = $.state.startPosition;
let position = $.getPosition();
let differenceY = position.y - startPosition.y;
let value = $.state.value + differenceY * $.state.amplify;
```
`$.onUse()`でUse開始時点の位置を基準にすることで、Useしている間の移動量だけを反映させることができます。
```js
if (isDown) {
    $.state.startPosition = $.getPosition();
}
```
あとは`publishValue()`の`$.setStateCompat()`を通してSetAnimatorValueGimmickに値を送り、それをAnimatorのStateのSpeed>Multiplierに反映させることで演出のスピードを変化させます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2398274/dd37ff20-cb86-15b6-b4f9-58e17b2c8d43.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2398274/2560af48-ecff-43db-ecf6-d90bb764ebc2.png)

# 2軸での操作
1軸の場合は高さ＝Y座標のみを利用するのでシンプルでしたが、横移動も含めた2軸の場合は少し工夫が必要です。
「Live Stage [CORE]」では**ライトなどの色を 「色相」「明るさ」の2軸に分けて制御**するために利用しています。

高さと異なり、**「横方向」は持っているプレイヤーの向きによって基準が変わる**ため、単なるX座標の比較では感覚的に操作できません（自分の正面に対してではなく、Global座標系に対してになってしまう）。
そのため**アイテムを持っているアバターの向きを基準とした横移動**を計算するようにします。

以下が実際のスクリプトです。
```js
const hueIndicator = $.subNode("HueIndicator");
const valueIndicator = $.subNode("ValueIndicator");

$.onStart(() => {
    $.state.initialPosition = $.getPosition();
    $.state.initialRotation = $.getRotation();
    $.state.isUsing = false;
    $.state.user = null;
    $.state.startPosition = $.getPosition();
    $.state.startRotation = $.getRotation();
    let initValue = {x: $.getStateCompat("this", "init", "float"), y: -0.5};
    $.state.value = initValue;
    publishValue(initValue);
});

$.onGrab((isGrab, isLeftHand, player) => {
    if (isGrab) {
        $.state.user = player;
    }
    else {
        $.state.isUsing = false;
        $.setPosition($.state.initialPosition);
        $.setRotation($.state.initialRotation);
    }
});

$.onUse((isDown, player) => {
    $.state.isUsing = isDown;
    if (isDown) {
        $.state.startPosition = $.getPosition();

        let user = $.state.user;
        if (!user?.exists()) {
            user = null;
        }
        $.state.startRotation = user?.getRotation() ?? $.getRotation();
    }
    else {
        $.state.value = calcValue();
    }
});

$.onUpdate((deltaTime) => {
    if ($.state.isUsing) {
        let value = calcValue();
        publishValue(value);
    }
});

function crop(value, min, max) {
    return Math.min(Math.max(value, min), max);
}

function publishValue(value) {
    let h = (value.x - Math.trunc(value.x)) * 360;
    if (h < 0) {
        h += 360;
    }
    let rgb = hsv2rgb({h: h, s: 1, v: crop(value.y, 0, 1)});

    $.setStateCompat("this", "red", rgb.r);
    $.setStateCompat("this", "green", rgb.g);
    $.setStateCompat("this", "blue", rgb.b);

    hueIndicator.setRotation(new Quaternion().setFromEulerAngles(new Vector3(0, 0, h)));
    valueIndicator.setPosition(new Vector3(0, crop(value.y, 0, 1), 0));
}

function calcValue() {
    let startPosition = $.state.startPosition;
    let position = $.getPosition();

    let difference = position.clone().sub(startPosition);

    let startRotation = $.state.startRotation;
    let differenceLocal = difference.clone().applyQuaternion(startRotation.clone().invert());

    let value = $.state.value;
    let x = value.x + differenceLocal.x * 2;
    let y = crop(value.y + difference.y * 10, -1, 2);

    return {x: x, y: y};
}

function hsv2rgb(hsv) {
    const max = hsv.v;
    const min = max - hsv.s * max;
    const h = hsv.h;

    let r = 0;
    let g = 0;
    let b = 0;

    if (h < 60) {
        r = max;
        g = (h / 60) * (max - min) + min;
        b = min;
    }
    else if (h < 120) {
        r = ((120 - h) / 60) * (max - min) + min;
        g = max;
        b = min;
    }
    else if (h < 180) {
        r = min;
        g = max;
        b = ((h - 120) / 60) * (max - min) + min;
    }
    else if (h < 240) {
        r = min;
        g = ((240 - h) / 60 * max - min) + min;
        b = max;
    }
    else if (h < 300) {
        r = ((h - 240) / 60) * (max - min) + min;
        g = min;
        b = max;
    }
    else {
        r = max;
        g = min;
        b = ((360 - h) / 60) * (max - min) + min;
    }

    return {r: r, g: g, b: b};
}
```

1軸の場合との大きな違いとして、Useしたときに初期位置に加え、**アイテムを持ったプレイヤーのアバターの回転（向き）を基準として保持**しています。
`calcValue()`の中ではまず基準位置と現在位置の差分のベクトル、つまりGlobal空間上で基準位置からどれだけ動いたかを取得します。
これに「**アバターの向きの逆回転**」を`applyQuaternion()`することで、**アバターの向きを基準としたローカル座標系**に変換することができます。
※余談ですが、ClusterScriptアドカレで確保しているもう一枠ではこういったQuaternionの使い方について書きたいと思っています。
```js
let differenceLocal = difference.clone().applyQuaternion(startRotation.clone().invert());
```
あとはそのローカル座標系のX, Y座標を基準に変化量を計算することで、**操作するプレイヤーから見て自然な向きでの縦横方向への移動**を取得できます。

操作感を良くするための工夫として、明るさを操作するY方向の値は実際に適用する値の外側にバッファを設けてました。`calcValue()`内では-1～2の範囲で出力して、`publishValue()`で実際に反映するときに0～1の範囲に丸めています。
今回の用途では明るさ方向は基本的に最大or最小のまま色相だけを操作することが多いと考えたので、横にだけ動かしたいときに変に明るさがブレないようにしました。
```js
let y = crop(value.y + difference.y * 10, -1, 2);
```
```js
let rgb = hsv2rgb({h: h, s: 1, v: crop(value.y, 0, 1)});
```
ちなみにこのあたりのコードは最終的な操作感を微調整するときに横着したので、マジックナンバー（定数化せず処理中に直接書いた数字）を書いてしまっています。あまり良い書き方ではないので真似しないでください。

色相と明るさの値を求めたら、HSVからRGBの値に変換して`$.setStateCompat()`します。
こちらもSetAnimatorValueGimmickでAnimatorに送り、Animator側ではRGBそれぞれに対応するレイヤを分け、BlendTreeで制御しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2398274/a3ee146c-97aa-fab0-5efb-7931c7b38283.png)

---

ギミック・トリガーコンポーネントの組み合わせに対し、スクリプトの強みとして「**位置や回転を詳細に取得できる**」「**複雑な計算を記述しやすい**」という点があります。
今回のように微妙な位置や回転の変化を拾ってパラメータに反映させると、例えば「引っ張った距離に応じた速度で走るミニカー」みたいなこともできると思います。またその際、距離に比例するだけでなく対数関数などを適用してバランスを調整するのもスクリプトなら簡単です。何か面白いアイデアを思いついたらこの記事を参考につくってみてください。

明日は蛮樽むさしさんの「スクリプト配布おじさん」です。現在開催中の[ゆるゲームジャム](https://note.com/cluster_official/n/n4b67001fb218)関連でしょうか。
僕もこの記事を書き終えたので何かつくらねば…。