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

レシピ

ほとんどのプロジェクトで必要になる処理の、完成したスクリプト集です。コピーしてオブジェクトに付け、数値を変えるだけで使えます。

スクリプトを書くのが初めてなら、はじめてのスクリプトから始めてください。ここにあるスクリプトに共通する部分を説明しています。

どのスクリプトも、使うコンポーネントクラスを @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 });
3D シーンと orbit カメラを使いましょう

コンフィギュレーターは、たいてい AR を使わない普通の 3D シーンでつくります。すぐに開き、どのデバイスでも動き、マーカーも要りません。カメラを orbit にして、傾けられる範囲(極角)を制限しておけば、誰も商品を下から見ることはありません。→ カメラ

写真撮影をオンにしましょう

見る人が選んだものを写真に撮って、共有できるようにします。 → プロジェクトの設定

値を置く場所​

値の使われ方置き場所
このスクリプトだけで使うinit の中の普通の変数
シーンが切り替わったあとも必要ctx.store
ほかのスクリプト、イベント、パッチで必要ctx.setGlobal
ほかのものに反応してほしい出来事ctx.postMessage
ルームにいるほかの人と共有するctx.net.state
タブを閉じると、すべて消えます

次回も使いたい値があるなら、見る人がまだページにいる間に、自分のサーバーへ送ってください。


次へ: マルチプレイヤー — 1 つのルームで複数の人が使うスクリプト。