レシピ
ほとんどのプロジェクトで必要になる処理の、完成したスクリプト集です。コピーしてオブジェクトに付け、数値を変えるだけで使えます。
スクリプトを書くのが初めてなら、はじめてのスクリプトから始めてください。ここにあるスクリプトに共通する部分を説明しています。
どのスクリプトも、使うコンポーネントクラスを @was/engine からインポートします。init と defineProps はインポートしなくても使えます。
オブジェクトを回す
import { TransformComponent } from '@was/engine';
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 は、直前のフレームにかかった時間です。dt を掛けると、速いスマートフォンでも遅いスマートフォンでも、オブジェクトは 1 秒あたり同じ速さで回ります。掛けないと、速さがデバイスによって変わってしまいます。
上下にふわふわ動かす
import { TransformComponent } from '@was/engine';
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', () => {
if (props.sound) ctx.audio.play(props.sound.id);
ctx.setGlobal('score', (ctx.getGlobal<number>('score') ?? 0) + props.points);
ctx.destroy(ctx.entity);
});
});
スコアはグローバルに保存するので、画面上のカウンターや勝利の判定など、ほかのものからも読めます。
スコアを画面に表示する
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); // 最初の値を表示する
});
数値はスクリプトが持ち、カードは表示するだけにします。スコアをカードの中で持つと、ほかのものがスコアを必要とした時点でうまく動かなくなります。
カウントダウン
UI カードの上でカウントダウンします。時間切れになると、シグナル time-up を送ります。すると、Signal トリガーが time-up に設定されたイベントがすべて実行されます。
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.step('emit_signal', { signal: 'time-up', scope: 'broadcast' });
}
ui.set('time', `${Math.ceil(left)}`);
});
});
数秒ごとに繰り返す
init((ctx) => {
const props = defineProps({
thing: { type: 'resource', resource: 'scene', label: 'Model' },
every: { type: 'number', default: 3, suffix: 's' },
});
let since = 0;
ctx.tick((dt) => {
since += dt;
if (since < props.every) return;
since = 0;
if (props.thing) ctx.spawn(props.thing, { parent: ctx.scene });
});
});
スクリプトには setInterval がありません。代わりに tick の中で秒数を数えます。オブジェクトが削除されれば、自動で止まります。
3D モデルは scene 型のリソースです。そのため、モデルの設定は { type: 'resource', resource: 'scene' } と書きます。
スクリプト付きのオブジェクトを生成する
ctx.spawn は、リソースからオブジェクトをつくります。モデルを生成すると、手に入るのはモデルだけで、スクリプトは付いていません。
追いかけてくる敵、飛んでいく弾、反応するアイテムなどは、オブジェクトを自分で組み立ててスクリプトを追加します。
import {
ColliderComponent,
ModelRefComponent,
RigidBodyComponent,
ScriptComponent,
TransformComponent,
} from '@was/engine';
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));
});
生成したオブジェクトは、それぞれ自分専用のコピーのスクリプトと、自分専用の変数を持ちます。そのため、何もしなくても、敵ごとに体力、ステート、タイマーが別々になります。
// 敵自身のスクリプト
init((ctx) => {
let health = 3;
ctx.on('on-collide', () => {
health -= 1;
if (health <= 0) ctx.destroy(ctx.entity);
});
});
create でつくったオブジェクトには、指定したコンポーネントしかありません。コライダーがなければ衝突せず、リジッドボディがなければ物理で動きません。
いちばん近いオブジェクトを探す
タワー、敵など、ターゲットを選ぶものに便利です。
import { Entity, TransformComponent } from '@was/engine';
const nearest = (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(...) で取得します。敵だけが持つコンポーネントを指定しましょう。または、敵をつくるときにタグを付けておき、タグで絞り込みます。
プレイヤーについていく
ターゲットに向かって進み、決めた距離まで近づいたら止まります。
import { TransformComponent } from '@was/engine';
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 を掛けると、オブジェクトがちょうどその距離だけ動きます。この書き方はよく使います。
見ているものを撃つ
import { MetaComponent } from '@was/engine';
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 が必要なのは、結果が次のフレームで届くからです。
物理オブジェクトを投げる
import {
ColliderComponent,
InitialVelocityComponent,
ModelRefComponent,
RigidBodyComponent,
TransformComponent,
} from '@was/engine';
init((ctx) => {
const props = defineProps({
ball: { type: 'resource', resource: 'scene', label: 'Ball' },
speed: { type: 'number', default: 6, suffix: 'm/s' },
});
ctx.on('on-click', () => {
const pose = ctx.camera.pose();
if (!pose || !props.ball) return;
const { x, y, z } = pose.forward;
const ball = ctx.create({
name: 'Ball',
parent: ctx.scene,
components: [
new ModelRefComponent({ referal: props.ball.id }),
new RigidBodyComponent({ type: 'dynamic' }),
new ColliderComponent({ type: 'sphere', radius: 0.1 }),
// ボディが最初に持つ速度。1 回だけ加わる
new InitialVelocityComponent({
velocity: { x: x * props.speed, y: y * props.speed, z: z * props.speed },
}),
],
});
ball.getComponent(TransformComponent)?.update({ position: pose.position });
});
});
カメラの姿勢はワールド座標で表され、ボールの位置はそのシーンを基準に測られます。3D シーンでは、この 2 つは同じです。
物理は、ボディをつくるときに位置と初速を 1 回だけ読み取ります。そのあとで位置を設定しても、何も起きません。ボディを置くには ctx.physics.teleport を、押すには ctx.physics.applyImpulse を使います。
ドアを一度だけ開ける
import { TransformComponent } from '@was/engine';
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 を使うと、変化が一瞬ではなく、なめらかになります。この中で変更したものは、すべて一緒に動きます。
衝突に反応する
import { MetaComponent } from '@was/engine';
init((ctx) => {
const props = defineProps({
thud: { type: 'resource', resource: 'audio' },
});
ctx.on('on-collide', ({ other }) => {
const hit = ctx.get(other);
const name = hit?.getComponent(MetaComponent)?.$data.name ?? '';
if (name !== 'Player') return;
if (props.thud) ctx.audio.play(props.thud.id);
ctx.postMessage('player-hit', { by: ctx.entity.id });
});
});
postMessage は、起きたことをほかのスクリプトに伝えます。どちらのスクリプトも、相手への参照を持つ必要はありません。
トラッキングを待つ
シーンが見つかったらアニメーションを再生し、トラッキングが外れたら止めます。
init((ctx) => {
const props = defineProps({
animation: { type: 'string', default: 'Intro', label: 'Animation name' },
});
// ステップにはアニメーションの ID を渡す。Animation パネルに表示される名前で探す
const presets = ctx.entity.getComponent('TemporalStateComponent')?.$data.presets ?? [];
const presetId = presets.find(
(p: { id: string; name: string }) => p.name === props.animation,
)?.id;
if (!presetId) return;
ctx.on('on-detect', () => ctx.step('play_animation', { presetId }));
ctx.on('on-lost', () => ctx.step('stop_animation', { presetId }));
});
エディターがステップとして実行できることは、スクリプトからも 1 回の呼び出しで実行できます。アニメーション、トランジション、ステート、ナビゲーションなどです。何かを自分でつくる前に、ステップに目を通してください。
商品コンフィギュレーター
コンフィギュレーターがやることは 2 つです。表示するものを変えることと、見る人が選んだものを覚えておくことです。
import { MaterialComponent } from '@was/engine';
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); // 最初の仕上げから始める
});
仕上げではなくモデルを丸ごと入れ替えるときは、defineProps にモデルのリストを追加し、choose の中でモデルコンポーネントの referal を変更します。
import { ModelRefComponent } from '@was/engine';
// defineProps の中に
models: { type: 'array', item: { type: 'resource', resource: 'scene' }, label: 'Models' },
// choose(index) の中に
ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
コンフィギュレーターは、たいてい AR を使わない普通の 3D シーンでつくります。すぐに開き、どのデバイスでも動き、マーカーも要りません。カメラを orbit にして、傾けられる範囲(極角)を制限しておけば、誰も商品を下から見ることはありません。→ カメラ
見る人が選んだものを写真に撮って、共有できるようにします。 → プロジェクトの設定
値を置く場所
| 値の使われ方 | 置き場所 |
|---|---|
| このスクリプトだけで使う | init の中の普通の変数 |
| シーンが切り替わったあとも必要 | ctx.store |
| ほかのスクリプト、イベント、パッチで必要 | ctx.setGlobal |
| ほかのものに反応してほしい出来事 | ctx.postMessage |
| ルームにいるほかの人と共有する | ctx.net.state |
次回も使いたい値があるなら、見る人がまだページにいる間に、自分のサーバーへ送ってください。
次へ: マルチプレイヤー — 1 つのルームで複数の人が使うスクリプト。