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 },
});
});
});
dtdt 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
});
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);
});
});
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.
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 }));
});
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 });
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
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 script | una variable normal en init |
| hace falta después de cambiar 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 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.