Recettes
Des scripts complets pour les tâches qui reviennent dans presque tous les projets. Copiez-en un, attachez-le à un objet et changez les nombres.
Vous débutez avec les scripts ? Commencez par Premier script. Cette page explique les parties communes à tous ces scripts.
Chaque script importe depuis @was/engine les classes de composants qu'il utilise. init et
defineProps ne demandent aucun import.
Faire tourner un objet
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; // degrés → radians
t.update({
rotation: { ...t.$data.rotation, y: t.$data.rotation.y + turn * dt },
});
});
});
dtdt est la durée de la dernière image. En multipliant par dt, l'objet tourne à la même vitesse
par seconde sur un téléphone rapide comme sur un téléphone lent. Sans cela, la vitesse dépend de
l'appareil.
Monter et descendre doucement
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 et vient en douceur entre −1 et 1, encore et encore. Multipliez le résultat pour
changer l'amplitude du mouvement. Multipliez le temps pour changer sa vitesse.
Toucher pour ramasser
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);
});
});
Le score est gardé dans une variable globale : d'autres éléments peuvent donc le lire aussi, comme un compteur à l'écran ou une vérification de victoire.
Afficher le score à l'écran
Placez ce script sur l'objet qui porte votre carte d'interface :
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); // afficher la valeur de départ
});
Votre script garde les nombres, et la carte se contente de les afficher. Si vous gardez le score dans la carte, il ne fonctionne plus dès que quelque chose d'autre en a besoin.
Compte à rebours
Affiche un compte à rebours sur une carte d'interface. Quand le temps est écoulé, le script envoie
le signal time-up. Tous les événements dont le déclencheur Signal est réglé sur time-up
s'exécutent alors.
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)}`);
});
});
Répéter à intervalles réguliers
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 });
});
});
Les scripts n'ont pas de setInterval. Comptez plutôt les secondes dans tick. Le décompte
s'arrête tout seul quand l'objet est retiré.
Un modèle 3D est une ressource de type scene : un réglage de modèle s'écrit donc
{ type: 'resource', resource: 'scene' }.
Faire apparaître un objet avec un script
ctx.spawn construit un objet à partir d'une ressource. Si vous faites apparaître un modèle,
vous obtenez seulement le modèle, sans script.
Pour un ennemi qui poursuit, une balle qui vole ou un objet à ramasser qui réagit, construisez l'objet vous-même et ajoutez le 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));
});
Chaque objet ainsi créé reçoit sa propre copie du script, avec ses propres variables. Chaque ennemi a donc ses propres points de vie, son propre état et ses propres minuteurs, sans travail supplémentaire :
// le script propre à l'ennemi
init((ctx) => {
let health = 3;
ctx.on('on-collide', () => {
health -= 1;
if (health <= 0) ctx.destroy(ctx.entity);
});
});
Un objet créé avec create n'a que les composants que vous listez. Sans collider, il n'entre pas
en collision. Sans corps rigide, la physique ne le déplace pas.
Trouver l'objet le plus proche
Utile pour une tour, un ennemi, ou tout ce qui choisit une cible :
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;
};
Obtenez les candidats avec ctx.query(...). Demandez un composant que seuls les ennemis
possèdent. Ou bien ajoutez des tags quand
vous les créez, et filtrez par tag.
Suivre le joueur
Se dirige vers une cible et s'arrête à une distance donnée :
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; // assez près
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 },
});
});
});
Diviser par gap donne une direction d'exactement 1 unité de long. Multiplier ensuite par step
déplace l'objet d'exactement cette distance. Vous vous en servirez souvent.
Tirer sur ce que vous regardez
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);
});
});
Il faut await, car la réponse arrive à l'image suivante.
Lancer un objet physique
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 vitesse de départ du corps, appliquée une seule fois
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 caméra est exprimée dans l'espace monde, et la position de la balle est mesurée à partir de sa scène. Dans une scène 3D, les deux sont identiques.
La physique lit la position et la vitesse initiale une seule fois, quand elle construit le corps.
Ensuite, changer la position ne fait plus rien. Utilisez ctx.physics.teleport pour placer le
corps, et ctx.physics.applyImpulse pour le pousser.
Ouvrir une porte une seule fois
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 rend le changement progressif au lieu d'instantané. Tout ce que vous modifiez à
l'intérieur bouge ensemble.
Réagir à une collision
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 informe les autres scripts de ce qui s'est passé. Aucun des deux scripts n'a besoin
d'une référence vers l'autre.
Attendre le suivi
Lance une animation quand la scène est trouvée, et l'arrête quand le suivi est perdu :
init((ctx) => {
const props = defineProps({
animation: { type: 'string', default: 'Intro', label: 'Animation name' },
});
// Les étapes prennent l'identifiant de l'animation. Trouvez-le grâce au nom affiché dans le panneau 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 peut lancer en un seul appel tout ce que l'éditeur propose sous forme d'étape : animations, transitions, états, navigation. Parcourez les étapes avant de construire quelque chose à la main.
Configurateur de produit
Un configurateur fait deux choses : il change ce qui est affiché, et il retient ce que le visiteur a choisi.
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 matériau peut pointer vers un matériau enregistré au lieu de porter des valeurs.
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); // commencer par la première finition
});
Pour remplacer tout le modèle plutôt qu'une finition, ajoutez une liste de modèles dans
defineProps, et changez referal sur le composant de modèle à l'intérieur de choose :
import { ModelRefComponent } from '@was/engine';
// dans defineProps
models: { type: 'array', item: { type: 'resource', resource: 'scene' }, label: 'Models' },
// dans choose(index)
ctx.entity.getComponent(ModelRefComponent)?.update({ referal: props.models[index]?.id ?? null });
Un configurateur est en général une simple scène 3D, sans AR. Elle s'ouvre instantanément,
fonctionne sur tous les appareils et ne demande aucun marqueur. Réglez la caméra sur orbit et
limitez son inclinaison (l'angle polaire), pour que personne ne voie le produit par en dessous.
→ Caméras
Laissez les visiteurs photographier ce qu'ils ont choisi, pour qu'ils puissent le partager. → Réglages du projet
Où garder les valeurs
| La valeur est… | Mettez-la dans |
|---|---|
| utilisée par ce script seulement | une simple variable dans init |
| nécessaire après un changement de scène | ctx.store |
| nécessaire à d'autres scripts, événements ou patches | ctx.setGlobal |
| un événement auquel d'autres doivent réagir | ctx.postMessage |
| partagée avec d'autres personnes dans une salle | ctx.net.state |
Si vous avez besoin d'une valeur la prochaine fois, envoyez-la à votre propre serveur pendant que le visiteur est encore sur la page.
Suivant : Multijoueur — des scripts pour plusieurs personnes dans une même salle.