Aller au contenu principal

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 },
});
});
});
Pourquoi multiplier par dt

dt 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
});
Gardez les nombres dans votre script

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);
});
});
Ajoutez un collider si la physique doit le voir

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.

Réglez le départ avant que la physique prenne le relais

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 }));
});
Regardez d'abord les étapes

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 });
Utilisez une scène 3D et une caméra orbit

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

Activez la prise de photo

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 seulementune simple variable dans init
nécessaire après un changement de scènectx.store
nécessaire à d'autres scripts, événements ou patchesctx.setGlobal
un événement auquel d'autres doivent réagirctx.postMessage
partagée avec d'autres personnes dans une sallectx.net.state
Fermer l'onglet efface tout

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.