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 },
});
});
});
dtdt 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
});
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);
});
});
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,
});
});
});
teleport, empújalo con applyImpulseFijar 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 motorTodo 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 });
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
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 script | una variable normal en init |
| hace falta tras un cambio de escena | ctx.store |
| lo necesitan otros scripts, eventos o patches | ctx.setGlobal |
| es un evento al que otros deben reaccionar | ctx.postMessage |
| se comparte con otras personas en una sala | ctx.net.state |
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