Рецепты
Готовые скрипты для задач, которые встречаются почти в каждом проекте. Скопируйте скрипт, прикрепите к объекту и поменяйте числа.
Впервые работаете со скриптами? Начните с первого скрипта. Там объясняются части, которые есть во всех этих скриптах.
Каждый скрипт импортирует нужные ему классы компонентов из @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 },
});
});
});
dtdt — это сколько длился прошлый кадр. Если умножать на него, объект поворачивается с одной и
той же скоростью в секунду и на быстрых, и на медленных телефонах. Без этого скорость зависит от
устройства.
Покачивать вверх-вниз
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);
});
});
Счёт хранится в глобальной переменной, поэтому его могут читать и другие: например, счётчик на экране или проверка победы.
Показать счёт на экране
Повесьте этот скрипт на объект с карточкой интерфейса:
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); // показать начальное значение
});
Числа хранит скрипт, а карточка только показывает их. Если хранить счёт внутри карточки, всё сломается, как только он понадобится чему-то ещё.
Обратный отсчёт
Отсчитывает время на карточке интерфейса. Когда время вышло, отправляет сигнал time-up. После
этого срабатывают все события с триггером Сигнал, настроенным на 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 }),
// скорость, с которой тело стартует; применяется один раз
new InitialVelocityComponent({
velocity: { x: x * props.speed, y: y * props.speed, z: z * props.speed },
}),
],
});
ball.getComponent(TransformComponent)?.update({ position: pose.position });
});
});
Положение камеры задано в мировых координатах, а положение мяча отсчитывается от его сцены. В 3D-сцене это одно и то же.
Физика читает положение и начальную скорость один раз, когда создаёт тело. После этого задавать
положение бесполезно. Чтобы переставить тело, используйте 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 анимации. Найдите его по имени, которое видно в панели «Анимация».
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 }));
});
Скрипт одним вызовом может запустить всё, что редактор умеет делать шагом: анимации, переходы, состояния, навигацию. Прежде чем собирать что-то вручную, просмотрите шаги.
Конфигуратор товара
Конфигуратор делает две вещи: меняет то, что показано, и запоминает, что выбрал посетитель.
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 список моделей и меняйте
referal в компоненте модели внутри choose:
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-сценой без AR. Она открывается мгновенно, работает на
любом устройстве и не требует маркера. Поставьте камере режим orbit и ограничьте наклон (полярный
угол), чтобы никто не увидел товар снизу. → Камеры
Пусть посетители фотографируют то, что выбрали, и делятся снимками. → Настройки проекта
Где хранить значения
| Какое значение | Куда положить |
|---|---|
| нужно только этому скрипту | в обычную переменную в init |
| нужно и после смены сцены | в ctx.store |
| нужно другим скриптам, событиям или патчам | в ctx.setGlobal |
| событие, на которое должны реагировать другие | в ctx.postMessage |
| общее для всех людей в комнате | в ctx.net.state |
Если значение понадобится в следующий раз, отправьте его на свой сервер, пока посетитель ещё на странице.
Дальше: Мультиплеер — скрипты для нескольких людей в одной комнате.