Saltar al contenido principal

Escribir un plugin

Un plugin es una carpeta en tu disco. La editas en tu propio IDE, se ejecuta en el editor sin paso de compilación, y la publicas cuando esté lista.

Para empezar

En el panel Plugins, pulsa Create. Studio te pide una carpeta y escribe una plantilla dentro:

my-plugin/
meta.json el manifiesto
main.html el marcado de tu panel
main.js tu código — .ts, .tsx y .jsx también valen
plugin.d.ts tipos generados para toda la API
tsconfig.json para que tu IDE resuelva esos tipos
.wasignore qué no subir al publicar

Edita los archivos, pulsa Reload from disk y tus cambios están en vivo. Sin bundler y sin paso de instalación.

.ts, .tsx y .jsx se compilan al leerse

Puedes escribir TypeScript y JSX directamente. Los tipos vienen del plugin.d.ts generado, así que tu editor autocompleta toda la API.

El manifiesto

{
"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,…"
}

El icono es una data URL y no una ruta de archivo, así que una carpeta local y un plugin publicado se ven igual en todos los sitios donde aparece el plugin: la barra de herramientas, la cabecera del panel, la ficha de la biblioteca. El formulario de publicación te lo genera a partir de cualquier imagen.

Tu panel

El panel más simple es un archivo HTML y un script:

<div id="root">Loading…</div>
<script type="module">
import { signal } from '@was/signals';

const clicks = signal(0);

init(async () => {
// aquí editor, world y meta ya están listos
document.getElementById('root').textContent = `${meta.name}${world.getEntities().length} entities`;
});
</script>

O prescinde del HTML por completo y apunta entry a un archivo .tsx: el runtime monta su exportación por defecto y puedes construir el panel con la propia biblioteca de componentes del editor:

// 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() espera a que el mundo esté realmente ahí

Se ejecuta cuando la conexión con el editor está abierta y la copia sincronizada de la escena está lista, así que nunca tienes que sondear ninguna de las dos.

Hay cuatro globales siempre disponibles: editor (la API del editor), world (una copia viva y sincronizada de la escena en forma de entidades y componentes), meta (tu manifiesto) e init.

Puedes importar @was/ecs, @was/signals, @was/engine, @was/editor-api, @was/ui, @was/icons, react y react-dom/client. Cualquier otra cosa falla de forma ruidosa en vez de resolverse en silencio a nada.

El conjunto de estilos es fijo

Los paneles construidos con @was/ui usan una hoja de estilos precompilada, así que solo existen las clases que trae. Valores arbitrarios como text-[13px] no están entre ellas: usa un style en línea para tamaños fuera de la escala.

Trabajar con la escena

Podrías ensamblar entidades a mano a través de world, pero para escenas y spaces hay una vía mejor: pídeselo al editor y obtendrás sus propios valores por defecto, su numeración y sus salvaguardas:

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' });

El trigger no es más que los datos de un anclaje, validados con el mismo esquema que usa el editor, así que los tipos nuevos de disparador funcionan sin que cambie el protocolo de plugins. Un disparador de imagen deduce su tamaño físico de la propia imagen si no le das uno.

Volver a encontrar tu trabajo

Un plugin que genera escenas necesita encontrarlas después. Para eso están los tags: quedan automáticamente en el espacio de nombres de tu plugin, así que dos plugins nunca se pisan las marcas, y el editor ni las muestra ni las toca:

tags.set(entity, { kind: 'scene', card: '2' });
tags.find({ kind: 'scene' });
tags.remove(entity, 'card');

Estado de sesión compartido

Parte del estado pertenece a la reunión, no al documento: un temporizador en marcha, una votación abierta, una mano levantada. Deshacerlo, publicarlo o guardarlo en el proyecto sería un error en los tres casos.

La sala del proyecto es un pequeño almacén clave-valor compartido que ven todos los que están en el proyecto ahora mismo:

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');

Sobrevive a una recarga de página, caduca a las doce horas y admite hasta 64 claves de 8 KB.

now es el reloj del servidor, y ahí está la gracia

Los ordenadores de dos personas pueden diferir en minutos. Guarda horas de fin absolutas tomadas del reloj del servidor y deja que cada cliente cuente atrás por su cuenta; nunca guardes «segundos restantes», o un temporizador en marcha significaría escribir en la sala cada segundo para todos los que estén en el proyecto.

El servidor también registra quién escribió cada clave, que es lo que hace posible una votación honesta: un voto cuenta solo si la clave vote/<userId> la escribió de verdad ese usuario.

La superposición del viewport

Un panel es privado y se puede cerrar, lo que no sirve para algo que todo el mundo debería ver. Un panel declarado con "surface": "hud" se dibuja como una pequeña superposición encima de la escena:

editor.hud.set({ visible: true, width: 240, height: 96 });
editor.panels.open('main'); // una superposición puede invocar su propio panel

Empieza oculta y se muestra cuando tiene algo que mostrar: un marco transparente invisible sobre la escena se tragaría los clics. El tamaño está limitado a 640×400.

La superposición no recibe copia de la escena

Está levantada para todos y todo el tiempo, así que es deliberadamente barata: lo que necesite mostrar, lo pone el panel en la sala.

Publicar

El formulario de publicación acepta un icono, un nombre, hasta 12 etiquetas, una descripción de hasta 500 caracteres, hasta 4 capturas y una marca de «solo con suscripción».

Límites: 512 KB por archivo, 64 archivos, 50 plugins por cuenta.

Publicar de nuevo actualiza la misma entrada. También puedes descargar un plugin publicado para editarlo, lo que devuelve sus archivos a una carpeta que elijas.

Los recursos solo funcionan una vez publicados

Los archivos de un plugin publicado se sirven correctamente, así que ./icon.png se resuelve. Una carpeta de desarrollo local solo incrusta sus .ts, .js y .json: las imágenes referenciadas por ruta no aparecerán hasta que publiques.

Permisos

El manifiesto lista lo que tu plugin necesita — scene:read, scene:write, resources:write, spaces:write, collaboration:read, collaboration:write, editor:panels y otros — y el servidor rechaza los desconocidos.

Declara solo lo que necesitas

Los permisos son lo que leen los revisores y aquello por lo que la gente juzga tu plugin. Pide el conjunto más estrecho que haga el trabajo.

Y al instalar el plugin de otra persona, da por hecho que puede llegar a todo el proyecto: instala solo aquello en lo que tengas motivos para confiar. → Confianza y verificación


Siguiente: Extensiones de componente