「粕寺保育園 おやつの時間ですよ!」のお布団めくりゲームについて
2024年7月7日(日)に創業9周年を迎えた、メタバースプラットフォーム「cluster (クラスター)」。
そこでユーザーが創作したワールドが4万4千を超えたというのは、すでに数年前に聞いた話であり、今やどれほどのものか想像もつかないが、かくいう自分も、その中の幾つかの制作に関わり、楽しませていただいている。
今回は、その一つ、ウルトラソウル【超魂】チーム制作のゲームワールドにて、自分が担当したパートのスクリプトを、公開したい。
ゲームの概略
根幹となるゲームのイメージは、トランプの神経衰弱である。
それをワールドクラフトで実装する、というのが此度のワールド制作の命題だ。
バラバラに配置されたアイテムから一つを選択し、その対(ペア)となるアイテムを探し出す。ペアが揃ったことが確認できたら、1カウントゲット。
ワールド内で用いているものは、カードではなく、キャラクターがセットされた布団のアイテムなので、少々飛躍してはいるが、キャラクターがトランプの数字または絵柄に相当すると考えていただきたい。
全体的なボリュームを勘案して、ここでは、全部で12セットのペアを用意した。
1)「絵柄合わせ」部分の実装(スクリプト)について
カードに相当するアイテム(24個)は、ワールドクラフトアイテムなので、条件によって自身の挙動を定めるスクリプトをそれぞれに付与する必要があった。しかし、構文は可能な限り、共有させたい。
まず、ペアの設定は、アイテム名の、頭2文字を共有することで管理し、末尾1文字に、数字の「1」または「2」を付与し個別するルールとした。
例として抜粋すれば、"RA1","RA2","RB1","RB2","RC1"…という感じで続いていく。
ペアが揃ったことを確認する手段としては、制作当時はベータ機能※であった、アイテム同士のメッセージのやりとり(item.send、onReceive)で、自分のKey(スクリプトの開始1行目で変数に設定するアイテム自身の名前)を送受することにより判定材料とした。
(※正式リリース前であるが、試用許可されている
クラスタースクリプトのこと)
また、どういった場合にメッセージを送るか、というやりとりの条件には、距離(getItemsNear)を用いた。
そして、トランプを裏返すことにより、カードの数字または絵柄が明示される部分だが、アイテムに同梱したキャラクターとそれを覆う布団のモデルを、持った時(onGrab)、また、近づけた時に交わされるメッセージ(onReceive)により、表示、非表示を切り替える(setEnabled)仕組みで対応した。
他にも、スコアボードのゲージカウントアップに関わる処理や、最終的にアイテム自身を消滅させるための処理、動作状況確認用のビープ音、グリップしたアイテムの視認性を上げるための「おふとんくるくるコード」(「仕込屋」氏 提供)など、機能満載?だが、コード内のコメント表記(コメント記号「//」から該当行の終わりまで)を参考に、詳細は読み解いてほしい。
「絵柄合わせ」用アイテムのスクリプト全文/例)RA1
1行目の""に、アイテム自身の名前を記述する箇所以外、
スクリプト内容は、24アイテム共通である。
const SKEY = "RA1" ; //自身のKey
//ペアのKeyを設定する
let SKEYend = SKEY.slice(2); //SKEYの末尾1文字を取得
let RKEYend ;
if (SKEYend == "1") {
RKEYend = "2" ;
} else {
RKEYend = "1" ;
}
const RKEY = SKEY.slice(0,2) + RKEYend ; //ペアのKey
const se1 = $.audio("Audio1");
const se2 = $.audio("Audio2");
const se3 = $.audio("Audio3");
const se4 = $.audio("Audio4");
//おふとんくるくるコード_ここから
//伸縮定義
const step = $.subNode("Gr_Core");
//回転定義
const obj = $.subNode("Gr_Core");
const second = 12;
const v3 = new Vector3(0, 1, 0);
const angle = 360;
const count = 3;
const rotate = 1;
//おふとんくるくるコード_ここまで
// onGrab...アイテムを持ったとき、離したときに実行される
$.onGrab((isGrab) => {
// 音を鳴らす:アイテムを持った合図(ポコ♪)
se3.play();
// アイテムを持っているか否かの状態を保存
$.state.isG = isGrab;
// メッセージ送信のタイマー変数を設定
$.state.timeS = 0;
// ゲーム状況判定(ペアからのKeyを受け取った)があれば保存
let isDame = $.state.isDame ?? false;
// アイテムを持っている間は中身が見える状態にする
if (isGrab) {
$.subNode("Gr_Bbox").setEnabled(false);
$.subNode("Gr_Core").setEnabled(true);
// アイテムを持った(持ち直した)時にはゲーム状況判定はリセット
$.state.isDame = false;
} else {
// アイテムをリリースした時、ペアからのKeyを受け取っていなかったら中身は見えない状態に戻す
if (!isDame) {
$.subNode("Gr_Bbox").setEnabled(true);
$.subNode("Gr_Core").setEnabled(false);
}
}
});
//おふとんくるくるコード_ここから
$.onUse((isDown, playerHandle) => {
let isDown = $.state.isDown;
isDown = !isDown;
$.state.isDown = isDown;
//const pos = Vector3(isDown ? 1.0:0,isDown ? -1.0:0,0 );
const pos = Vector3(isDown ? 1.0:0,isDown ? -0.1:0,0 );
step.setPosition(pos);
if(isDown){
$.state.flg = isDown;
}
});
//おふとんくるくるコード_ここまで
// onReceive...メッセージを受け取ったときに実行される
$.onReceive((requestName, arg, sender) => {
// 初期化
if (!$.state.initialized) {
$.state.initialized = true;
}
// ゲーム状況判定(ペアからのKeyを受け取った)があれば保存
let isDame = $.state.isDame ?? false;
//自身を消すためのKEYを受診したら、デストロイ
if (requestName == "Reset") {
se4.play();
$.destroy();
return;
} else {
// メッセージ受診のタイミングで中身を表示
$.subNode("Gr_Bbox").setEnabled(false);
$.subNode("Gr_Core").setEnabled(true);
// メッセージ受診に伴う中身表示のタイマー変数を設定
$.state.timeR = 0;
}
// 特定のrequestNameのときだけ処理(ここではペアのKey受信がなされた)
if (requestName == RKEY) {
// ゲーム判定(ペアのKeyを受け取ったか)を保存
$.state.isDame = true;
// ペアのKey受診に伴うリアクション
//(ここでは、ペアのアイテムが相互にペアを発見したという
// メッセージを得るために受信側からも再度、自身のKeyを送っている。)
sender.send(SKEY, 1);
se1.play();
} else {
// 音を鳴らす:ペア以外のKey受信の合図(ブー)
if (requestName[2] == "1" || requestName[2] == "2" ) {
se2.play();
}
}
// ゲーム状況判定を元にKEY送信に付与するパラメータを用意
if (isDame) {
isDameX = 1
} else {
isDameX = 0
}
//ゲージカウントアップ処理用のKEYを送信
if (requestName == "who") {
sender.send(SKEY, isDameX); // ペアからのKeyを受け取っていたら
//break;
return;
}
});
// onUpdate...毎フレーム毎の処理
$.onUpdate(deltaTime => {
let timeS = $.state.timeS ?? 0;
let isG = $.state.isG ?? false;
let timeR = $.state.timeR ?? 0;
let isDame = $.state.isDame ?? false;
//let timeD = $.state.timeD ?? 0;
// アイテムを持っている間はメッセージ送信を試みる
// アイテムを持っていない時にペア以外のKeyを受診したら2秒間中身を表示する
if (isG) {
timeS += deltaTime;
$.state.timeS = timeS;
if (timeS > 3) {
$.state.timeS = 0
//ペアが見つかっていない場合、timeS に設定された値の間隔で自身のKEYを送信
if (!isDame) {
//周囲1m以内のアイテムのハンドルを取得
let items = $.getItemsNear($.getPosition(), 1);
//取得したハンドルに対して自身のKEYを送信
for (let item of items) {
item.send(SKEY, 0);
// 音を鳴らす:(ポコ♪)
se3.play();
}
}
//return;
}
} else {
timeR += deltaTime;
$.state.timeR = timeR;
if (!isDame) {
if (timeR > 2) {
$.subNode("Gr_Bbox").setEnabled(true);
$.subNode("Gr_Core").setEnabled(false);
//return;
}
}
}
//おふとんくるくるコード_ここから
if(!$.state.init) {
$.state.originPos = obj.getRotation();
$.state.init = true;
}
if (!$.state.flg) {
return;
}
let time = ($.state.time ?? 0) + deltaTime;
$.state.time = time;
if (time < second) {
//ここから 回転する処理
obj.setRotation(
new Quaternion().setFromAxisAngle(v3,
((time % second / second) * angle * count * rotate ) % angle));
//ここまで
} else {
//ここから 回転したオブジェクトを初期位置に状態をもどす
obj.setRotation($.state.originPos);
//ここまで
$.state.time = 0;
$.state.flg = false;
}
//おふとんくるくるコード_ここまで
});
2)「カウント」部分の実装(スクリプト)について
ペア成立によりプレイヤーの手札となったカードの、より多いものが勝利するという神経衰弱ゲームの勝敗ルールを、どのように実装するかは、大きな課題だった(正直、当初は考えていなかった)が、、、ここは何かしら、ないと締まらない。
最高12得点(ゲーム用アイテム12セット )を分け合うため、1チーム同士の対抗戦で良くないだろうか。と、考えたのが、この、セルフ式、スコアボード。
赤と青の2チームのゲートをそれぞれ常設しておくので、プレイヤー自身が、揃えた1ペア2アイテムを、任意に定めた自チームのゲートに運び込んでいただく。
間違えて相手チームのゲートに運び込んだら、相手チームのカウントになるが、それもゲームのうちだ。
スコアボードのゲートは、接触したオブジェクトを(getOverlaps)で確認する。
まずは、運び込まれたアイテムが、カウント対象のものかを確認するために、該当アイテムに対して、問い合わせ用のメッセージ("who")を投げ、その結果返信されるメッセージをもとに、作動する仕組みになっている。
運び込まれたアイテムが、採点対象アイテムの一覧(変数名:KAr)に存在し、且つペアが揃ったというフラグが立っていたら、スコアパネルを1カウントアップさせる。
具体的には、スコアボードのカウントゲージ部分には、あらかじめ15個のポイントアイテムが非表示状態でセットしてあるので、これを、その都度一つづつ表示させていく仕組みだ。
同時に、スコアゲージのカウントアップ処理後、運び込まれたアイテムには、自身を消滅させて欲しいので、トリガーとなるKeyメッセージ("Reset")を送信する。
「カウント」用アイテム(赤と青の2ゲート)のスクリプト全文
//対象となるアイテムのKEY一覧を配列に格納
const KAr = ["HA1","HA2","HB1","HB2","HC1","HC2","HD1","HD2","MA1","MA2","MB1","MB2","MC1","MC2","MD1","MD2","RA1","RA2","RB1","RB2","RC1","RC2","RD1","RD2",""];
//const YY = KAr.length
const se1 = $.audio("Audio1");
//得点としてゲージに表示する子アイテムの一覧を配列に格納
const PAr = [];
const PNum = 15;
for (let PNo = 1; PNo < PNum; PNo++) {
PAr.push($.subNode("P" + PNo));
}
// onReceive...メッセージを受け取ったときに実行される
$.onReceive((requestName, arg, sender) => {
// 初期化
if (!$.state.initialized) {
$.state.initialized = true;
}
//受け取ったメッセージと同じものが
//対象となるアイテムのKEY一覧配列 KAr[] の中に存在したら true をセット
let Khit = KAr.some(value => value == requestName) //true or false
//メッセージと同じものが配列の中に存在する(処理対象となるアイテムである)場合に処理
if (Khit) {
//メッセージに含まれるパラメータで処理対象とする状態(ペアが見つかっているアイテム)かを判断
if (arg == 1) {
//ゲージをカウントアップする(ペアアイテムの片方 -KEYの3文字目が1のもの- を対象に処理)
if (requestName[2] == "1" ) {
for (let PNo = 1; PNo < PNum; PNo++) {
Pact1 = PAr[PNo].getEnabled()
if (!Pact1) {
PAr[PNo].setEnabled(true); //配列に設定したsubNodeのうち、一番若く非表示になっているものを表示する
if(PNo = 1 ) { $.subNode("02_RBox_BM").setEnabled(true) } //ゲージの目盛板
se1.play();
break;
}
}
}
//ゲージカウントアップ処理の終わったアイテムに自身を消すためのKEYを送信
sender.send("Reset", 10);
return ;
}
}
//ゴールリセット用アイテムからのメッセージを受け取った場合は、ゲージをリセットする
if (requestName == "BarReset") {
$.subNode("02_RBox_BM").setEnabled(false) //ゲージの目盛板
se1.play();
for (let PNo = 1; PNo < PNum; PNo++) {
PAr[PNo].setEnabled(false);
}
return ;
}
});
$.onUpdate(deltaTime => {
// 初期化
if (!$.state.initialized) {
$.state.initialized = true;
$.state.overlapItems = [];
}
// 前のフレームで接触していたアイテムIDの一覧
let previousOverlapItems = $.state.overlapItems;
// このフレームで接触しているアイテムIDの一覧
let currentOverlapItems = [];
// 接触しているオブジェクトをすべて取得
let overlaps = $.getOverlaps();
overlaps.forEach(overlap => {
// 接触しているオブジェクトがアイテムであるかどうかを確認
let itemHandle = overlap.object.itemHandle;
if (itemHandle == null) return;
// 現在接触しているアイテムの一覧に追加
currentOverlapItems.push(itemHandle.id);
// 前のフレームで接触していたアイテムは除外
// メッセージの送信には頻度制限があるためその対策、また接触し続けた場合にダメージが入り続けることを防止
if (previousOverlapItems.includes(itemHandle.id)) return;
// メッセージを送信
//itemHandle.send("damage", 10);
itemHandle.send("who", 10);
});
// 接触しているアイテムの一覧を更新
$.state.overlapItems = currentOverlapItems;
});
3)「カウントリセット」の実装(スクリプト)について
カウントアップしたゲージをリセットする仕組みには、ゴールリセット用のメッセージを発信する、専用の別アイテムを用意した。
こちらも、スコアボードに運び込まれることで、メッセージのやりとりが開始され、スコアパネルのポイントアイテムを、非表示状態に戻す。
「カウントリセット」用アイテムのスクリプト全文
const se1 = $.audio("Audio1");
// onGrab...アイテムを持ったとき、離したときに実行される
$.onGrab((isGrab) => {
// 音を鳴らす:アイテムを持った合図(ポコ♪)
se1.play();
});
// onReceive...メッセージを受け取ったときに実行される
$.onReceive((requestName, arg, sender) => {
// 初期化
if (!$.state.initialized) {
$.state.initialized = true;
}
// 特定のrequestNameのときだけ処理
// "who"受診時のリアクションとしてゴールリセット用のキーを返信する
if (requestName == "who") {
sender.send("BarReset", 1);
}
});
4)課題について
以上、ウルトラソウル【超魂】チーム制作のゲームワールド「粕寺保育園 おやつの時間ですよ!」にて、担当したパートのスクリプトを公開させていただいた。
制作したスクリプトも、この解説文も、本来の自分の能力を超えた域にあるもので、問題点も多いと思う。
また、ゲーム自体にも課題があり、実は、サーバーが混んでいると思われる時、多人数でプレイしたときなど、想定した動きにならない場合もあり、改善の余地があるのか、そもそも設計に無理があったのか、残念ながら、自分にはわからない。
自分がわからなければ、人様に教えていただくしかないのだが、自分ごときの、稚拙なスクリプトなど、誰も目を通したいなどと思わないだろう。
そう諦めていた先日、clusterのとある 公式イベント(#ハロークラスター)にて、神の声が聞こえてしまった。
「インターネットを信じろ!」「公開されていることが大事」という、スクリプト等の使用例や疑問点など、共有を促すものだ。
聞こえてしまったら、乗らないわけがない。
というわけで、ゲームの品質および当方の能力向上につながる、貴重な ご助言、ご指摘、温かめのやつ、お待ちしております。
最後に、スクリプト作成に伴い、参考、流用させていただいた、サンプルコード提供者の皆様、我が ウルトラソウル【超魂】チームメンバーの諸氏、このような試行錯誤の機会を提供、運用いただいている、cluster公式さまに、感謝申し上げます。