Aller au contenu principal

É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 lecture

Vous 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.

Le jeu de styles est figé

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êt

Les 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.

La surcouche n'a pas de copie de la scène

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 annexes ne marchent qu'une fois publiés

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.

Ne déclarez que ce dont vous avez besoin

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