Saltar al contenido principal

Lo que vas a construir de verdad

Todos los scripts de aquí están completos. Copia uno, adjúntalo a un objeto, cambia los números.

Si no has escrito ninguno antes, empieza por Tu primer script: explica la forma que todos comparten.

Hacer girar algo para siempre

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; // grados → radianes
t.update({
rotation: { ...t.$data.rotation, y: t.$data.rotation.y + turn * dt },
});
});
});
Por qué multiplicar por dt

dt es lo que duró el último fotograma. Multiplicar por él significa que el objeto gira lo mismo por segundo en un móvil rápido y en uno lento. Si lo quitas, tu animación corre a la velocidad que el dispositivo consiga dar.

Hacer que algo cabecee suavemente

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 oscila suavemente entre −1 y 1 sin parar. Multiplícalo para fijar cuánto se mueve, y multiplica el tiempo para fijar la velocidad.

Tocar para recoger

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

La puntuación vive en un global para que cualquier otra cosa (un marcador en pantalla, la condición de victoria) pueda leerla.

Mostrar esa puntuación en pantalla

Pon esto en el objeto que lleva tu tarjeta de interfaz:

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); // mostrar el valor inicial
});
Este es el patrón que hay que recordar

Tu lógica es dueña de los números; la tarjeta solo los muestra. Intentar guardar la puntuación dentro de la tarjeta se rompe en cuanto cualquier otra cosa la necesita.

Una cuenta atrás

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

Hacer algo cada pocos segundos

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

Aquí no hay setInterval. Contar segundos en tick es el equivalente, y se detiene por su cuenta cuando el objeto desaparece.

Generar algo que tiene su propio comportamiento

Este pilla a mucha gente. ctx.spawn construye un objeto a partir de un recurso, así que generar un modelo te da un modelo, y nada más. No arrastra un script.

Para un enemigo que persigue, una bala que vuela, un objeto recogible que reacciona, construye el objeto tú y adjúntale el comportamiento:

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

Cada objeto generado recibe su propia instancia de ese script, con sus propias variables, así que la salud, el estado y los temporizadores por enemigo no necesitan nada especial:

// el script propio del enemigo
init((ctx) => {
let health = 3;

ctx.on('on-collide', () => {
health -= 1;
if (health <= 0) ctx.destroy(ctx.entity);
});
});
Dale también un collider si la física debe verlo

Un objeto construido con create es exactamente los componentes que enumeraste. Si olvidas el collider, no chocará; si olvidas el cuerpo rígido, la física no lo moverá.

Encontrar el más cercano

La otra mitad de una torreta, de un enemigo, o de cualquier cosa que apunte:

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

Consigue los candidatos con ctx.query(...): pide un componente que solo lleven los enemigos, o márcalos con tags al crearlos y filtra por eso.

Seguir al jugador, pero no demasiado de cerca

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; // ya está bastante cerca

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

Dividir por gap convierte la dirección en una longitud de exactamente 1, así que multiplicar por step mueve precisamente esa distancia. Este truco sale constantemente.

Disparar a lo que estás mirando

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

Hace falta await porque el rayo se traza contra la escena real y la respuesta vuelve en el siguiente fotograma.

Lanzar un objeto físico

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,
});
});
});
Colócalo con teleport, empújalo con applyImpulse

Fijar directamente la posición de un objeto físico no hace nada: la física es dueña de dónde está y te sobrescribe en el siguiente paso.

Una puerta que se abre una vez

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 es lo que hace que el cambio se deslice en vez de saltar. Todo lo que cambies dentro se interpola junto.

Reaccionar a una colisión

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 avisa a otros scripts sin que ninguno de los dos necesite una referencia al otro.

Esperar a que la escena esté rastreada

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

ctx.on('on-lost', () => {
ctx.step('stop_animation', { presetId: 'intro' });
});
});
ctx.step te ahorra reescribir el motor

Todo lo que el editor sabe hacer como paso (animaciones, transiciones, states, navegación) un script puede dispararlo con una llamada. Repasa la referencia de pasos antes de construir algo a mano.

Un configurador de producto

El montaje comercial más habitual en esta plataforma, y consiste básicamente en dos movimientos: cambiar lo que se muestra y recordar lo que se ha elegido.

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;

// Un slot de material puede apuntar a un material guardado en vez de llevar valores.
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); // empezar por el primer acabado
});

Cambiar un modelo entero en vez de un acabado tiene la misma forma; se escribe referal en el componente de modelo:

ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
Dale una cámara que el visitante pueda orbitar, y nada de AR

Un configurador suele ser una escena 3D normal: abre al instante, funciona en todos los dispositivos y no necesita marcador. Pon la cámara en orbit y limita el ángulo polar para que nadie acabe mirándolo desde abajo. → Cámaras

Activa la captura de fotos

Un configurador del que se pueden hacer capturas es un configurador que se comparte. → Ajustes del proyecto

Dónde poner tu estado

El valor…Ponlo en
lo usa solo este scriptuna variable normal en init
hace falta tras un cambio de escenactx.store
lo necesitan otros scripts, eventos o patchesctx.setGlobal
es un evento al que otros deben reaccionarctx.postMessage
se comparte con otras personas en una salactx.net.state
Nada de esto sobrevive a cerrar la pestaña

Si algo tiene que estar ahí la próxima vez, mándalo a tu propio servidor mientras el visitante siga en la página.


Siguiente: Multijugador