実際に作ることになるもの
ここの script はどれも完成品です。コピーしてオブジェクトに付け、数値を変えてください。
まだ 1 つも書いたことがなければ、はじめての script から始めてください。 ここに並ぶものが共有している形を説明しています。
何かをずっと回し続ける
init((ctx) => {
const props = defineProps({
speed: { type: 'number', default: 45, suffix: '°/s' },
});
ctx.tick((dt) => {
const t = ctx.entity.getComponent(TransformComponent);
if (!t) return;
const turn = (props.speed * Math.PI) / 180; // 度 → ラジアン
t.update({
rotation: { ...t.$data.rotation, y: t.$data.rotation.y + turn * dt },
});
});
});
dt を掛けるのかdt は直前のフレームにかかった時間です。これを掛けることで、速いスマートフォンでも遅い端末でも、
1 秒あたり同じだけ回ります。省くと、端末がたまたま出せる速さでアニメーションが進みます。
何かをゆっくり上下に揺らす
init((ctx) => {
const t = ctx.entity.getComponent(TransformComponent);
const startY = t?.$data.position.y ?? 0;
ctx.tick((dt, time) => {
t?.update({
position: { ...t.$data.position, y: startY + Math.sin(time * 2) * 0.1 },
});
});
});
Math.sin は −1 と 1 の間をなめらかに、いつまでも往復します。掛ける数で揺れ幅を、時間に掛ける数で
速さを決めます。
タップで回収する
init((ctx) => {
const props = defineProps({
points: { type: 'number', default: 1 },
sound: { type: 'resource', resource: 'audio' },
});
ctx.on('on-click', () => {
ctx.audio.play(props.sound);
ctx.setGlobal('score', ((ctx.getGlobal<number>('score') ?? 0) + props.points));
ctx.destroy(ctx.entity);
});
});
スコアは global に置きます。ほかのもの(画面上のカウンター、クリア条件)が読めるようにするためです。
そのスコアを画面に出す
UI カードを持っているオブジェクトに、これを付けます。
init((ctx) => {
const ui = ctx.getDivKit(ctx.entity);
ctx.subscribeGlobal('score', (value) => {
ui.set('score', Number(value ?? 0));
});
ui.set('score', ctx.getGlobal<number>('score') ?? 0); // 初期値を表示する
});
数値を持つのはロジック、カードはそれを表示するだけ。 スコアをカードの中に置こうとすると、 ほかの何かがそれを必要とした瞬間に破綻します。
カウントダウン
init((ctx) => {
const props = defineProps({ seconds: { type: 'number', default: 60 } });
const ui = ctx.getDivKit(ctx.entity);
let left = props.seconds;
let finished = false;
ctx.tick((dt) => {
if (finished) return;
left -= dt;
if (left <= 0) {
finished = true;
left = 0;
ctx.emit('on-signal', { signal: 'time-up' });
}
ui.set('time', `${Math.ceil(left)}`);
});
});
数秒おきに何かをする
init((ctx) => {
const props = defineProps({ every: { type: 'number', default: 3, suffix: 's' } });
let since = 0;
ctx.tick((dt) => {
since += dt;
if (since < props.every) return;
since = 0;
ctx.spawn(props.thing, { parent: ctx.scene });
});
});
ここに setInterval はありません。tick の中で秒を数えるのが同等の手段で、オブジェクトが
消えれば自分で止まります。
自分の挙動を持つものをスポーンする
これはよく引っかかります。ctx.spawn はリソースからオブジェクトを組み立てるので、モデルを
スポーンすればモデルが出ます。それだけです。script は付いてきません。
追いかけてくる敵、飛んでいく弾、反応するアイテムには、オブジェクトを自分で組み立てて挙動を 付けてください。
init((ctx) => {
const props = defineProps({
model: { type: 'resource', resource: 'scene' },
brain: { type: 'resource', resource: 'script' },
});
const spawnEnemy = (x: number, z: number) => {
const enemy = ctx.create({
name: 'Enemy',
parent: ctx.scene,
components: [
new ModelRefComponent({ referal: props.model?.id ?? null }),
new ScriptComponent({ referal: props.brain?.id ?? null }),
new RigidBodyComponent({ type: 'dynamic' }),
new ColliderComponent({ type: 'capsule', radius: 0.3, height: 1.6 }),
],
});
enemy.getComponent(TransformComponent)?.update({ position: { x, y: 0, z } });
return enemy;
};
ctx.on('on-launch', () => spawnEnemy(2, 0));
});
スポーンされたオブジェクトは、それぞれがその script の専用インスタンスを持ち、変数も別々です。 敵ごとの体力・状態・タイマーに、特別な仕掛けは要りません。
// 敵自身の script
init((ctx) => {
let health = 3;
ctx.on('on-collide', () => {
health -= 1;
if (health <= 0) ctx.destroy(ctx.entity);
});
});
create で組み立てたオブジェクトは、並べた component そのものです。collider を忘れれば衝突せず、
rigid body を忘れれば物理は動かしません。
いちばん近いものを見つける
タレット、敵、そのほか狙いを定めるもの全般の、もう半分です。
const nearest = (ctx, from: Entity, candidates: Entity[]) => {
const a = from.getComponent(TransformComponent)?.$data.position;
if (!a) return undefined;
let best: Entity | undefined;
let bestGap = Infinity;
for (const c of candidates) {
const b = c.getComponent(TransformComponent)?.$data.position;
if (!b) continue;
const gap = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
if (gap < bestGap) { bestGap = gap; best = c; }
}
return best;
};
候補は ctx.query(...) で集めます。敵だけが持つ component を指定するか、作成時に
tags で印を付けて絞り込んでください。
プレイヤーを追うが、近づきすぎない
init((ctx) => {
const props = defineProps({
target: { type: 'entity' },
distance: { type: 'number', default: 2 },
speed: { type: 'number', default: 1.5 },
});
ctx.tick((dt) => {
const me = ctx.entity.getComponent(TransformComponent);
const them = props.target?.getComponent(TransformComponent);
if (!me || !them) return;
const a = me.$data.position;
const b = them.$data.position;
const dx = b.x - a.x;
const dz = b.z - a.z;
const gap = Math.hypot(dx, dz);
if (gap <= props.distance) return; // 十分近い
const step = Math.min(props.speed * dt, gap - props.distance);
me.update({
position: { ...a, x: a.x + (dx / gap) * step, z: a.z + (dz / gap) * step },
});
});
});
gap で割ると、方向の長さがちょうど 1 になります。だから step を掛ければ、その分だけ正確に
動きます。この手はしょっちゅう出てきます。
見ているものを撃つ
init((ctx) => {
ctx.on('on-click', async () => {
const hits = await ctx.raycast();
const hit = hits[0];
if (!hit?.entity) return;
const meta = hit.entity.getComponent(MetaComponent);
if (meta?.$data.name.startsWith('Target')) ctx.destroy(hit.entity);
});
});
await が必要なのは、レイが実際のシーンに対して飛ばされ、答えが次のフレームに返ってくるからです。
物理オブジェクトを投げる
init((ctx) => {
const props = defineProps({ force: { type: 'number', default: 6 } });
ctx.on('on-click', () => {
const pose = ctx.camera.pose();
if (!pose) return;
const ball = ctx.spawn(props.ball, { parent: ctx.scene });
ctx.physics.teleport(ball, pose.position);
ctx.physics.applyImpulse(ball, {
x: pose.forward.x * props.force,
y: pose.forward.y * props.force,
z: pose.forward.z * props.force,
});
});
});
teleport、押すのは applyImpulse物理オブジェクトの位置を直接設定しても何も起きません。位置は物理が所有していて、次のステップで 上書きされます。
一度だけ開くドア
init((ctx) => {
let open = false;
ctx.on('on-click', () => {
if (open) return;
open = true;
ctx.startTransition({ durationMs: 600, easing: 'ease-out' }, () => {
const t = ctx.entity.getComponent(TransformComponent);
t?.update({ rotation: { ...t.$data.rotation, y: Math.PI / 2 } });
});
});
});
startTransition が、変化をパチッとではなく滑らかにします。その中で変えたものは、まとめて
なめらかに動きます。
衝突に反応する
init((ctx) => {
ctx.on('on-collide', ({ other }) => {
const hit = ctx.get(other);
const name = hit?.getComponent(MetaComponent)?.$data.name ?? '';
if (name !== 'Player') return;
ctx.audio.play(props.thud);
ctx.postMessage('player-hit', { by: ctx.entity.id });
});
});
postMessage は、双方が相手への参照を持たなくても、ほかの script に知らせられます。
シーンが検出されるのを待つ
init((ctx) => {
ctx.on('on-detect', () => {
ctx.step('play_animation', { presetId: 'intro' });
});
ctx.on('on-lost', () => {
ctx.step('stop_animation', { presetId: 'intro' });
});
});
ctx.step があれば、エンジンを書き直さずに済みますエディターがステップとしてできること(アニメーション、トランジション、state、画面遷移)は、 script から呼び出し 1 回で起こせます。手で作り始める前に ステップのリファレンスに目を通してください。
製品コンフィギュレーター
このプラットフォームでいちばん多い商業案件で、やることはほぼ 2 つです。表示するものを差し替え、 選ばれたものを覚える。
init((ctx) => {
const props = defineProps({
variants: {
type: 'array',
item: { type: 'resource', resource: 'material' },
label: 'Finishes',
},
prices: { type: 'array', item: { type: 'number' }, label: 'Price per finish' },
});
const ui = ctx.getDivKit(ctx.entity);
const material = ctx.entity.getComponent(MaterialComponent);
const choose = (index: number) => {
const finish = props.variants[index];
if (!finish || !material) return;
// マテリアルのスロットは、値を持つ代わりに保存済みマテリアルを指せます。
material.update({
materials: [{ ...material.$data.materials[0], type: 'ref', referal: finish.id }],
});
ctx.setGlobal('finish', index);
ui.set('price', props.prices[index] ?? 0);
};
ui.onAction('finish-0', () => choose(0));
ui.onAction('finish-1', () => choose(1));
ui.onAction('finish-2', () => choose(2));
ui.onAction('buy', () => {
const index = ctx.getGlobal<number>('finish') ?? 0;
ctx.step('url_transit_action', {
url: `https://shop.example.com/chair?finish=${index}`,
transitionType: 'new_tab',
});
});
choose(0); // 最初の仕上げから始める
});
仕上げではなくモデルごと差し替える場合も形は同じで、referal をモデルの component に
書きます。
ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
コンフィギュレーターはたいてい、ただの 3D シーンです。すぐ開き、どの端末でも動き、マーカーも
要りません。カメラを orbit にして極角を制限し、下から覗き込まれないようにしてください。
→ カメラ
スクリーンショットを撮れるコンフィギュレーターは、共有されるコンフィギュレーターです。 → プロジェクトの設定
状態をどこに置くか
| その値は… | 置き場所 |
|---|---|
| この script だけが使う | init の中のふつうの変数 |
| シーンが変わったあとも必要 | ctx.store |
| ほかの script・イベント・patch が必要とする | ctx.setGlobal |
| ほかが反応すべき出来事 | ctx.postMessage |
| ルームのほかの人と共有する | ctx.net.state |
次に来たときにも必要なものは、来場者がまだページにいるうちに、自分のサーバーへ送ってください。
次へ: マルチプレイヤー