💨

【メモ】イベント制作備忘録

2025/03/02に公開

記録にすらならないものでしたが、備忘録として残しておきます。

【サンプル付き】イベントで使えるコメント連動ギミックをつくろう!「コメント取得 API」を使う
https://creator.cluster.mu/2025/01/31/commentapi/
イベントまたは開発者環境でないと機能しないので注意ですね。

僕は親アイテムオブジェクトをScriptableItemにして、子ノードのアイテムオブジェクトの挙動を変えるように使いました。
onCommentReceivedで受け取ったコメントはあくまでトリガーだけの機能を持っていて、そのトリガーをもとにonUpdateで子ノードのアイテムオブジェクトの挙動を変える処理をするようにしました。

$.onCommentReceived((comments) => {
    // $.log("comments " + comments.map(c => c.body));
    const indexRegex = /([0-9]+)/;
    const speedRegex = /(u|d|U|D||||||)/;

    for (const comment of comments) {
        const matchIndex = comment.body.match(indexRegex);
        const matchSpeed = comment.body.match(speedRegex);
        if (matchIndex && matchSpeed) {
            let number = parseInt(matchIndex[1]);
            const direction = matchSpeed[1];
            let speed = 0;
            if (["u", "U", "↑", "上", "速"].includes(direction)) {
                 speed += 1;
            } else if (["d", "D", "↓", "下", "遅"].includes(direction)) {
                speed += -1;
            }
            NodeManager.PushSpeedInfo($, number, speed);
        }
    }
})

子ノードのアイテムオブジェクトにはそれぞれ0から9までの数字が割り当てられていて、それぞれ移動速度を持っています。コメントに数字と"up"、数字と"down"が来たら対応する子ノードのアイテムオブジェクトの速度が変わるようにしました。
添付したコードでは、"up"と"down"ではなく、"u"や"d"だけで反応するようにしています(ほかにもちらほらありますけど)。正規表現とspeedを上げ下げする処理に箇所で文字一致判断していてかっこ悪いですが、まぁ許してください。
PushSpeedInfoにはオブジェクトを要素にする配列として追加しているだけだったとおもいます。

コメント連動全体
const NodeManager = (($) => {
    const node_size = 1;
    const node_height = 0.025;
    const width = 5 - node_size * 0.5;
    const depth = node_size * 0.5;
    const subNodes = [
        $.subNode("Node0"),
        $.subNode("Node1"),
        $.subNode("Node2"),
        $.subNode("Node3"),
        $.subNode("Node4"),
        $.subNode("Node5"),
        $.subNode("Node6"),
        $.subNode("Node7"),
        $.subNode("Node8"),
        $.subNode("Node9"),
    ]

    const textNode = $.subNode("Text");
    let _textIndexSpeed = [];
    const createPhaseManager = () => {
        let _time = 0;
        let _speed = 1;
        const period = 10;
        const trapezoidalWave = (t) => {
            if (t < 0.25) {
                return t * 8 - 1;
            } else if (t < 0.5) {
                return 1;
            } else if (t < 0.75) {
                return 5 - t * 8;
            } else {
                return -1;
            }
        };
        const UpdateTime = ($, deltaTime) => {
            _time += deltaTime * _speed;
            if (_time > period) {
                _time = 0;
            }
        }
        const GetPhase = ($) => {
            let t = _time % period / period;
               return trapezoidalWave(t);
        }
        const AddSpeed = (shift) => {
            let current_speed = _speed + shift;
            _speed = Math.min(Math.max(current_speed, 1), 5);
        }
        return { UpdateTime, GetPhase, AddSpeed };
    };
    const phaseManagers = subNodes.map(() => createPhaseManager());
    const GetNodePosition = (i, phase) => {
        let center = Math.floor((1 - subNodes.length) * 0.5 + 0.5);
        return new Vector3(
            (i + center) * node_size - depth,
            node_height,
            phase * width);
    }

    const Update = ($, deltaTime) => {
        for (let i = 0; i < subNodes.length; i++) {
            phaseManagers[i].UpdateTime($, deltaTime);
            const pos = GetNodePosition(i, phaseManagers[i].GetPhase($))
            subNodes[i].setPosition(pos);
        }
        // インターバルをすぎたら、_textIndexSpeedの先頭要素を取得してphaseManagersの
        {
            let time = $.state._time ?? 0;
            time += deltaTime;
            const INTERVAL = 1;
            if (time > INTERVAL) {
                time = 0;
                if (_textIndexSpeed.length > 0) {
                    const { index, speed } = _textIndexSpeed.shift();
                    phaseManagers[index].AddSpeed(speed);
                } else {
                    textNode.setText("");
                }
            }
            $.state._time = time;
        }

    }

    PushSpeedInfo = ($, number, speed) => {
        index = number % subNodes.length;
        // $.log("index " + index);
        // $.log("speed" + speed);
        _textIndexSpeed.push({ index, speed });

        if (_textIndexSpeed.length > 0) {
            // テキストを更新
            let text = "";
            let count = 0;
            for (const { index, speed } of _textIndexSpeed) {
                text += `Box${index}:${speed > 0 ? "UP" : "DOWN"}\n`;
                count++;
                if (count >= 3) {
                    break;
                }
            }
            textNode.setText(text);
        }
    }
    return { Update, PushSpeedInfo };
})($);

$.onUpdate(deltaTime => {
    NodeManager.Update($, deltaTime);
});

他にもコメント連動する機能がありますが、Update関数の内容をかえているだけです。

イベント専用ワールドということで、giftを使ったお邪魔機能を作ろうと思いました。

  • やらなかったこと
    たらいをアイテムとして生成したかった。コメントを受け取ってアイテムを作ればよいと思ったけど

ワールドアイテムテンプレートから生成されたアイテムがワールドコンポーネントを持っていてもそれは動作しません。

と記載がありました。
ワールドコンポーネントが何であるかを調べていません。Player Enter Warp Portalを設定していて安全のため生成することはやめました。

World Item Template List コンポーネント
https://docs.cluster.mu/creatorkit/item-components/world-item-template-list/

createItem
https://docs.cluster.mu/script/interfaces/ClusterScript.html#createItem

destroy
https://docs.cluster.mu/script/interfaces/ClusterScript.html#destroy

World Item Reference List コンポーネント
https://docs.cluster.mu/script/interfaces/ClusterScript.html#worldItemReference

  • やったこと
    たらいにはPlayer Enter Warp Portalを設定して、それとは別にギフトを受信するアイテムオブジェクトを用意しました。なんで分けたのか記憶がないのですが、たらいはたらで独立したアイテムにしたほうがすっきりすると思っちゃたんだと思います。ギフトアイテムとたらいはアイテム間でメッセージやり取りするようにしました。たいしたことはやっていませんのでペタっと添付します。
    コードなんてGitHubでpublic公開していますが、たぶんリンクがお亡くなりになるのでここで供養しておきます
たらい
const subNodeText = $.subNode("Text");
$.onReceive((messageType, arg, sender) => {
    // $.log(`<tub move> onReceive: ${arg}`);
    switch (messageType) {
        case "<tub manager> initialize":
            sender.send("<tub marker> receive initialize", {});
            break;
        case "<tub manager> Set Position":
            // $.log(`<manager> Set Position: ${arg}`);
            // $.log(`arg.position: ${arg.position}`);
            // $.log(`arg.displayName: ${arg.displayName}`);
            $.setPosition(arg.position);
            subNodeText.setText(arg.displayName);
            break;
    }
}, { item: true, player: false });
ギフト


const initializeHandles = () => {
    const key = "<tub manager> initialize";
    return ($) => {
        let itemHandles = $.getItemsNear(new Vector3(), Infinity);
        for (let itemHandle of itemHandles) {
            const arg = {};
            arg.itemHandle = $.itemHandle;
            itemHandle.send(key, {});
        }
    }
};

const NodeManager = (($) => {
    const node_size = 1;
    const width = 5 - node_size * 0.5;
    const subNodePosition = $.subNode("Position");
    let _giftSender = [];
    const createPhaseManager = () => {
        let _time = 0;
        let _speed = 1;
        const period = 10;
        const trapezoidalWave = (t) => {
            if (t < 0.5) {
                return t * 4 - 1;
            } else {
                return 3 - t * 4;
            }
        };
        const UpdateTime = ($, deltaTime) => {
            _time += deltaTime * _speed;
            if (_time > period) {
                _time = 0;
            }
        }
        const GetPhase = ($) => {
            let t = _time % period / period;
            return trapezoidalWave(t);
        }
        return { UpdateTime, GetPhase };
    };
    const createTubManager = (() => {
        const _tubMarkers = [];
        const addMarker = ($, marker) => {
            // $.log(`addMarker`);
            if (marker) {
                _tubMarkers.push(marker);
            }
        };
        const sendMessageMarker = ($, number, key, message) => {
            // $.log("sendMessageMarker");
            if (_tubMarkers.length > 0) {
                let index = number % _tubMarkers.length;
                // $.log("_tubMarkers.length:" + _tubMarkers.length);
                _tubMarkers[index].send(key, message);
            }
        };
        return {
            addMarker, sendMessageMarker
        };
    });
    const phaseManager = createPhaseManager();
    const tubManager = createTubManager();

    const Update = ($, deltaTime) => {
        phaseManager.UpdateTime($, deltaTime);

        let position = subNodePosition.getPosition();
        position.z = phaseManager.GetPhase($) * width;
        subNodePosition.setPosition(position);
        // インターバルをすぎたら、_giftSenderの先頭要素を取得してphaseManagersの
        {
            let time = $.state._time ?? 0;
            time += deltaTime;
            const INTERVAL = 2;
            if (time > INTERVAL) {
                time = 0;
                if (_giftSender.length > 0) {
                    let giftSender = _giftSender.shift();
                    // $.log("Update giftSender:" + giftSender);
                    const { number, displayName, position } = giftSender;
                    // $.log("Update displayName:" + displayName);
                    // $.log("Update displayName:" + displayName);
                    // $.log("Update position:" + position);
                    // $.log("Update number:" + number);
                    const key = "<tub manager> Set Position";
                    tubManager.sendMessageMarker($, number, key, { position: position, displayName: displayName });
                }
            }
            $.state._time = time;

        }
    }

    PushGiftInfo = ($, displayName) => {
        let number = $.state._number ?? 0;
        let position = subNodePosition.getPosition();
        // $.log("PushGiftInfo position:" + position);
        _giftSender.push({ number, displayName, position });
        $.state._number = number + 1;
    }
    AddTub = ($, tub) => {
        tubManager.addMarker($, tub);
    }
    return { Update, PushGiftInfo, AddTub };
})($);

$.onUpdate(deltaTime => {
    NodeManager.Update($, deltaTime);
});

$.onGiftSent((gifts) => {
    // $.log("onGiftSent called");
    for (let i = 0; i < gifts.length; i++) {
        let displayName = gifts[i].senderDisplayName;
        NodeManager.PushGiftInfo($, displayName);
    }
});


const handleInitializeHandles = initializeHandles();
$.onStart(() => {
    handleInitializeHandles($);
});

$.onReceive((messageType, arg, sender) => {
    // $.log(`<manager> onReceive: ${arg}`);
    switch (messageType) {
        case "<tub marker> receive initialize":
            // $.log(`<tub marker> receive initialize: ${sender}`);
            NodeManager.AddTub($, sender);
            break;
    }
}, { item: true, player: false });
  • Rigidbodyコンポーネントで重力onにすると変な挙動になる件
    Rigidbodyコンポーネントで重力onにすると、宙に浮くけどその場にとどまらないような変な挙動になったと思います。変だなーとしか思わなかったんですが、Movable Itemが必要なんですね。。。

Rigidbodyコンポーネントは、Movable Item コンポーネントが追加されている場合のみ使用されます。

https://docs.cluster.mu/creatorkit/craft-item/limitation/
たらいが下に落ちなくて困った記憶があります。

  • マテリアルのemission切り替え
    https://discord.com/channels/682526731311251636/1296013133672091672/1344348109777670227
    にも書いたのですが、ゲームオブジェクトのマテリアルのemission切り替えは、マテリアルのemissionフラグが立っていないと効かないような感じです。細かい仕様は調べていません、ごめんなさい。
    つかうとemissionが変わるコードです。供養供養。
emission
const materialHandle = $.material("material0");

let colorIndex = 0;
const rainbowColors = [
    [1, 0, 0],     // Red
    [1, 0.5, 0],   // Orange
    [1, 1, 0],     // Yellow
    [0, 1, 0],     // Green
    [0, 1, 1],     // Cyan
    [0, 0, 1],     // Blue
    [1, 0, 1]      // Magenta
];

$.onUse((isDown, player) => {
    // $.log(`colorIndex=${colorIndex}`);
    if (!isDown) { return; }
    if ($.getGrabbingPlayer() === null) { return; }

    const [R, G, B] = rainbowColors[colorIndex];
    // $.log(`{R,G,B}=${R},${G},${B}`);
    materialHandle.setEmissionColor(R, G, B, 1);

    colorIndex = (colorIndex + 1) % rainbowColors.length; // get next index
});

  • 寿限無のコメント
    もう一つ、寿限無をコメントするイベント用ワールド作ったので備忘録のために記載します。
jugemu
// const subNodeText = $.subNode("Text");
const JugemuManager = (($) => {
    const jugemuFull =
        "じゅげむ" + "じゅげむ" +
        "ごこうのすりきれ" +
        "かいじゃりすいぎょの" +
        "すいぎょうまつ" + "うんらいまつ" + "ふうらいまつ" +
        "くうねるところにすむところ" +
        "やぶらこうじのぶらこうじ" +
        "ぱいぽぱいぽぱいぽのしゅーりんがん" +
        "しゅーりんがんのぐーりんだい" +
        "ぐーりんだいのぽんぽこぴーのぽんぽこなーの" +
        "ちょうきゅうめいのちょうすけ";
    const toHiragana = (text) => {
        const mapping = {
            "寿": "じゅ", "限": "げ", "無": "む",
            "五": "ご", "劫": "こう", "擦": "す", "切": "き",
            "海": "かい", "砂": "じゃ", "利": "り",
            "水": "すい", "魚": "ぎょ",
            "行": "ぎょう", "末": "まつ", "雲": "うん", "来": "らい", "風": "ふう",
            "食": "く", "寝": "ね", "処": "ところ", "住": "す",
            "藪": "やぶ", "柑": "こう", "子": "じ",
            "パ": "ぱ", "イ": "い", "ポ": "ぽ", "シ": "し", "ュ": "ゅ", "ー": "ー",
            "リ": "り", "ン": "ん", "ガ": "が", "グ": "ぐ", "ダ": "だ", "コ": "こ",
            "ピ": "ぴ", "ナ": "な",
            "長": "ちょう", "久": "きゅう", "命": "めい", "助": "すけ",
            "ジ": "じ", "ゲ": "げ", "ム": "む",
            "ゴ": "ご", "ウ": "う", "ノ": "の", "ス": "す", "キ": "き", "レ": "れ",
            "カ": "か", "ャ": "ゃ",
            "ギ": "ぎ", "ョ": "ょ",
            "マ": "ま", "ツ": "つ", "ヤ": "や", "ブ": "ぶ", "ラ": "ら", "フ": "ふ",
            "ク": "く", "ネ": "ね", "ル": "る", "ト": "と", "ロ": "ろ", "ニ": "に",
            "チ": "ち", "メ": "め", "ケ": "け",
        };
        return text.split("").map(c => mapping[c] || c).join("");
    };
    _dancingPlayers = []; //ダンスプレイヤー
    let _commentQueue = [];
    let _isJugemuMode = false;
    let _currentIndex = 0;
    const sound = $.audio("Sound");
    const soundEffect = $.audio("SoundEffect");
    function StopJugemu($) {
        _isJugemuMode = false;
        sound.stop();
        _currentIndex = 0;
        _dancingPlayers = [];
    }
    const PopComment = ($) => {
        // $.log("_commentQueue" + _commentQueue);
        if (_commentQueue.length > 0) {
            const comment = _commentQueue.shift();
            // $.log("comment" + comment);
            // $.log("_currentIndex" + _currentIndex);

            let input = toHiragana(comment.body.trim());
            // $.log("input: " + input);
            let expected = jugemuFull.slice(_currentIndex, _currentIndex + input.length);
            // $.log("expected: " + expected);
            if (_isJugemuMode) {
                if (expected === input) {
                    // 入力が現在のターゲットの続きなら進行
                    _currentIndex += input.length;

                    let playerHandle = comment.sender;
                    if (playerHandle !== null) {
                        // not ghost or group viewing users
                        let players = _dancingPlayers;
                        _dancingPlayers = players.concat(playerHandle);
                    }
                    if (_currentIndex === jugemuFull.length) {
                        // $.log("ok"); // 全部正しく言えた
                        StopJugemu($);
                        soundEffect.play();
                    }
                } else {
                    // 入力が現在のターゲットの続きでないなら失敗
                    // $.log("ng");
                    StopJugemu($);
                }
            } else {
                StopJugemu($);
                if (jugemuFull.startsWith(input)) {
                    // 「じゅ」などの部分入力でもモード開始
                    _isJugemuMode = true;
                    sound.play();
                    _currentIndex = input.length;

                    let playerHandle = comment.sender;
                    if (playerHandle !== null) {
                        // not ghost or group viewing users
                        let players = _dancingPlayers;
                        _dancingPlayers = players.concat(playerHandle);
                    }
                }
            }
         }
    }

    const AddComments = ($, comments) => {
        // $.log("AddComments " + comments.map(c => c.body));
        _commentQueue = _commentQueue.concat(comments);
    }

    // idでフィルタリングと重複削除を行う関数
    function filterAndRemoveDuplicates(array) {
        const seenIds = new Set(); // 重複をチェックするためのセット
        return array.filter((obj) => {
            // 重複チェック
            if (seenIds.has(obj.id)) {
                return false; // すでに同じidのオブジェクトがある場合は除外
            } else {
                seenIds.add(obj.id); // 新しいidをセットに追加
                return true; // 重複していない場合は残す
            }
        });
    }
    const humanoidAnimation = $.humanoidAnimation("Dance0");
    const _danceLength = humanoidAnimation.length;
    _danceTick= 0; //ダンスの秒数
    const Dance = ($) => {
        // $.log("dancingPlayers: " + _dancingPlayers.length);
        // $.log("danceTick: " + danceTick);
        // $.log("danceLength: " + _danceLength);
        //現在のダンス秒数から現在のポーズを取得
        let pose = humanoidAnimation.getSample(_danceTick);
        let dancingExistingPlayers = _dancingPlayers.filter((player) => player.exists());
        let duplicatesRemovedArray = filterAndRemoveDuplicates(dancingExistingPlayers);
        //ワールドで対象のプレイヤーにポーズをとらせる
        duplicatesRemovedArray.forEach((p) => p.setHumanoidPose(pose, { timeoutSeconds: 1.0, timeoutTransitionSeconds: 0.3, transitionSeconds: 0.11 }));

        //今回いたプレイヤーは記録しておく
        _dancingPlayers = dancingExistingPlayers;
    }
    const UpdateDance = ($, deltaTime) => {
        //ダンスの秒数を進める
        _danceTick += deltaTime;
        if (_danceTick > _danceLength) {
            //ダンスが終わったら先頭のほうに戻す
            _danceTick -= _danceLength;
        }
    }
    return { PopComment, AddComments, UpdateDance, Dance };
 })($);


$.onCommentReceived((comments) => {
    JugemuManager.AddComments($, comments);
})

const IntervalUpdater = (() => {
    let _tick = 0; // 切りかえを行うまでの秒数を計測
    const INTERVAL = 0.25; // 適当な秒数
    const IsEnough = ($, deltaTime) => {
        _tick += deltaTime;
        if (_tick < INTERVAL) {
            return false;
        }
        _tick -= INTERVAL;
        return true;
     }
    return { IsEnough }
})();

$.onUpdate((deltaTime) => {
    JugemuManager.PopComment($);
    JugemuManager.UpdateDance($, deltaTime);
    if (!IntervalUpdater.IsEnough($, deltaTime)) {
        return;
    }
    JugemuManager.Dance($);
});

  • ダンスの設定
    Humannoid Animation Listでダンスを設定しています。
    痛い目を見ているのでアニメーションの設定画像も添付します。
    Rigタブのアニメーションタイプと、Animationタブの時間をループオプションは何度も設定を忘れます。


    またItemAudioSetListで音楽をセットしています。

Discussion