То, что вы действительно будете собирать
Каждый скрипт здесь законченный. Скопируйте, прикрепите к объекту, поменяйте числа.
Если вы ещё ни одного не писали, начните с Первого скрипта — там объясняется общая для всех них форма.
Крутить что-то бесконечно
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 — это длительность прошлого кадра. Умножение на него означает, что объект поворачивается на
одну и ту же величину за секунду и на быстром телефоне, и на медленном. Уберите его — и
анимация пойдёт с той скоростью, какую устройство случайно потянет.
Заставить что-то мягко покачиваться
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 });
Конфигуратор — это обычно простая 3D-сцена: открывается сразу, работает на любом устройстве и
не требует маркера. Поставьте камере режим orbit и ограничьте полярный угол, чтобы никто не
разглядывал товар снизу. → Камеры
Конфигуратор, который можно сфотографировать, — это конфигуратор, которым делятся. → Настройки проекта
Где держать состояние
| Значение… | Кладите в |
|---|---|
| используется только этим скриптом | обычную переменную в init |
| нужно после смены сцены | ctx.store |
| нужно другим скриптам, событиям или патчам | ctx.setGlobal |
| это событие, на которое должны реагировать | ctx.postMessage |
| общее с другими людьми в комнате | ctx.net.state |
Если что-то должно быть на месте в следующий раз, отправьте это на свой сервер, пока посетитель ещё на странице.
Дальше: Мультиплеер