Écrire un plugin
Créez votre propre plugin Studio : créez le dossier, écrivez un panneau, travaillez avec la scène et publiez-le.
Un plugin est un dossier sur votre ordinateur. Vous le modifiez dans votre propre éditeur de code, Studio l'exécute sans étape de build, et vous le publiez quand il est prêt.
Pour le code qui s'exécute dans le projet publié, voir Code d'exécution.
Créer un plugin
- Ouvrez le panneau Plugins.
- Cliquez sur Create plugin.
- Choisissez un dossier. Studio y écrit un modèle de départ :
my-plugin/
meta.json le manifeste
main.html le balisage de votre panneau
main.js votre code — .ts, .tsx et .jsx fonctionnent aussi
plugin.d.ts les types générés pour toute l'API
tsconfig.json pour que votre IDE trouve ces types
.wasignore ce qu'il ne faut pas envoyer à la publication
Pour voir une modification, enregistrez vos fichiers et cliquez sur Reload. Il n'y a rien à empaqueter ni à installer.
Vous pouvez écrire directement des fichiers .ts, .tsx et .jsx. Le fichier plugin.d.ts
généré donne à votre éditeur de code l'autocomplétion sur toute l'API.
Le manifeste
meta.json décrit votre plugin :
{
"id": "acme.shape-spawner",
"name": "Shape Spawner",
"version": "1.0.0",
"entry": "main.tsx",
"permissions": ["scene:read", "scene:write"],
"panels": [{ "id": "main", "title": "Shapes", "entry": "main.tsx" }],
"icon": "data:image/webp;base64,…"
}
L'icon est une URL de données (data URL), pas un chemin de fichier. Ainsi, un dossier local et un
plugin publié affichent la même icône partout : dans la barre d'outils, dans l'en-tête du panneau
et sur la fiche de la bibliothèque. Le formulaire de publication peut la générer pour vous à partir
de n'importe quelle image.
Le manifeste peut ajouter d'autres éléments :
| Champ | Ce qu'il ajoute |
|---|---|
components | un composant pour les objets → Composants personnalisés |
runtime | du code pour le projet publié → Code d'exécution |
triggers, steps | des entrées dans le panneau des événements → Déclencheurs et étapes |
settings | une section dans Project settings → Réglages du projet |
locales | des traductions → Traduire votre plugin |
actions | des éléments dans le menu contextuel des calques et des assets → Éléments de menu et raccourcis |
commands | des commandes que les autres plugins et l'assistant peuvent appeler → Commandes |
skills | des guides que l'assistant lit dans les projets qui ont le plugin, chacun sous forme de fichier .md du dossier |
Votre panneau
Le panneau le plus simple est un fichier HTML avec un script :
<div id="root">Loading…</div>
<script type="module">
init(async () => {
// editor, world et meta sont prêts ici
document.getElementById('root').textContent = `${meta.name} — ${world.getEntities().length} entities`;
});
</script>
Vous pouvez aussi vous passer du HTML et faire pointer entry vers un fichier .tsx. Studio monte
son export par défaut, et vous pouvez construire le panneau avec les composants de l'éditeur
lui-même :
// main.tsx
import { useState } from 'react';
import { Button } from '@was/ui';
export default function Panel() {
const [clicks, setClicks] = useState(0);
return <Button onClick={() => setClicks(clicks + 1)}>{clicks}</Button>;
}
init() attend que tout soit prêtinit() s'exécute dès que l'éditeur et la scène sont prêts. Vous n'avez rien à vérifier
vous-même.
Ce que votre code peut utiliser
Ces variables globales sont disponibles :
| Globale | Ce que c'est |
|---|---|
editor | l'API de l'éditeur |
world | la scène en direct, en entités et composants ; null si votre manifeste ne liste pas scene:read, et toujours null dans une surcouche |
tags | les étiquettes propres à votre plugin sur les objets (voir plus bas) ; null quand world l'est |
meta | votre manifeste |
init | exécute votre code dès que l'éditeur et la scène sont prêts |
Vous pouvez importer @was/ecs, @was/signals, @was/engine, @was/editor-api, @was/ui,
@was/icons, react et react-dom/client. Tout autre import échoue avec une erreur : vous le
remarquez tout de suite.
Les panneaux construits avec @was/ui utilisent une feuille de style toute faite : seules ses
classes fonctionnent. Les valeurs personnalisées comme text-[13px] ne fonctionnent pas. Pour une
taille hors de l'échelle, utilisez un style en ligne.
Travailler avec la scène
Vous pouvez construire des entités à la main via world. Pour les scènes et les espaces, passez
plutôt par l'éditeur : vous obtenez les mêmes valeurs par défaut, la même numérotation et les mêmes
contrôles de sécurité que lorsque vous travaillez dans Studio.
await editor.scenes.list();
await editor.scenes.create({ name: 'Chapter 2', trigger: { type: 'image', imageId } });
await editor.scenes.setTrigger(sceneId, { type: 'surface', bindingType: 'wall' });
await editor.scenes.delete(sceneId);
await editor.spaces.create({ name: 'Lobby' });
Le trigger correspond aux données d'une ancre. Il est validé avec le même schéma que celui de
l'éditeur : les nouveaux types de déclencheurs fonctionnent donc dans votre plugin sans modification
de l'API des plugins. Pour un déclencheur image, si vous n'indiquez pas de taille physique, elle
est calculée à partir de l'image.
Retrouver vos propres objets plus tard
Un plugin qui génère des scènes doit pouvoir les retrouver. Utilisez tags pour cela :
tags.set(entity, { kind: 'scene', card: '2' });
tags.find({ kind: 'scene' });
tags.remove(entity, 'card');
Les tags sont conservés séparément pour chaque plugin : deux plugins n'écrasent donc jamais les tags l'un de l'autre. L'éditeur ne les affiche pas et ne les modifie pas.
La salle du projet
Certains états appartiennent à la session en cours, pas au projet : un minuteur qui tourne, un vote ouvert, une main levée. Ils ne doivent pas être annulés, publiés ni enregistrés avec le projet.
La salle du projet (project room) est un petit espace de stockage partagé de clés et de valeurs. Toutes les personnes présentes dans le projet à ce moment-là le voient.
const { now, entries } = await editor.room.get();
await editor.room.set('timer', { running: true, endsAt: now + 60_000 });
editor.on('room.changed', (state) => render(state.entries.timer));
await editor.room.delete('timer');
- Elle survit au rechargement de la page.
- Elle expire 12 heures après la dernière écriture.
- Elle contient jusqu'à 64 clés. Un nom de clé fait 128 caractères au maximum, et une valeur 8 Ko au maximum en JSON. Une écriture qui dépasse une limite est ignorée.
- Toutes les personnes du projet peuvent la lire. Seules celles qui peuvent modifier le projet peuvent y écrire.
Les horloges de deux ordinateurs peuvent différer de plusieurs minutes : now vous donne donc
l'heure du serveur. Enregistrez l'heure de fin à partir de now, et laissez l'éditeur de
chacun faire le décompte de son côté. N'enregistrez pas les « secondes restantes » : le minuteur
écrirait alors dans la salle toutes les secondes, pour tout le monde.
Le serveur enregistre qui a écrit chaque clé, et un plugin ne peut pas le falsifier. Cela permet des
votes honnêtes, mais c'est à votre plugin de le vérifier : le serveur laisse toute personne qui peut
modifier le projet écrire n'importe quelle clé. L'exemple Session voting ne compte la clé
vote/<userId> que si ce même utilisateur l'a écrite.
La surcouche de la zone d'affichage
Un panneau vous est réservé, et vous pouvez le fermer. Pour quelque chose que tout le monde doit
voir, déclarez un panneau avec "surface": "hud". Il s'affiche sous forme de petite surcouche
par-dessus la scène :
editor.hud.set({ visible: true, width: 240, height: 96 });
editor.panels.open('main'); // une surcouche peut ouvrir son propre panneau
La surcouche est masquée au départ. Ne l'affichez que lorsqu'elle a quelque chose à montrer : même vide, elle intercepte les clics destinés à la scène. La taille maximale est de 640×400.
Dans une surcouche, world vaut null. Depuis votre panneau, placez dans la salle du projet tout
ce qu'elle doit afficher.
Traduire votre plugin
Gardez un dictionnaire par langue dans un même dossier, et indiquez ce dossier dans le manifeste :
"name": "${meta.name}",
"locales": "locale",
"panels": [{ "id": "main", "title": "${panel.title}", "entry": "main.tsx" }]
my-plugin/
locale/
en.json { "meta": { "name": "Shape Spawner" }, "panel": { "title": "Shapes" } }
de.json { "meta": { "name": "Formen-Werkzeug" }, "panel": { "title": "Formen" } }
name,descriptionet les titres des panneaux et des composants peuvent être des modèles${…}, lus dans le dictionnaire.- Dans un panneau,
useEditorLocale()de@was/editor-api, oueditor.i18n(), vous donnet(key, vars)dans la langue de l'éditeur. Le texte se met à jour quand la personne change de langue. - Si une langue manque, le plugin se rabat sur l'anglais, puis sur n'importe quelle langue qu'il possède.
- Quand vous publiez, la bibliothèque affiche le nom et la description dans chaque langue.
Retenir des réglages
Un panneau n'a pas de localStorage. Pour retenir quelque chose pour la personne d'une visite à
l'autre, utilisez editor.storage :
await editor.storage.set('lastShape', 'sphere');
const shape = await editor.storage.get('lastShape');
editor.storage.subscribe((values) => render(values));
Il existe aussi getAll(), delete(key) et clear(). Les valeurs sont enregistrées en JSON, pour
cette personne et ce plugin uniquement : jusqu'à 256 Ko par plugin, et 1 Mo pour l'ensemble des
plugins d'une même personne. Pour des valeurs que tout le monde doit voir dans le projet, utilisez
la salle du projet.
Notifications et questions
editor.notify('Scene generated');
editor.notify({ message: 'Could not load the model', kind: 'error' });
const ok = await editor.confirm({ title: 'Delete 12 objects?', danger: true });
- Une notification peut aussi avoir un
detail, unkind(success,warningouerror) et uneduration. confirmrépondtrueoufalse. Un clic à l'extérieur compte comme un non.- Les deux affichent le nom de votre plugin. Trois notifications au maximum restent à l'écran, et une seule question à la fois peut attendre une réponse.
- Utilisez-les à la place de
alert()etconfirm(), qu'un panneau ne peut pas afficher.
La caméra
const view = await editor.viewport.camera.get();
await editor.viewport.camera.set({ position: [0, 2, 5], target: [0, 0, 0] });
await editor.viewport.camera.focus([entityId]);
setglisse en douceur vers une vue proche et saute directement vers une vue éloignée. Passezanimate: falsepour toujours sauter.focuscadre un ou plusieurs objets. Il répondfalsequand il n'y a rien à montrer.- En 2D,
setse contente de déplacer la vue et de zoomer. - Cela nécessite la permission
editor:viewport.
Annulation
Les modifications que votre plugin apporte à la scène peuvent être annulées, comme celles de la personne elle-même. Des modifications espacées de moins de 0,3 seconde forment une seule étape d'annulation.
editor.history.transaction(fn)regroupe tout ce quefnmodifie en une seule étape, même quandfnest asynchrone.editor.history.skip(fn)garde des modifications hors de l'historique. Utilisez-le pour les données que votre plugin calcule à partir de la scène. Sinon, une annulation supprime ces données, votre plugin les réécrit, et la personne ne revient jamais à sa propre modification.
Éléments de menu et raccourcis
actions ajoute des éléments au menu contextuel (clic droit) des lignes de Layers et de la
liste des assets :
"actions": [
{ "id": "check", "title": "Check the scene", "panel": "main",
"targets": ["entity"], "shortcut": "Mod+Alt+KeyD" }
]
editor.actions.on('check', ({ target, ids }) => {
// ce qui a reçu le clic droit, ou ce qui est sélectionné
});
targetsvautentitypour les lignes des calques etresourcepour les lignes des assets. Un plugin peut avoir jusqu'à 20 actions.- Un clic ouvre votre
panels'il est fermé, et y appelle le gestionnaire. - Un
shortcutcontientMod,Altou les deux, dans l'ordreMod+Alt+Shift, avec le code de la touche, commeKeyD. Il agit sur la sélection en cours. Les raccourcis de l'éditeur lui-même ont la priorité, et les touches que le navigateur se réserve, commeMod+KeyW, sont refusées.
Commandes
commands permet aux autres plugins et à l'assistant d'appeler votre
plugin :
"commands": [
{ "name": "diagnose", "description": "Lists the problems in the scene", "panel": "main" }
]
editor.commands.handle('diagnose', async (input, { caller }) => {
return { problems: await findProblems() };
});
- La commande s'appelle
plugin.<plugin id>.<name>. Les autres plugins la trouvent aveceditor.commands.list(). Un plugin peut avoir jusqu'à 20 commandes, nommées en camelCase. - Renvoyez du JSON, ou levez une erreur. Vérifiez vous-même l'entrée : personne ne le fait à votre place.
- Si le panneau est fermé, il s'ouvre d'abord, mais seulement pour un plugin que la personne a déjà autorisé.
permissionsliste ce que doit avoir un plugin qui appelle la commande. Sans ce champ, l'appelant a besoin de toutes les permissions de votre plugin.- Avec
destructive: true, la personne confirme d'abord chaque appel, quel que soit l'appelant.
Publier
Le formulaire de publication demande :
| Champ | Limite |
|---|---|
| Icon | générée à partir de l'image de votre choix, recadrée en carré de 256×256 |
| Name | — |
| Tags | 12 maximum |
| Description | 500 caractères maximum |
| Screenshots | 4 maximum |
| Access | Free, Premium ou Subscribers only — voir Notes et plugins payants |
Si le plugin a des fichiers de langue, le nom et la description en sont tirés, dans chaque langue.
Autres limites : 64 fichiers maximum par plugin, 512 Ko par fichier, 50 plugins par compte. Les images et les autres fichiers binaires doivent faire chacun moins d'environ 384 Ko.
Republier met à jour le même plugin. Pour modifier un plugin publié, téléchargez-le : Studio écrit ses fichiers dans un dossier de votre choix.
Les fichiers d'un plugin publié sont servis normalement : un chemin comme ./icon.png fonctionne.
Un dossier local ne charge que les fichiers .ts, .tsx, .js, .jsx et .json de son premier
niveau, plus les fichiers .html que votre manifeste déclare comme points d'entrée. Les images, les
styles et les fichiers des sous-dossiers n'apparaissent qu'une fois le plugin publié.
Permissions
Indiquez dans le manifeste ce dont votre plugin a besoin : scene:read, scene:write,
resources:write, spaces:write, collaboration:read, collaboration:write, editor:panels,
entre autres. Le serveur refuse les permissions qu'il ne connaît pas.
Studio ne restreint pas la plupart de ces permissions : chaque plugin les reçoit. Il y a deux
exceptions. Un panneau ne reçoit world que si le manifeste liste scene:read. Les données du
projet (editor.data) ne répondent qu'à un plugin dont le manifeste liste data:read ou
data:write.
Les relecteurs lisent vos permissions, et les utilisateurs jugent votre plugin sur cette base. Demandez le plus petit ensemble qui suffit.
Quand vous installez le plugin de quelqu'un d'autre, partez du principe qu'il peut accéder à tout le projet. N'installez que ce à quoi vous avez des raisons de faire confiance. Voir Confiance et vérification.
Suite : Composants personnalisés — ajoutez votre propre composant aux objets.