Перейти к основному содержимому

Рецепты

Готовые скрипты для задач, которые встречаются почти в каждом проекте. Скопируйте скрипт, прикрепите к объекту и поменяйте числа.

Впервые работаете со скриптами? Начните с первого скрипта. Там объясняются части, которые есть во всех этих скриптах.

Каждый скрипт импортирует нужные ему классы компонентов из @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 — это сколько длился прошлый кадр. Если умножать на него, объект поворачивается с одной и той же скоростью в секунду и на быстрых, и на медленных телефонах. Без этого скорость зависит от устройства.

Покачивать вверх-вниз​

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-сцену и орбитальную камеру

Конфигуратор обычно делают обычной 3D-сценой без AR. Она открывается мгновенно, работает на любом устройстве и не требует маркера. Поставьте камере режим orbit и ограничьте наклон (полярный угол), чтобы никто не увидел товар снизу. → Камеры

Включите съёмку фото

Пусть посетители фотографируют то, что выбрали, и делятся снимками. → Настройки проекта

Где хранить значения​

Какое значениеКуда положить
нужно только этому скриптув обычную переменную в init
нужно и после смены сценыв ctx.store
нужно другим скриптам, событиям или патчамв ctx.setGlobal
событие, на которое должны реагировать другиев ctx.postMessage
общее для всех людей в комнатев ctx.net.state
Если закрыть вкладку, всё сотрётся

Если значение понадобится в следующий раз, отправьте его на свой сервер, пока посетитель ещё на странице.


Дальше: Мультиплеер — скрипты для нескольких людей в одной комнате.