Saltar al contenido principal

Recetas

Scripts completos para tareas que aparecen en casi todos los proyectos. Copia uno, añádelo a un objeto y cambia los números.

¿Es tu primera vez con los scripts? Empieza por Primer script. Explica las partes que tienen en común todos estos scripts.

Cada script importa de @was/engine las clases de componente que usa. init y defineProps no necesitan importación.

Haz girar un objeto​

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

dt es lo que ha durado el último fotograma. Al multiplicar por él, el objeto gira a la misma velocidad por segundo en móviles rápidos y lentos. Sin él, la velocidad depende del dispositivo.

Sube y baja​

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 va y viene de forma suave entre −1 y 1, una y otra vez. Multiplica el resultado para cambiar cuánto se mueve el objeto. Multiplica el tiempo para cambiar lo rápido que se mueve.

Toca para recoger​

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

La puntuación se guarda en una variable global, así que otras cosas también pueden leerla, como un contador en pantalla o la comprobación de si has ganado.

Muestra la puntuación en pantalla​

Pon este script en el objeto que tiene 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); // muestra el valor inicial
});
Guarda los números en tu script

Tu script guarda los números, y la tarjeta solo los muestra. Si guardas la puntuación dentro de la tarjeta, deja de funcionar en cuanto otra cosa la necesita.

Cuenta atrás​

Cuenta hacia atrás en una tarjeta de interfaz. Cuando se acaba el tiempo, envía la señal time-up. Entonces se ejecutan todos los eventos que tienen el disparador Signal configurado con 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)}`);
});
});

Repite cada pocos segundos​

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

Los scripts no tienen setInterval. Cuenta los segundos en tick. Se detiene solo cuando se elimina el objeto.

Un modelo 3D es un recurso de tipo scene, así que un ajuste de modelo se escribe { type: 'resource', resource: 'scene' }.

Crea un objeto con un script​

ctx.spawn crea un objeto a partir de un recurso. Si creas un modelo, obtienes solo el modelo, sin script.

Para un enemigo que persigue, una bala que vuela o un objeto que reacciona al recogerlo, crea tú el objeto y añádele el script:

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

Cada objeto creado recibe su propia copia del script, con sus propias variables. Así cada enemigo tiene su propia salud, su estado y sus temporizadores, sin trabajo extra:

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

ctx.on('on-collide', () => {
health -= 1;
if (health <= 0) ctx.destroy(ctx.entity);
});
});
Añade un colisionador si la física tiene que verlo

Un objeto creado con create solo tiene los componentes que indicas. Sin colisionador, no choca. Sin cuerpo rígido, la física no lo mueve.

Busca el objeto más cercano​

Útil para una torre, un enemigo o cualquier cosa que elige un objetivo:

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

Obtén los candidatos con ctx.query(...). Pide un componente que solo tengan los enemigos. O añade etiquetas al crearlos y filtra por etiqueta.

Sigue al jugador​

Se mueve hacia un objetivo y se para a una distancia fija:

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

Al dividir entre gap, la dirección mide exactamente 1 unidad. Después, al multiplicar por step, el objeto se mueve justo esa distancia. Lo usarás a menudo.

Dispara a lo que miras​

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

Necesitas await porque la respuesta llega en el siguiente fotograma.

Lanza un objeto con física​

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 }),
// la velocidad con la que arranca el cuerpo; se aplica una vez
new InitialVelocityComponent({
velocity: { x: x * props.speed, y: y * props.speed, z: z * props.speed },
}),
],
});
ball.getComponent(TransformComponent)?.update({ position: pose.position });
});
});

La pose de la cámara está en coordenadas del mundo, y la posición de la pelota se mide desde su escena. En una escena 3D, las dos coinciden.

Fija el inicio antes de que la física tome el control

La física lee la posición y la velocidad inicial una sola vez, al crear el cuerpo. Después, cambiar la posición no hace nada. Usa ctx.physics.teleport para colocar el cuerpo y ctx.physics.applyImpulse para empujarlo.

Abre una puerta una sola vez​

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 hace que el cambio sea suave en lugar de instantáneo. Todo lo que cambias dentro se mueve a la vez.

Reacciona a una colisión​

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 avisa a otros scripts de lo que ha pasado. Ninguno de los dos scripts necesita una referencia al otro.

Espera al seguimiento​

Reproduce una animación cuando se encuentra la escena y la detiene cuando se pierde el seguimiento:

init((ctx) => {
const props = defineProps({
animation: { type: 'string', default: 'Intro', label: 'Animation name' },
});

// Los pasos reciben el id de la animación. Búscalo por el nombre que muestra el panel Animation.
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 }));
});
Mira antes los pasos

Un script puede ejecutar en una sola llamada cualquier cosa que el editor hace como paso: animaciones, transiciones, estados, navegación. Echa un vistazo a los pasos antes de construir algo a mano.

Configurador de producto​

Un configurador hace dos cosas: cambia lo que se muestra y recuerda lo que ha elegido el visitante.

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;

// Un slot de material puede apuntar a un material guardado en lugar de llevar los 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); // empieza con el primer acabado
});

Para cambiar un modelo entero en lugar de un acabado, añade una lista de modelos a defineProps y cambia referal en el componente del modelo dentro de choose:

import { ModelRefComponent } from '@was/engine';

// en defineProps
models: { type: 'array', item: { type: 'resource', resource: 'scene' }, label: 'Models' },

// en choose(index)
ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
Usa una escena 3D y una cámara orbital

Un configurador suele ser una escena 3D normal, sin AR. Se abre al instante, funciona en cualquier dispositivo y no necesita marcador. Pon la cámara en orbit y limita cuánto puede inclinarse (el ángulo polar), para que nadie vea el producto desde abajo. → Cámaras

Activa la captura de fotos

Deja que los visitantes hagan una foto de lo que han elegido para que puedan compartirla. → Ajustes del proyecto

Dónde guardar los valores​

El valor…Guárdalo en
solo lo usa este scriptuna variable normal en init
hace falta después de cambiar 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
Al cerrar la pestaña se borra todo

Si necesitas un valor la próxima vez, envíalo a tu propio servidor mientras el visitante sigue en la página.


Siguiente: Multijugador: scripts para varias personas en una misma sala.