Saltar al contenido principal

Crear un plugin

Crea tu propio plugin de Studio: prepara la carpeta, escribe un panel, trabaja con la escena y publícalo.

Un plugin es una carpeta en tu ordenador. La editas en tu propio editor de código, Studio la ejecuta sin ningún paso de compilación y la publicas cuando está lista.

Para el código que se ejecuta en el proyecto publicado, consulta Código de ejecución.

Crea un plugin​

  1. Abre el panel Plugins.
  2. Pulsa Create plugin.
  3. Elige una carpeta. Studio escribe en ella una plantilla inicial:
my-plugin/
meta.json el manifiesto
main.html el marcado de tu panel
main.js tu código; también sirven .ts, .tsx y .jsx
plugin.d.ts tipos generados para toda la API
tsconfig.json para que tu IDE encuentre esos tipos
.wasignore lo que no se sube al publicar

Para ver un cambio, guarda tus archivos y pulsa Reload. No hay nada que empaquetar ni instalar.

TypeScript y JSX funcionan tal cual

Puedes escribir archivos .ts, .tsx y .jsx directamente. El plugin.d.ts generado le da a tu editor de código autocompletado para toda la API.

El manifiesto​

meta.json describe tu 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,…"
}

El icon es una URL de datos, no la ruta de un archivo. Así, una carpeta local y un plugin publicado muestran el mismo icono en todas partes: en la barra de herramientas, en la cabecera del panel y en la tarjeta de la biblioteca. El formulario de publicación puede crearlo por ti a partir de cualquier imagen.

El manifiesto puede añadir más cosas:

CampoQué añade
componentsun componente para los objetos → Componentes personalizados
runtimecódigo para el proyecto publicado → Código de ejecución
triggers, stepsentradas en el panel de eventos → Disparadores y pasos
settingsuna sección en Project settings → Ajustes del proyecto
localestraducciones → Traduce tu plugin
actionselementos en el menú de clic derecho de las capas y los recursos → Elementos de menú y atajos
commandscomandos que pueden llamar otros plugins y el asistente → Comandos
skillsguías que el asistente lee en los proyectos que tienen el plugin; cada una es un archivo .md de la carpeta

Tu panel​

El panel más sencillo es un archivo HTML con un script:

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

También puedes prescindir del HTML y apuntar entry a un archivo .tsx. Studio monta su export por defecto, y puedes construir el panel con los propios 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 todo esté listo

init() se ejecuta cuando el editor y la escena están listos. No tienes que comprobar ninguna de las dos cosas.

Qué puede usar tu código​

Tienes disponibles estas globales:

GlobalQué es
editorla API del editor
worldla escena en directo, con entidades y componentes; null salvo que tu manifiesto incluya scene:read, y siempre null en una capa
tagslas etiquetas propias de tu plugin en los objetos (consulta más abajo); null cuando world lo es
metatu manifiesto
initejecuta tu código cuando el editor y la escena están listos

Puedes importar @was/ecs, @was/signals, @was/engine, @was/editor-api, @was/ui, @was/icons, react y react-dom/client. Cualquier otro import falla con un error, así que te das cuenta enseguida.

Solo existen los estilos incluidos

Los paneles hechos con @was/ui usan una hoja de estilos ya preparada, así que solo funcionan sus clases. Los valores personalizados como text-[13px] no. Para un tamaño fuera de la escala, usa un style en línea.

Trabaja con la escena​

Puedes crear entidades a mano con world. Para escenas y espacios, es mejor pedírselo al editor: obtienes los mismos valores por defecto, la misma numeración y las mismas comprobaciones que al trabajar en 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' });

El trigger son los datos de un anchor. Se valida con el mismo esquema que usa el editor, así que los tipos nuevos de disparador funcionan en tu plugin sin cambios en la API de plugins. En un disparador de imagen, si no indicas un tamaño físico, se calcula a partir de la imagen.

Encuentra después tus propios objetos​

Un plugin que genera escenas necesita volver a encontrarlas. Usa tags para eso:

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

Las etiquetas se guardan por separado para cada plugin, así que dos plugins nunca se pisan las etiquetas. El editor no las muestra ni las cambia.

La sala del proyecto​

Hay estado que pertenece a la sesión actual, no al proyecto: un temporizador en marcha, una votación abierta, una mano levantada. No debe deshacerse, publicarse ni guardarse con el proyecto.

La sala del proyecto es un pequeño almacén compartido de claves y valores. Lo ve todo el que está en el proyecto en ese momento.

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 la página.
  • Caduca 12 horas después de la última escritura.
  • Admite hasta 64 claves. El nombre de una clave tiene hasta 128 caracteres, y un valor hasta 8 KB como JSON. Una escritura que supera un límite se ignora.
  • Todo el que está en el proyecto puede leerla. Solo quien puede editar el proyecto puede escribir en ella.
Usa el reloj del servidor para los temporizadores

Los relojes de dos ordenadores pueden diferir en minutos, así que now te da la hora del servidor. Guarda la hora de fin a partir de now y deja que el editor de cada persona haga la cuenta atrás por su cuenta. No guardes «segundos restantes»: el temporizador escribiría en la sala cada segundo, para todos.

El servidor registra quién escribió cada clave, y un plugin no puede falsificarlo. Eso permite votaciones limpias, pero tu plugin tiene que comprobarlo: el servidor deja que cualquiera que pueda editar escriba cualquier clave. El ejemplo Session voting solo cuenta la clave vote/<userId> si la escribió ese mismo usuario.

La capa sobre el visor​

Un panel es privado y puedes cerrarlo. Para algo que deban ver todos, declara un panel con "surface": "hud". Se dibuja como una pequeña capa encima de la escena:

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

La capa empieza oculta. Muéstrala solo cuando tenga algo que enseñar: incluso una capa vacía captura los clics destinados a la escena. El tamaño máximo es 640×400.

La capa no puede leer la escena

En una capa, world es null. Pon en la sala del proyecto, desde tu panel, todo lo que necesite mostrar.

Traduce tu plugin​

Guarda un diccionario por idioma en una carpeta e indica esa carpeta en el manifiesto:

"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 y los títulos de los paneles y los componentes pueden ser plantillas ${…}, que se leen del diccionario.
  • En un panel, useEditorLocale() de @was/editor-api, o editor.i18n(), te da t(key, vars) en el idioma del editor. Se actualiza cuando la persona cambia de idioma.
  • Si falta un idioma, se usa el inglés y, si tampoco está, cualquier idioma que tenga el plugin.
  • Al publicar, la biblioteca muestra el nombre y la descripción en cada idioma.

Guarda ajustes​

Un panel no tiene localStorage. Para recordar algo de la persona entre una visita y otra, usa editor.storage:

await editor.storage.set('lastShape', 'sphere');
const shape = await editor.storage.get('lastShape');
editor.storage.subscribe((values) => render(values));

También tienes getAll(), delete(key) y clear(). Los valores se guardan como JSON, solo para esa persona y ese plugin: hasta 256 KB por plugin y 1 MB entre todos los plugins de una persona. Para valores que deba ver todo el que está en el proyecto, usa la sala del proyecto.

Notificaciones y preguntas​

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 });
  • Una notificación también puede llevar un detail, un kind (success, warning o error) y una duration.
  • confirm responde true o false. Un clic fuera cuenta como un no.
  • Las dos muestran el nombre de tu plugin. Puede haber hasta tres notificaciones en pantalla a la vez, y solo una pregunta esperando respuesta.
  • Úsalas en lugar de alert() y confirm(): un panel no puede mostrarlos.

La cámara​

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 se desliza hasta una vista cercana y salta directamente a una lejana. Pasa animate: false para que salte siempre.
  • focus encuadra uno o varios objetos. Responde false cuando no hay nada que mostrar.
  • En 2D, set solo desplaza la vista y hace zoom.
  • Necesita el permiso editor:viewport.

Deshacer​

Los cambios que tu plugin hace en la escena se pueden deshacer, igual que los de la propia persona. Los cambios separados por menos de 0,3 segundos cuentan como un solo paso.

  • editor.history.transaction(fn) convierte todo lo que cambia fn en un solo paso, aunque fn sea asíncrona.
  • editor.history.skip(fn) deja los cambios fuera del historial. Úsalo para los datos que tu plugin calcula a partir de la escena. Si no, al deshacer se borran esos datos, tu plugin los vuelve a escribir y la persona nunca llega a recuperar su propia edición.

actions añade elementos al menú de clic derecho de las filas de Layers y de la lista de recursos:

"actions": [
{ "id": "check", "title": "Check the scene", "panel": "main",
"targets": ["entity"], "shortcut": "Mod+Alt+KeyD" }
]
editor.actions.on('check', ({ target, ids }) => {
// lo que se pulsó con el botón derecho o lo que está seleccionado
});
  • targets es entity para las filas de capas y resource para las filas de recursos. Un plugin puede tener hasta 20 acciones.
  • Un clic abre tu panel si está cerrado y llama allí al manejador.
  • Un shortcut lleva Mod, Alt o los dos, en el orden Mod+Alt+Shift, más el código de la tecla, como KeyD. Actúa sobre la selección actual. Los atajos del propio editor tienen prioridad, y las teclas que se reserva el navegador, como Mod+KeyW, se rechazan.

Comandos​

commands permite que otros plugins y el asistente llamen a tu plugin:

"commands": [
{ "name": "diagnose", "description": "Lists the problems in the scene", "panel": "main" }
]
editor.commands.handle('diagnose', async (input, { caller }) => {
return { problems: await findProblems() };
});
  • El comando se llama plugin.<plugin id>.<name>. Otros plugins lo encuentran con editor.commands.list(). Un plugin puede tener hasta 20 comandos, con nombres en camelCase.
  • Devuelve JSON o lanza un error. Comprueba tú mismo la entrada: nadie lo hace por ti.
  • Si el panel está cerrado, primero se abre, pero solo en un plugin que la persona ya ha permitido.
  • permissions indica lo que tiene que tener el plugin que llama. Si no lo indicas, quien llama necesita todos los permisos de tu plugin.
  • Con destructive: true, la persona confirma antes cada llamada, la haga quien la haga.

Publica​

El formulario de publicación te pide:

CampoLímite
Iconse crea a partir de la imagen que elijas, recortada a un cuadrado de 256×256
Name—
Tagshasta 12
Descriptionhasta 500 caracteres
Screenshotshasta 4
AccessFree, Premium o Subscribers only: consulta Valoraciones y plugins de pago

Si el plugin tiene archivos de idioma, el nombre y la descripción salen de ellos, en cada idioma.

Otros límites: hasta 64 archivos por plugin, 512 KB por archivo, 50 plugins por cuenta. Las imágenes y los demás archivos binarios tienen que pesar menos de unos 384 KB cada uno.

Si vuelves a publicar, se actualiza el mismo plugin. Para editar un plugin publicado, descárgalo: Studio escribe sus archivos en la carpeta que elijas.

Las imágenes solo funcionan después de publicar

Los archivos de un plugin publicado se sirven de forma normal, así que una ruta como ./icon.png funciona. Una carpeta local solo carga los archivos .ts, .tsx, .js, .jsx y .json de su nivel superior, además de los archivos .html que tu manifiesto indica como entradas. Las imágenes, los estilos y los archivos de subcarpetas solo aparecen después de publicar.

Permisos​

Indica en el manifiesto lo que necesita tu plugin: scene:read, scene:write, resources:write, spaces:write, collaboration:read, collaboration:write, editor:panels y otros. El servidor rechaza los permisos que no conoce.

Studio no restringe la mayoría de estos permisos: todos los plugins los reciben. Hay dos excepciones. Un panel solo recibe world si el manifiesto incluye scene:read. Los datos del proyecto (editor.data) solo responden a un plugin cuyo manifiesto incluya data:read o data:write.

Pide solo lo que necesitas

Los revisores leen tus permisos, y la gente juzga tu plugin por ellos. Pide el conjunto más pequeño que te sirva.

Cuando instales el plugin de otra persona, da por hecho que puede acceder a todo el proyecto. Instala solo lo que tengas motivos para considerar fiable. Consulta Confianza y verificación.


Siguiente: Componentes personalizados: añade tu propio componente a los objetos.