メインコンテンツまでスキップ

実際に作ることになるもの

ここの 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);
});
});
物理に見せたいなら、collider も付けてください

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 });
来場者が回せるカメラを付け、AR は使わない

コンフィギュレーターはたいてい、ただの 3D シーンです。すぐ開き、どの端末でも動き、マーカーも 要りません。カメラを orbit にして極角を制限し、下から覗き込まれないようにしてください。 → カメラ

写真撮影を有効にする

スクリーンショットを撮れるコンフィギュレーターは、共有されるコンフィギュレーターです。 → プロジェクトの設定

状態をどこに置くか

その値は…置き場所
この script だけが使うinit の中のふつうの変数
シーンが変わったあとも必要ctx.store
ほかの script・イベント・patch が必要とするctx.setGlobal
ほかが反応すべき出来事ctx.postMessage
ルームのほかの人と共有するctx.net.state
どれもタブを閉じたら残りません

次に来たときにも必要なものは、来場者がまだページにいるうちに、自分のサーバーへ送ってください。


次へ: マルチプレイヤー