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

То, что вы действительно будете собирать

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

Если вы ещё ни одного не писали, начните с Первого скрипта — там объясняется общая для всех них форма.

Крутить что-то бесконечно

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

Заставить что-то мягко покачиваться

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);
});
});

Счёт живёт в глобальном значении, чтобы всё остальное (счётчик на экране, условие победы) могло его прочитать.

Показать этот счёт на экране

Повесьте это на объект с карточкой интерфейса:

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 строит объект из ресурса, поэтому порождение модели даёт вам модель — и больше ничего. Скрипт он с собой не несёт.

Для врага, который преследует, пули, которая летит, или предмета, который реагирует, соберите объект сами и прикрепите поведение:

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, — это ровно те компоненты, которые вы перечислили. Забудете коллайдер — он не будет сталкиваться; забудете 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(...): спросите компонент, который есть только у врагов, или пометьте их тегами при создании и фильтруйте по ним.

Следовать за игроком, но не вплотную

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 сообщает другим скриптам, не требуя, чтобы кто-то из них держал ссылку на другого.

Дождаться, пока сцену отследят

init((ctx) => {
ctx.on('on-detect', () => {
ctx.step('play_animation', { presetId: 'intro' });
});

ctx.on('on-lost', () => {
ctx.step('stop_animation', { presetId: 'intro' });
});
});
ctx.step избавляет от переписывания движка

Всё, что редактор умеет делать шагом (анимации, транзишены, состояния, навигация), скрипт может запустить одним вызовом. Загляните в справочник шагов, прежде чем собирать что-то руками.

Конфигуратор товара

Самая частая коммерческая сборка на этой платформе, и состоит она в основном из двух движений: поменять то, что показано, и запомнить выбранное.

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 пишется в компонент модели:

ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
Дайте посетителю камеру, которую можно вращать, и никакого AR

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

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

Конфигуратор, который можно сфотографировать, — это конфигуратор, которым делятся. → Настройки проекта

Где держать состояние

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

Если что-то должно быть на месте в следующий раз, отправьте это на свой сервер, пока посетитель ещё на странице.


Дальше: Мультиплеер