Aller au contenu principal

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

  1. Ouvrez le panneau Plugins.
  2. Cliquez sur Create plugin.
  3. 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.

TypeScript et JSX fonctionnent tels quels

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 :

ChampCe qu'il ajoute
componentsun composant pour les objets → Composants personnalisés
runtimedu code pour le projet publié → Code d'exécution
triggers, stepsdes entrées dans le panneau des événements → Déclencheurs et étapes
settingsune section dans Project settings → Réglages du projet
localesdes traductions → Traduire votre plugin
actionsdes éléments dans le menu contextuel des calques et des assets → Éléments de menu et raccourcis
commandsdes commandes que les autres plugins et l'assistant peuvent appeler → Commandes
skillsdes 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êt

init() 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 :

GlobaleCe que c'est
editorl'API de l'éditeur
worldla scène en direct, en entités et composants ; null si votre manifeste ne liste pas scene:read, et toujours null dans une surcouche
tagsles étiquettes propres à votre plugin sur les objets (voir plus bas) ; null quand world l'est
metavotre manifeste
initexé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.

Seuls les styles intégrés existent

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.
Utilisez l'horloge du serveur pour les minuteurs

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.

La surcouche ne peut pas lire la scène

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, description et les titres des panneaux et des composants peuvent être des modèles ${…}, lus dans le dictionnaire.
  • Dans un panneau, useEditorLocale() de @was/editor-api, ou editor.i18n(), vous donne t(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, un kind (success, warning ou error) et une duration.
  • confirm répond true ou false. 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() et confirm(), 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]);
  • set glisse en douceur vers une vue proche et saute directement vers une vue éloignée. Passez animate: false pour toujours sauter.
  • focus cadre un ou plusieurs objets. Il répond false quand il n'y a rien à montrer.
  • En 2D, set se 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 que fn modifie en une seule étape, même quand fn est 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.

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é
});
  • targets vaut entity pour les lignes des calques et resource pour les lignes des assets. Un plugin peut avoir jusqu'à 20 actions.
  • Un clic ouvre votre panel s'il est fermé, et y appelle le gestionnaire.
  • Un shortcut contient Mod, Alt ou les deux, dans l'ordre Mod+Alt+Shift, avec le code de la touche, comme KeyD. 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, comme Mod+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 avec editor.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é.
  • permissions liste 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 :

ChampLimite
Icongénérée à partir de l'image de votre choix, recadrée en carré de 256×256
Name—
Tags12 maximum
Description500 caractères maximum
Screenshots4 maximum
AccessFree, 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 images ne fonctionnent qu'après publication

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.

Ne demandez que ce dont vous avez besoin

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.