この記事は「クラスター Advent Calendar 2025」の25日目の記事になります。
はじめに
メリークリスマス!
昨年から引き続き、今年もAIが大いに発展した年でしたね!
恐らく皆さんも日ごろから便利に活用しているのではないでしょうか?
AIといえば、CreatorsGuideの記事でも『AIに Cluster Script を書いてもらう方法』をご紹介したことがあります。
こちらの記事を見て、実際にAIを活用してCluster Scriptにチャレンジしてみて上手に活用出来た方もいれば、活用イメージが湧かなくてまだ試せていない方など、様々な方がいると思います。
そこで、今回の記事ではこのようなお悩みをお持ちの方たちに向けて、
”ClusterScriptを一切自分で考えない”で、”AIと一緒”に公園を作ってみようというテーマになっております。
諸注意
- 記事の構成の都合上、AIが出力したコードをそのまま掲載しています。
そのため、掲載したコードが最適な実装になっているとは限りません - 記事中のコードをご利用いただいても問題はありませんが、何か不具合が見つかる可能性もある点にご注意ください。
- 使用するAIが違う場合はもちろん、同じAI・環境を構築したとしても記事の内容と結果が変わる場合があります。
この記事の想定読者の方
- Cluster Scriptにチャレンジしてみたいけど自信がない方
- AIを使ってみたけど上手くいかなかった方
- そもそも活用イメージが湧かない方
- ご自身でアイテム用のモデルをご用意できる方
開発環境
- cluster
- cluster公式サイトからインストールしてください
- https://cluster.mu/
- Unity 6000.2.6f2
- CCK 3.0.0以上も併せて必要
- 導入方法はこちら
- Google Notebook LM
ステップ0: イメージを固める
さっそくAIを使って…と行きたいところですが、まずは自分で完成のイメージをしっかりと固めることが重要です。
自分が完成をイメージできないものは、AIにもイメージができないのです。
なので、今回はイメージを分かりやすくするために『公園』をモチーフとしました。
公園には様々な動きをする遊具や要素があるので、それらを一つ一つ表現してみようかなと思っています。
ステップ1: まずは簡単そうなところから「回る遊具を作る」
まずはこんなモチーフを用意してみました。
真ん中の円盤状の台座が回る遊具のようです。

モデルの階層構造はこのようになっています。
”pivot”オブジェクトが円盤状の台座の中心位置になるような親オブジェクトになっているのがキモです。


このpivotをY軸(緑色の矢印)基準に時計回りに回転させるような動きをさせたいと思います。
せっかくなので乗って遊べるようにもしたいですね。
まずは下準備としてオブジェクトに必要なコンポーネントを追加します。
- ClusterScriptを適用するための”Scriptable Item”
- 座るための”Ridable Item”
- アイテムをクリック(インタラクト)できるようにするための”Mesh Collider”
※seatには座る位置を調整するための空のGameObjectを指定しています。


※Mesh Colliderはdiskとringに設定しています。
現時点ではこんな感じ。

次はいよいよ回転動作を付けていきます。
まずは試しに以下のような感じでお願いをしてみました。
以下のような仕様のClusterScriptを書いてください。
1. アイテム(RidableItem)をクリックして座ると、子階層にいるpivotオブジェクトがY軸を基点に時計回りに回転する。
2. 座っていないときは回転動作が止まる
ポイント
AIにお願いする際は、「現在のオブジェクトやデータ状態を教えること」と「具体的な指示をすること」を意識すると良い結果になりやすい気がします。
そして出来上がったコードがこちら。
// 回転させる対象のSubNode名
const PIVOT_NAME = "pivot";
// 回転速度(度/秒)。正の値でY軸基準の回転を行います。
const ROTATION_SPEED = 90.0;
// 1. 乗降イベントの検知
$.onRide((isGetOn) => {
// 座った状態(true)か降りた状態(false)かをstateに保存します
$.state.isRiding = isGetOn;
});
// 2. 毎フレームの更新処理
$.onUpdate((deltaTime) => {
// 座っていない場合は処理を中断(回転動作が止まる)
const isRiding = $.state.isRiding ?? false;
if (!isRiding) {
return;
}
// 現在の角度を取得(未定義の場合は0)
let currentAngle = ($.state.angle as number) ?? 0;
// 経過時間分だけ角度を加算(時計回り)
// Y軸正方向の回転を時計回りとみなして加算します
currentAngle += ROTATION_SPEED * deltaTime;
// 角度が大きくなりすぎないように360度で剰余をとる
currentAngle %= 360;
// 計算した角度をstateに保存
$.state.angle = currentAngle;
// SubNodeを取得して回転を適用
const pivotNode = $.subNode(PIVOT_NAME);
if (pivotNode) {
// オイラー角(X, Y, Z)からクォータニオンを作成して適用
// Y軸のみ回転させます
const newRotation = new Quaternion().setFromEulerAngles(0, currentAngle, 0);
pivotNode.setRotation(newRotation);
}
});早速このコードを一切手を加えずにScriptable ItemのSouce Codeに設定してアップロードしてみましょう。

動きませんでした。
人生、そう簡単に上手くいかないものですね。

ただ、このままだとどうやって直してもらえばいいか分からなくて困ってしまいますね。
試しに「動かないから直して」とAIに頼んでみてもいいかもしれませんが、もう少し工夫してみましょう。
まずはデベロッパーメニュー(※)からコンソール機能をONにし、何か出ていないか確認をしてみましょう。
※… もしメニューが見つからない場合は 設定>その他>開発者向け機能の有効化 をONにしましょう。


どうやらSyntaxError(書き方が間違っているときにでるエラー)が出ているようです。
このエラー情報をAIに教えてあげましょう。

AIがエラーを解決したようなことを言っているので、修正されたコードを改めて適用してみましょう。
// 回転させる対象のSubNode名
const PIVOT_NAME = "pivot";
// 回転速度(度/秒)。正の値でY軸基準の回転を行います。
const ROTATION_SPEED = 90.0;
// 1. 乗降イベントの検知
$.onRide((isGetOn) => {
// 座った状態(true)か降りた状態(false)かをstateに保存します
$.state.isRiding = isGetOn;
});
// 2. 毎フレームの更新処理
$.onUpdate((deltaTime) => {
// 座っていない場合は処理を中断(回転動作が止まる)
const isRiding = $.state.isRiding ?? false;
if (!isRiding) {
return;
}
// 現在の角度を取得(未定義の場合は0)
// 【修正】as number を削除し、JavaScriptとして有効な記述にしました
let currentAngle = $.state.angle ?? 0;
// 経過時間分だけ角度を加算(時計回り)
// Y軸正方向の回転を時計回りとみなして加算します
currentAngle += ROTATION_SPEED * deltaTime;
// 角度が大きくなりすぎないように360度で剰余をとる
currentAngle %= 360;
// 計算した角度をstateに保存
$.state.angle = currentAngle;
// SubNodeを取得して回転を適用
// サブノードの操作にはsubNode()メソッドを使用します[3]
const pivotNode = $.subNode(PIVOT_NAME);
if (pivotNode) {
// オイラー角(X, Y, Z)からクォータニオンを作成して適用
// Y軸のみ回転させます
// Quaternionの生成にはsetFromEulerAnglesなどが利用可能です[3][4]
const newRotation = new Quaternion().setFromEulerAngles(0, currentAngle, 0);
pivotNode.setRotation(newRotation);
}
});動きました!
ちゃんと座った時のみ回転しているようです!

(ただ、このままではこの遊具の本来の遊び方(?)とは異なってしまうので、遊んでる風なポーズを付けて遊んでみたりしても面白いかもしれませんね。)
公園の遊具には似たような動きをする遊具が多いので、同じ流れで作成することができるか試してみても面白そうです。
ステップ2: ちょっとした工夫をプラス「滑り台を作る」
次は滑り台にチャレンジしてみましょう。
公園にある遊具といったらやっぱりこれ。

せっかくなので実際に滑れるようにしたいところです。
ですが、先ほどの回転する遊具とは違い「とある一点(一軸)を回転させる」のでなく、「とある点Aからとある点Bに移動させる」必要がありそうです。

これをAIにどうやって指示すればよいでしょうか。
たとえば、「Aのポジション(x, y, z)」と「Bのポジション(x, y, z)」をメモしてその情報をAIに伝え、「seatオブジェクトをAのポジション(x, y, z)からBのポジション(x, y ,z)に移動するClusterScriptを書いて」とでもいえばそれで上手く行く可能性もあります。
ですが、せっかくAIを使うならもう少し工夫して難しいことにも挑戦してみたいです。
そこで、今回は以下のようなデータ構成にしてみました。

このpos_1 ~ pos_4が今回のキモです。
それぞれ以下のような位置に設置しています。

このようなデータを用意することで、「seatがpos_1 ~ pos_4上を通過するような経路を移動するClusterScript」をAIに作ってもらう作戦です。
もしこれが成功すれば、滑り台の大きさや長さを変更してもいちいちポジションをメモしてAIに修正してもらう必要もなく、pos_1 ~ pos_4オブジェクトの位置を調整するだけで滑る経路を修正できそうです。
早速AIにお願いしてみましょう。
以下のような仕様のClusterScriptを書いてください。
1. アイテム(RidableItem)をクリックすると、子階層にいるseatオブジェクトが、同じく子階層にあるオブジェクトpos_1, pos_2, pos_3, pos_4の位置を順番に通って移動する
2. 下りると、seatの位置が初期位置にリセットされる
「seatがpos_1~pos_4を巡回し、~」という文章から何やら間違った解釈をされていそうな気がしますが、ひとまず出来上がったコードがこちら。
// 移動させる座席のSubNode名
const SEAT_NAME = "seat";
// 巡回する目標地点のSubNode名リスト
const TARGET_NAMES = ["pos_1", "pos_2", "pos_3", "pos_4"];
// 移動速度 (m/s)
const SPEED = 2.0;
// 目標に到達したとみなす距離 (m)
const THRESHOLD = 0.05;
/**
* 毎フレーム実行される更新処理
* 移動のアニメーションと初期位置の保存を行います
*/
$.onUpdate((deltaTime) => {
// 1. 初期位置の保存(まだ保存されていない場合のみ実行)
if (!$.state.initialized) {
const seat = $.subNode(SEAT_NAME);
const currentPos = seat.getPosition();
if (currentPos) {
$.state.initialPos = currentPos; // Vector3はstateに保存可能[1]
$.state.initialized = true;
}
}
// 2. 移動ロジック(乗っている間のみ実行)
if ($.state.isRiding) {
const seat = $.subNode(SEAT_NAME);
// 現在の目標地点のインデックスを取得
let targetIndex = $.state.targetIndex ?? 0;
const targetName = TARGET_NAMES[targetIndex];
const targetNode = $.subNode(targetName);
// SubNodeの位置取得[2]
const currentPos = seat.getPosition();
const targetPos = targetNode.getPosition();
if (currentPos && targetPos) {
// 現在位置から目標位置へのベクトルと距離を計算[3]-[4]
const direction = targetPos.clone().sub(currentPos);
const distance = direction.length();
if (distance < THRESHOLD) {
// 目標に到達したら次の地点へ切り替え(最後まで行ったら最初に戻る)
targetIndex = (targetIndex + 1) % TARGET_NAMES.length;
$.state.targetIndex = targetIndex;
} else {
// 目標に向かって移動
// 今回のフレームで移動する距離
const moveDist = SPEED * deltaTime;
if (moveDist >= distance) {
// 通過してしまう場合は直接目標位置へセット
seat.setPosition(targetPos);
} else {
// 方向ベクトルを正規化して移動分を加算
const moveVec = direction.normalize().multiplyScalar(moveDist);
const newPos = currentPos.clone().add(moveVec);
// 新しい位置を適用[5]
seat.setPosition(newPos);
}
}
}
}
});
/**
* プレイヤーが乗る・降りる際の処理[6]
*/
$.onRide((isGetOn) => {
// 乗車状態を更新
$.state.isRiding = isGetOn;
if (isGetOn) {
// 乗った時:ターゲットを最初の地点(pos_1)にリセット
$.state.targetIndex = 0;
} else {
// 降りた時:位置を初期位置にリセット
const seat = $.subNode(SEAT_NAME);
const initialPos = $.state.initialPos;
// 保存しておいた初期位置に戻す
if (seat && initialPos) {
seat.setPosition(initialPos); // [5]
}
}
});早速アップロードしてみたところ…
なんか違いそうでした。

pos_1 ~ pos_4上を通るような移動は成功していそうですが、どうやら「pos_1, pos_2, pos_3, pos_4の位置を順番に通って移動する」という文章だと解釈の幅が広くこのような結果になってしまったようです。
あとは、動きに緩急が無く面白みがないのも気になりますね。
なので、追加で以下のようなお願いをしてみましょう。
pos_4に到達したらその場で停止するようにしてください。
また、移動開始時はゆっくりで徐々に加速し、移動終了直前にブレーキがかかるような感じにしてください。
再びSyntaxErrorが出てしまったのでそこだけステップ1でやった手順と同じ方法で修正をお願いし、出来上がったコードがこちら。
はたしてうまくいくでしょうか。
// --- 設定項目 ---
const SEAT_NAME = "seat";
const TARGET_NAMES = ["pos_1", "pos_2", "pos_3", "pos_4"];
const MAX_SPEED = 4.0; // 最高速度 (m/s)
const ACCEL_DISTANCE = 2.0; // 加速にかける距離 (m)
const BRAKE_DISTANCE = 2.0; // 減速を開始するゴール手前の距離 (m)
const MIN_SPEED = 0.5; // 最低速度
const REACH_THRESHOLD = 0.05; // 到達判定距離 (m)
// --- スクリプト本体 ---
$.onUpdate((deltaTime) => {
// 1. 初期化処理(初期位置の保存)
if (!$.state.initialized) {
const seat = $.subNode(SEAT_NAME);
const currentPos = seat.getPosition();
if (currentPos) {
$.state.initialPos = currentPos;
$.state.initialized = true;
}
}
// 2. 移動処理(乗車中かつ、まだ移動終了していない場合)
if ($.state.isRiding && !$.state.isFinished) {
const seat = $.subNode(SEAT_NAME);
// 現在の目標地点
let targetIndex = $.state.targetIndex ?? 0;
// ターゲットリストを超えていたら処理しない(停止)
if (targetIndex >= TARGET_NAMES.length) {
$.state.isFinished = true;
return;
}
const targetName = TARGET_NAMES[targetIndex];
const targetNode = $.subNode(targetName);
const currentPos = seat.getPosition();
const targetNodePos = targetNode.getPosition(); // 変数名をtargetPosから変更して明確化
if (currentPos && targetNodePos) {
// 【修正箇所】as number を削除しました
// 現在の移動総距離を取得
let traveled = $.state.traveledDistance ?? 0;
const totalLength = $.state.totalPathLength ?? 100;
// --- 速度計算ロジック (加速・減速) ---
// 残り距離
const remaining = totalLength - traveled;
// 加速係数 (0.0 ~ 1.0)
let accelFactor = 1.0;
if (traveled < ACCEL_DISTANCE) {
accelFactor = traveled / ACCEL_DISTANCE;
}
// 減速係数 (0.0 ~ 1.0)
let brakeFactor = 1.0;
if (remaining < BRAKE_DISTANCE) {
brakeFactor = remaining / BRAKE_DISTANCE;
}
// 最終的な速度係数 (加速と減速の小さい方を採用)
let speedFactor = Math.min(accelFactor, brakeFactor);
// 速度決定 (最低速度を下回らないようにする)
let currentSpeed = MAX_SPEED * speedFactor;
if (currentSpeed < MIN_SPEED) currentSpeed = MIN_SPEED;
// --- 移動実行 ---
const direction = targetNodePos.clone().sub(currentPos);
const distToTarget = direction.length();
// フレームごとの移動量
const moveStep = currentSpeed * deltaTime;
if (distToTarget < REACH_THRESHOLD) {
// 目標に到達した場合
// 次のターゲットへ
const nextIndex = targetIndex + 1;
$.state.targetIndex = nextIndex;
// もしこれが最後の地点(pos_4)だった場合、位置を合わせて終了フラグを立てる
if (nextIndex >= TARGET_NAMES.length) {
seat.setPosition(targetNodePos);
$.state.isFinished = true;
}
} else {
// 移動
if (moveStep >= distToTarget) {
seat.setPosition(targetNodePos);
$.state.traveledDistance = traveled + distToTarget;
} else {
const moveVec = direction.normalize().multiplyScalar(moveStep);
const newPos = currentPos.clone().add(moveVec);
seat.setPosition(newPos);
$.state.traveledDistance = traveled + moveStep;
}
}
}
}
});
$.onRide((isGetOn) => {
$.state.isRiding = isGetOn;
if (isGetOn) {
// --- 乗車時のセットアップ ---
// 1. 各パラメータのリセット
$.state.targetIndex = 0;
$.state.isFinished = false;
$.state.traveledDistance = 0;
// 2. 経路の全長を計算する (Start -> pos_1 -> pos_2 -> ... -> pos_4)
let totalLen = 0;
const seat = $.subNode(SEAT_NAME);
// 現在位置から計算開始
let prevPos = seat.getPosition();
if (prevPos) {
// 各ポイント間の距離を合計する
for (let i = 0; i < TARGET_NAMES.length; i++) {
const tNode = $.subNode(TARGET_NAMES[i]);
const tPos = tNode.getPosition();
if (tPos) {
totalLen += tPos.clone().sub(prevPos).length();
prevPos = tPos;
}
}
}
$.state.totalPathLength = totalLen;
} else {
// --- 降車時のリセット ---
// 初期位置に戻す
const seat = $.subNode(SEAT_NAME);
const initialPos = $.state.initialPos;
if (seat && initialPos) {
seat.setPosition(initialPos);
}
}
});上手くいきました!

今回はオブジェクトの位置を基準に移動経路を計算するような設計にしてあるため試しに滑り台の大きさや長さを変えてみたところ、狙い通り移動経路も良い感じに対応していそうです。大成功!

このように、単純にAIを使うだけでなくほんの少し人間が工夫することで、より良い結果に辿り着ける可能性も高くなりそうです。
ステップ3: アイテムとアイテムを連携「砂場を作る」
最後に砂場を作ります。
これも広い意味では遊具にはいるのではないでしょうか。入りますよね?

今回砂場でやりたいことは以下の通りです。
- スコップを手にもって砂場をクリックすると砂をすくえる
- 砂をすくったスコップを砂山に近づけると砂山が大きくなり、スコップの砂が無くなる
- 1〜2を繰り返すと、段々砂山が大きくなっていく
これらを達成するためにはアイテムが3つほどあれば行けそうな気がします。

それぞれこんなデータ構成になっています。
- 03_1: スコップ。sandの表示/非表示で砂をすくったような表現をしたい。
- 03_2: 砂場。今回の主役。
- 03_3: 砂山。S, M, Lのモデルを用意し段階的に表示することで少しずつ大きくなる表現をしたい。

あとはこれらをどうやって連携させればよいか。
考えてても仕方ないのでやりたいことを言語化してAIにお願いしてみましょう。
以下の仕様になるClusterScriptを作成してください。
1. アイテムA(GrabbableItem)をアイテムBに近づけると、アイテムAの子の"sand"が表示状態になる。
2. "sand"が表示状態のアイテムAをアイテムCに近づけると、アイテムAの"sand"が非表示、アイテムCの子の”sand_mountain_S”が非表示、"sand_mountain_M"が表示される。
3. "sand_mountain_M"が表示状態のアイテムCに、"sand"が表示状態のアイテムAを近づけると、アイテムAの"sand"が非表示、アイテムCの子の”sand_mountain_M”が非表示、"sand_mountain_L"が表示される。
4. "sand_mountain_L"が表示状態のアイテムCに、"sand"が表示状態のアイテムAを近づけると、アイテムAの"sand"が非表示になるが、アイテムCの表示状態は変わらない。
やりたいことは伝わっていそうです。
あとはこれが上手く動くかどうか。
// アイテムA: 砂を運ぶアイテム
const SAND_NODE_NAME = "sand";
$.onUpdate(deltaTime => {
// 初期化: 最初は砂がない状態にする
if (!$.state.initialized) {
$.state.hasSand = false;
const sandNode = $.subNode(SAND_NODE_NAME);
if (sandNode) {
sandNode.setEnabled(false); // 非表示
}
$.state.initialized = true;
}
});
$.onReceive((messageType, arg, sender) => {
const sandNode = $.subNode(SAND_NODE_NAME);
if (!sandNode) return;
// アイテムBから: 砂を補充する
if (messageType === "Fill") {
if (!$.state.hasSand) {
$.state.hasSand = true;
sandNode.setEnabled(true); // sandを表示 [2]
// $.log("Sand Filled");
}
}
// アイテムCから: 砂を要求された
if (messageType === "RequestSand") {
// 砂を持っている場合のみ渡す
if ($.state.hasSand) {
$.state.hasSand = false;
sandNode.setEnabled(false); // sandを非表示 [2]
// 要求元のアイテムCに「砂を渡しました」と返信する
// senderはItemHandle型として扱います
if (sender) {
sender.send("SandDelivered", null); // [1]
}
}
}
});
// アイテムB: 砂を補充する場所
const DETECTION_RADIUS = 1.5; // 検知する半径(m)
const CHECK_INTERVAL = 0.5; // 検知を行う間隔(秒) ※負荷軽減のため
$.onUpdate(deltaTime => {
let timer = $.state.timer ?? 0;
timer += deltaTime;
if (timer > CHECK_INTERVAL) {
timer = 0;
// 半径内のアイテムを探す [3]
const items = $.getItemsNear($.getPosition(), DETECTION_RADIUS);
for (let item of items) {
// 見つけたアイテムに「Fill」メッセージを送る [1]
// アイテムA以外は反応しないようにA側で実装されているため、無差別に送っても問題ありません
item.send("Fill", null);
}
}
$.state.timer = timer;
});
// アイテムC: 砂山が成長する場所
const MOUNTAIN_NODES = ["sand_mountain_S", "sand_mountain_M", "sand_mountain_L"];
const DETECTION_RADIUS = 1.5; // 検知する半径(m)
const CHECK_INTERVAL = 0.5; // 検知を行う間隔(秒)
// 状態に応じた表示更新を行う関数
function updateMountainVisuals(currentStage) {
for (let i = 0; i < MOUNTAIN_NODES.length; i++) {
const node = $.subNode(MOUNTAIN_NODES[i]); // [4]
if (node) {
// 現在のステージと一致するものだけ表示、それ以外は非表示 [2]
node.setEnabled(i === currentStage);
}
}
}
$.onUpdate(deltaTime => {
// 初期化処理: 最初はステージ0(S)を表示
if (!$.state.initialized) {
$.state.stage = 0; // 0:S, 1:M, 2:L
updateMountainVisuals(0);
$.state.initialized = true;
}
// 定期的に周囲のアイテムAに対して砂を要求する
let timer = $.state.timer ?? 0;
timer += deltaTime;
if (timer > CHECK_INTERVAL) {
timer = 0;
const items = $.getItemsNear($.getPosition(), DETECTION_RADIUS); // [3]
for (let item of items) {
// 近くのアイテムに「砂を持っていたらください」とメッセージを送る
item.send("RequestSand", null); // [1]
}
}
$.state.timer = timer;
});
// アイテムAから「砂を渡した」という返信を受け取った時の処理
$.onReceive((messageType, arg, sender) => {
if (messageType === "SandDelivered") {
let currentStage = $.state.stage;
// まだL(2)になっていなければ段階を進める
if (currentStage < 2) {
currentStage++;
$.state.stage = currentStage;
updateMountainVisuals(currentStage); // 表示更新
}
// すでにL(2)の場合は表示を変えない(Aの砂だけが消える挙動はA側で処理済み)
}
});
なんと一発成功です!素晴らしい!!

唯一気になるところとしては微妙にアイテム同士が遠い位置でも反応してしまうので、コードの中の「検知する半径(m)」と書かれている箇所の値を調整すればもっといい感じになるかもしれません。(現状は半径1.5m以内で反応する)
しかし、特に躓くことなく一発でこのギミックが出来たのには驚きました。
ただ山を大きくするだけでなく城の形にしてみたり、別のアイテムを用意して完成した山や城に当てると壊せる!みたいな遊びを追加で作ってみても面白いかもしれませんね。
おわりに
今回はAIと一緒に公園を作ってみました。
実は今回の記事で触れたギミックはどれも記事を書きながらリアルタイムで作っていっていたもので、実際に上手くいくかどうかは分かりませんでしたが、少なくともやりたかったことは達成できたのでホッとしました。
慣れてくると、AIが書いたコードを自分で微調整するといったプロセスでClusterScript制作の効率化や学習に役立てることも出来そうです。
皆さんも年末休みにAIを使って自分だけの公園を作ってみてはいかがでしょうか?

おまけ
よいお年を。





















