Écrire un plugin
Un plugin est un dossier sur votre disque. Vous l'éditez dans votre IDE, il s'exécute dans l'éditeur sans étape de build, et vous le publiez quand il est prêt.
Pour commencer
Dans le panneau Plugins, appuyez sur Create. Studio demande un dossier et y écrit un gabarit :
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 résolve ces types
.wasignore ce qu'il ne faut pas téléverser à la publication
Modifiez les fichiers, appuyez sur Reload from disk, et vos changements sont en place. Aucun bundler, aucune installation.
.ts, .tsx et .jsx sont compilés à la lectureVous pouvez écrire directement en TypeScript et JSX. Les types viennent du plugin.d.ts généré,
donc votre éditeur complète toute l'API.
Le manifeste
{
"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'icône est une data URL et non un chemin de fichier, si bien qu'un dossier local et un plugin publié se ressemblent partout où le plugin apparaît — barre d'outils, en-tête du panneau, carte de la bibliothèque. Le formulaire de publication la génère pour vous à partir de n'importe quelle image.
Votre panneau
Le panneau le plus simple, c'est un fichier HTML et un script :
<div id="root">Loading…</div>
<script type="module">
import { signal } from '@was/signals';
const clicks = signal(0);
init(async () => {
// editor, world et meta sont prêts ici
document.getElementById('root').textContent = `${meta.name} — ${world.getEntities().length} entities`;
});
</script>
Ou passez-vous entièrement de HTML et pointez entry vers un fichier .tsx : le runtime monte
son export par défaut et vous construisez le panneau avec la bibliothèque de composants de
l'éditeur.
// 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 le monde soit vraiment làIl s'exécute une fois la connexion à l'éditeur ouverte et la copie synchronisée de la scène prête : vous n'avez jamais à interroger l'un ni l'autre.
Quatre globales sont toujours disponibles : editor (l'API de l'éditeur), world (une
copie vivante et synchronisée de la scène en entités et composants), meta (votre manifeste)
et init.
Vous pouvez importer @was/ecs, @was/signals, @was/engine, @was/editor-api, @was/ui,
@was/icons, react et react-dom/client. Tout le reste échoue bruyamment plutôt que de se
résoudre silencieusement à rien.
Les panneaux construits avec @was/ui utilisent une feuille de styles préconstruite : seules les
classes qu'elle embarque existent. Des valeurs arbitraires comme text-[13px] n'en font pas
partie — utilisez un style en ligne pour les tailles hors échelle.
Travailler avec la scène
Vous pourriez assembler des entités à la main via world, mais pour les scènes et les spaces il y
a mieux : demandez à l'éditeur, et vous héritez de ses valeurs par défaut, de sa numérotation et
de ses garde-fous :
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 n'est que les données d'une ancre, validées par le schéma qu'utilise l'éditeur — de
nouveaux types de déclencheurs fonctionnent donc sans que le protocole des plugins change. Un
déclencheur image déduit sa taille physique de l'image elle-même si vous n'en donnez pas.
Retrouver votre propre travail
Un plugin qui génère des scènes doit pouvoir les retrouver ensuite. C'est à cela que servent les
tags — ils sont automatiquement rangés dans l'espace de noms de votre plugin, donc deux plugins
n'écrasent jamais les marques l'un de l'autre, et l'éditeur ne les affiche ni ne les touche :
tags.set(entity, { kind: 'scene', card: '2' });
tags.find({ kind: 'scene' });
tags.remove(entity, 'card');
État de session partagé
Certains états appartiennent à la réunion, pas au document : un minuteur en cours, un vote ouvert, une main levée. Les annuler, les publier ou les stocker dans le projet serait tout aussi faux.
La salle du projet est un petit stockage clé-valeur partagé que voient toutes les personnes actuellement dans le projet :
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 à un rechargement de page, expire au bout de douze heures et contient jusqu'à 64 clés de 8 Ko.
now est l'horloge du serveur, et c'est tout l'intérêtLes ordinateurs de deux personnes peuvent diverger de plusieurs minutes. Stockez des heures de fin absolues issues de l'horloge serveur et laissez chaque client décompter — ne stockez jamais « secondes restantes », sinon un minuteur en marche voudrait dire écrire dans la salle chaque seconde pour tout le monde.
Le serveur enregistre aussi qui a écrit chaque clé, ce qui rend un vote honnête possible : une
voix ne compte que si la clé vote/<userId> a bien été écrite par cet utilisateur.
La surcouche du viewport
Un panneau est privé et peut être fermé, ce qui ne convient pas à quelque chose que tout le monde
doit voir. Un panneau déclaré avec "surface": "hud" est dessiné en petite surcouche par-dessus
la scène :
editor.hud.set({ visible: true, width: 240, height: 96 });
editor.panels.open('main'); // une surcouche peut convoquer son propre panneau
Elle démarre masquée et s'affiche quand elle a quelque chose à montrer — un cadre transparent invisible au-dessus de la scène avalerait les clics. La taille est plafonnée à 640×400.
Elle est affichée pour tout le monde, tout le temps : elle est donc volontairement légère. Ce qu'elle doit afficher, le panneau le dépose dans la salle.
Publier
Le formulaire de publication prend une icône, un nom, jusqu'à 12 tags, une description de 500 caractères maximum, jusqu'à 4 captures d'écran et un indicateur « réservé aux abonnés ».
Limites : 512 Ko par fichier, 64 fichiers, 50 plugins par compte.
Publier de nouveau met à jour la même entrée. Vous pouvez aussi télécharger un plugin publié pour l'éditer, ce qui réécrit ses fichiers dans un dossier de votre choix.
Les fichiers d'un plugin publié sont servis correctement, donc ./icon.png se résout. Un dossier
de développement local n'inline que ses .ts, .js et .json — les images référencées par chemin
n'apparaîtront pas tant que vous n'aurez pas publié.
Permissions
Le manifeste liste ce dont votre plugin a besoin — scene:read, scene:write,
resources:write, spaces:write, collaboration:read, collaboration:write, editor:panels et
d'autres — et le serveur rejette celles qu'il ne connaît pas.
Les permissions sont ce que lisent les relecteurs et ce sur quoi les gens jugent votre plugin. Demandez l'ensemble le plus étroit qui fasse le travail.
Et quand vous installez le plugin de quelqu'un d'autre, supposez qu'il peut atteindre tout le projet — n'installez que ce que vous avez des raisons de croire fiable. → Confiance et vérification
Suite : Extensions de composant