← Volver a Docs

DOCS · MÓDULOS

Cómo crear un módulo para NEXUS

Guía para quien quiere hacer su propio módulo: qué es, cómo se escribe, qué puede hacer, qué no, y cómo se publica en el Mercado. Está pensada para leerse de corrido la primera vez y después usarse como referencia.

Todo lo que dice esta guía sale del código de la app (el SDK que ve tu módulo se genera desde ahí). Si algo no coincide con lo que pasa al probar, gana el código: avísanos y se corrige la guía.

1. Qué es un módulo #

Un módulo es una celda del grid de NEXUS: el chat, la lista de miembros y la voz son módulos, y el tuyo vive al lado de ellos. La persona lo agrega a su espacio, lo mueve, le cambia el tamaño y lo combina con otros.

Tu módulo se escribe en Dart con widgets al estilo Flutter, pero no corre como una app de Flutter normal: corre dentro de un sandbox (un intérprete de Dart, dart_eval). Eso tiene dos consecuencias que conviene tener claras desde el principio:

  • Es seguro para quien lo instala. Tu código no puede abrir archivos, ni conectarse a internet, ni lanzar programas por su cuenta. Todo lo que hace «afuera» pasa por el SDK (ModuleServer), que pide permiso cuando hace falta y pone topes.
  • Tiene su propio subconjunto de Dart y de widgets. La mayoría de lo que escribirías en Flutter funciona, pero no todo. La sección 11 dice exactamente qué evitar.

Si tu idea necesita algo que el sandbox no da (un motor de juego, mucho cálculo, audio), un módulo puede traer además un programa nativo en C/C++, que corre aislado en Windows (sección 9). Averno, el juego de disparos del Mercado, está hecho así.

Qué puede hacer un módulo, a grandes rasgos:

  • mostrar cualquier interfaz hecha con los widgets del SDK, y dibujar libre en un lienzo;
  • saber quién está en el servidor, quién habla, quién transmite;
  • guardar sus datos en la PC;
  • compartir datos en tiempo real con los demás miembros, y que el servidor los recuerde (una pizarra, un tablero, una partida);
  • mandar mensajes a un canal, pedirle a la persona que se una a la voz, abrir menús nativos de NEXUS;
  • con permiso, lanzar un programa que la persona eligió, ver si un proceso está abierto, abrir un servidor de juego en una carpeta que la persona eligió, o correr su propio programa nativo.

2. Tu primer módulo en cinco minutos #

  1. En NEXUS, abre el Creador de módulos (desde el Mercado, «Crear módulo»).
  2. Empieza con la plantilla. Ya trae un módulo que funciona:
dart
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/basic.dart';
import 'package:flutter/src/painting/edge_insets.dart';
import 'package:lannexus_sdk/module.dart';
import 'package:lannexus_sdk/widgets.dart';

class MyModule extends CustomGridModule {
  MyModule();

  @override
  Widget build(BuildContext context, double width, double height) {
    return Center(
      child: Padding(
        padding: EdgeInsets.all(12),
        child: Text(
          'Hola desde mi módulo!',
          style: TextStyle(fontSize: 14, color: Color(0xFFFFFFFF)),
        ),
      ),
    );
  }
}

// La entrada: NEXUS llama a esta función para crear tu módulo.
CustomGridModule createModule() => MyModule();
  1. La vista previa al lado del editor lo corre de verdad, con miembros y canales de mentira. Cambia el texto y mira cómo se actualiza.
  2. Guárdalo: aparece en tu inventario y lo puedes agregar a un espacio de cualquier servidor.

Las dos cosas que todo módulo tiene sí o sí:

  • una clase que **extiende CustomGridModule** y define build(context, width, height);
  • una función de nivel superior **CustomGridModule createModule()** que la devuelve.

width y height son el tamaño de tu celda en píxeles lógicos, ya descontado el margen. Úsalos para adaptar lo que muestras (sección 4).

3. Cómo está armado un proyecto #

Un módulo chico puede ser un solo archivo. Uno grande es una carpeta:

mi_modulo/
├─ manifest.json        lo que NEXUS necesita saber del módulo
├─ lib/
│  ├─ module.dart       la entrada (define createModule)
│  └─ src/              el resto de tu código, como quieras
│     ├─ ui.dart
│     └─ juego.dart
├─ native/              (opcional) fuente C/C++ y CMake de tu programa nativo
├─ README.md
├─ LICENSE
└─ tools/               (opcional) tus scripts: NO se empaquetan

manifest.json #

json
{
  "displayName": "Mi módulo",
  "defaultSize": [6, 4],
  "entryPoint": "lib/module.dart",
  "permissions": ["syncChannel"],
  "minAppVersion": "2.0.19"
}
CampoQué es
displayNameEl nombre que ve la gente.
defaultSizeTamaño al agregarlo, en celdas [ancho, alto]. Una celda mide 72 px.
entryPointEl archivo que define createModule(). Por defecto lib/module.dart.
busShared(opcional) Espacios del bus para hablar con otros módulos de esta PC: ["mapas"]. Ver la sección 7.4.
permissionsLo que el módulo va a pedir y que se declara de antemano: native.run, network.bridge, game.server, syncChannel. Ver la sección 10.
nativeSolo con programa nativo: {"executables": ["bin/mi_programa.exe"], "build": "cmake"}. Para partidas en red, "maxPeers": N (desde la 2.0.68): jugadores por partida, de 2 a 16 (sin él, 10).
minAppVersionLa versión más vieja de NEXUS que tiene lo que tu módulo usa. Súbela cuando uses una API nueva. Para algo opcional, mejor pregunta con server.supports(...) y degrada (ver abajo).
names(opcional) El nombre por idioma para el menú «Agregar módulo»: {"en": "Game Servers", "es": "Servidores de juego"}. Sin el idioma de la persona sale el inglés, y sin inglés, displayName. Códigos como en, es o pt-BR, hasta 16 idiomas, de 1 a 80 caracteres cada uno, sin saltos de línea ni caracteres invisibles. Pasan el mismo control de nombres reservados que displayName («NEXUS», «ZENISO»…).
descriptions(opcional) Qué hace, en una línea, por idioma (mismo formato, hasta 200 caracteres). description suelto cuenta como el inglés. Si no hay, el menú usa la descripción corta de tu página del Mercado.
category(opcional) La categoría del Mercado (juegos, utilidades, multimedia, chat_comunicacion, estetico, general) para los proyectos sin página publicada. Otra cosa rechaza el paquete.
icon(opcional) Un ícono propio: una imagen que viaje en el paquete dentro de assets/ (assets/icon.png; png, jpg o webp). Una ruta que no está en el paquete rechaza el envío.

La revisión del Mercado valida estos cuatro campos y los deja en el paquete publicado; si alguno no tiene la forma de arriba, el envío se rechaza con el motivo.

Que tu módulo degrade en vez de exigir una versión #

Desde la 2.0.68, server.supports('id') dice si la app que lo corre sabe hacer algo, y server.appFeatures las lista todas (server.appVersion es la versión). Los ids:

IdQué es
join.<plantilla>Plantillas de «Unirse al juego» (launch: de ModuleBridgeAddress). Hoy: join.steam.connect.
open.<regla>Lo que acepta server.openExternal: open.https, open.steam.install, open.steam.store.
ping.<protocolo>Protocolos de ModuleServerPing: ping.a2s, ping.tcp, ping.icmp.
draw.<letra>Comandos de DrawSurface (draw.G…).
native.maxPeersRespeta native.maxPeers del manifest.

Ojo: tu módulo se compila en la app de cada persona, así que un parámetro o método que esa app no tiene no compila. Lo nuevo del SDK entra como datos (un texto que eliges: launch: 'steam.connect', protocol: 'tcp', el comando G): si la app no lo conoce, lo ignora y tu módulo sigue andando. supports mismo necesita la 2.0.68.

dart
final String? unirse = server.supports('join.steam.connect') ? 'steam.connect' : null;
ModuleBridgeAddress(memberId: host, port: puerto, launch: unirse);  // sin plantilla: solo «Copiar»

Varios archivos #

Dentro de lib/ los archivos se importan con rutas relativas:

dart
import 'src/ui.dart';          // bien
import 'package:mi_modulo/src/ui.dart';   // mal: el sandbox no conoce tu paquete

Lo que viene del SDK se importa siempre igual: package:lannexus_sdk/module.dart, widgets.dart y server.dart, más los package:flutter/src/... de los widgets que uses (ver 11).

4. Dibujar la interfaz #

build devuelve un árbol de widgets, igual que en Flutter. Puedes usar StatelessWidget y StatefulWidget propios (con setState, initState y dispose).

Widgets disponibles #

Además de los básicos de Flutter que el sandbox trae (Center, Padding, SizedBox, Expanded, Align, Positioned…), el SDK agrega o recorta estos:

WidgetPara qué
Text(data, style, maxLines, ellipsis, center)Texto. ellipsis: true corta con «…».
TextStyle(color, fontSize, bold, italic, height…)Estilo del texto (bold/italic son booleanos).
Row, Column, Stack, FlexibleLayout.
Container(color, padding, margin, width, height, child)Caja simple.
ModuleBox(color, radius, circle, borderColor, borderWidth, padding, margin, width, height, child)Caja con bordes redondeados y borde: la base para tarjetas y botones.
ColoredBox, FittedBox, Baseline, Directionality, TooltipLo de siempre.
Icon(IconData(codepoint, fontFamily: 'MaterialIcons'), size, color)Íconos de Material por su código.
ModuleScrollView(child, horizontal)Un área con scroll (y la rueda del mouse).
ModuleTextField(value, onChanged, hintText, multiline, fontSize, autofocus, bare, onSubmitted)Campo de texto.
GestureDetector(...)Toques, arrastres, rueda y teclado (sección 5).
DrawSurface(onPaint)Lienzo para dibujar libre (sección 6).
ModuleAvatar(memberId, size, speakingRing, statusDot)La foto de un miembro, con el anillo de «hablando» y el puntito de estado.
ModuleStreamView(memberId, cover)La transmisión de pantalla de alguien (o el botón para empezar a verla).
ModuleNativeSurface(session, capture)Lo que dibuja tu programa nativo.
ModuleBridgeAddress(memberId, port, color, fontSize, cta, launch)La dirección de NexusBridge de un miembro (para servidores de juego), con «Copiar» y, con launch, «Jugar» con esa plantilla ('steam.connect'; desde la 2.0.68, antes join: true). Una plantilla que la app no tiene deja solo «Copiar».
ModuleServerPing(memberId, port, fontSize, protocol)Cuánto tarda en contestar el server de un miembro. protocol: 'a2s' (por defecto), 'tcp' o 'icmp' (desde la 2.0.68).

Colores y tema #

  • Los colores son Color(0xAARRGGBB).
  • Usa el color del tema para lo destacado: Color(server.accentColor). Así tu módulo combina con el fondo y el color que eligió cada persona.
  • Habla el idioma de cada persona: server.appLanguage es 'es' o 'en' (desde la 2.0.36; pon "minAppVersion": "2.0.36" si lo usas). Léelo en cada build y elige tus textos con una tabla propia: si la persona cambia el idioma, el próximo build ya sale en el nuevo.

Tamaños: tu módulo tiene que verse bien chico #

La gente achica las celdas. Una celda mide 72 px y un módulo puede quedar de una o dos celdas.

  • Lee width y height en build y elige qué mostrar. Averno, por ejemplo, muestra una versión compacta por debajo de 300×180.
  • Si algo puede no entrar (una barra de herramientas, una lista), ponlo en un ModuleScrollView.
  • Cuando necesitas más espacio, server.requestMaximize() pone tu módulo en toda la ventana, y server.requestRestore() lo devuelve. server.isMaximized te dice en qué modo estás.

Tu estado no se pierde al mover el módulo #

Mover tu módulo, cambiarle el tamaño, entrar y salir del modo edición o combinarlo con otro no lo reinicia: tus StatefulWidget conservan su estado. Lo que sí lo reinicia es irse a otro servidor y volver, o cerrar la app. Para que algo sobreviva a eso, guárdalo (7.3) o compártelo por el canal de sync, que el servidor recuerda (sección 8).

5. Tocar, arrastrar y escribir #

GestureDetector es la versión del SDK, con estos callbacks:

CallbackRecibeCuándo
onTap, onDoubleTap, onLongPressnadaToque simple, doble, largo.
onTapUp, onDoubleTapDownDragInfoIgual, pero con la posición (localX, localY).
onPanStart, onPanUpdate, onPanEndDragInfoArrastrar: posición y cuánto se movió desde la última vez (deltaX, deltaY).
onScrollDragInfoRueda del mouse: localX/localY es el cursor y deltaY < 0 es hacia arriba.
onKeyString → boolTeclado. Recibe el nombre de la tecla ('A', 'Delete', 'Escape', 'Arrow Left'…). Devuelve true si la usaste, para que no llegue a los atajos de NEXUS. El área toma el foco del teclado al tocarla.
dart
GestureDetector(
  onPanUpdate: (DragInfo d) {
    setState(() {
      x = x + d.deltaX;
      y = y + d.deltaY;
    });
  },
  onKey: (String key) {
    if (key == 'Delete') {
      borrarSeleccion();
      return true;
    }
    return false;
  },
  child: DrawSurface(onPaint: () => dibujar()),
)

Para escribir texto usa ModuleTextField: le pasas el value actual y te avisa con onChanged(texto) (y onSubmitted al apretar Enter).

6. Dibujo libre con DrawSurface #

Para pizarras, mapas, gráficos o juegos simples, DrawSurface te da un lienzo. Por cómo funciona el sandbox, no recibes un Canvas para llamar métodos. Al revés: onPaint es una función sin argumentos que devuelve un texto con comandos de dibujo, y NEXUS los dibuja.

  • ; separa comandos y | separa los campos de cada uno.
  • Los colores van como 0xAARRGGBB.
  • onPaint se llama en cada repintado: dibuja según tu estado.
ComandoCamposDibuja
Bfondo|colorPuntos|espaciado|radio[|desplX|desplY]Fondo de todo el lienzo y, si espaciado > 2, una grilla de puntos (el desplazamiento la mueve con el «pan» de tu vista).
Gespaciado|desplX|desplY|tamaño|caja|color|grosor|s x y x y …|f x y …Desde la 2.0.68. Tu propio motivo repetido en mosaico (un fondo con tu logo), movido con el pan. Cada campo desde el 8.º es un polígono en una caja de lado caja centrada en 0,0: s es contorno (grosor en unidades de la caja) y f relleno; se dibuja a tamaño px. Hasta 16 polígonos y 256 puntos. Una app vieja lo ignora.
VdesplX|desplY|escalaDesde la 2.0.50. Desde acá, todo se traslada y escala: mandas coordenadas de mundo y la vista (pan/zoom) la aplica NEXUS. Así puedes guardar el texto de tus objetos y reusarlo en cada frame en vez de recalcular cada coordenada al mover la vista, que en el sandbox es lo caro. Se cierra con U. Pon "minAppVersion": "2.0.50" si lo usas.
Lx1|y1|x2|y2|color|grosorUna línea.
Rx1|y1|x2|y2|color|grosor|relleno[|radio]Rectángulo (relleno 1 o 0; con radio, esquinas redondeadas).
Ox1|y1|x2|y2|color|grosor|rellenoÓvalo dentro de ese rectángulo.
Pcolor|grosor|relleno|x1|y1|x2|y2|…Polígono cerrado (3 puntos o más).
Tx|y|color|tamaño|textoTexto de una línea.
Wx|y|color|tamaño|anchoMax|textoTexto que se ajusta dentro de anchoMax.
Xx|y|color|tamaño|anchoMax|maxLineas|negrita|textoTexto ajustado con tope de líneas (…) y negrita (1/0).
Cx1|y1|x2|y2Desde acá, solo se dibuja dentro de ese rectángulo.
U—Cierra el último C o V.
dart
String dibujar() {
  String s = 'B|0xFF1E1E2E|0xFF3A3A55|24|1';
  for (final t in trazos) {
    s = s + ';L|${t.x1}|${t.y1}|${t.x2}|${t.y2}|0xFFFFFFFF|3';
  }
  return s + ';T|10|10|0xCCFFFFFF|12|Pizarra';
}
  • **El texto de T, W y X puede llevar |, pero no ;**: un ; lo cortaría en otro comando.
  • Si tu onPaint tira un error, el lienzo muestra el error en rojo en vez de quedar en blanco.

7. Hablar con NEXUS: ModuleServer #

Todo lo que tu módulo sabe y hace afuera de su celda pasa por ModuleServer:

dart
final ModuleServer server = ModuleServer(context);

Créalo en build (o donde tengas un BuildContext). Leerlo es barato. Cuando cambia algo que leíste (alguien entra, alguien habla, llega un op del canal), NEXUS vuelve a llamar tu build.

Algo importante sobre cómo responden las llamadas: las del SDK son síncronas o con callback, nunca Future. Si algo falla (te falta un permiso, mandas demasiado rápido), la excepción salta en el momento y la puedes atrapar con try/catch. Lo que viaja por la red es «dispara y olvida»: el resultado te llega después, en un próximo build.

7.1 Quién está #

server.myIdTu id en este servidor.
server.membersLos miembros: id, nickname, online, roleName, roleColor, roleOrder, status, statusText, inVoice, speaking.
server.canModeratetrue si puedes modificar o borrar lo de otros (dueño o administrador). Úsalo para decidir qué botones mostrar; el servidor igual lo vuelve a verificar.
server.statusTextOf(id) / server.statusLabel(status)Textos de estado listos para mostrar.
server.openMemberMenu(id) / server.openMyProfile()Abren el menú nativo de un miembro o tu perfil.
server.openExternal(uri)Propone abrir una página https://… o steam://install/<appid> / steam://store/<appid> (desde la 2.0.50: pon "minAppVersion": "2.0.50" y declara "openExternal" en permissions). La app valida el destino y la persona decide en un diálogo nativo (abrir, copiar para Win+R o cancelar); otro esquema tira un error.

Leer speaking o inVoice hace que tu módulo se redibuje con cada cambio de voz, que pasa muchas veces por segundo en una llamada. Léelo solo si lo usas.

7.2 Canales y mensajes #

  • server.channels: los canales de texto (id, name).
  • server.sendMessage(channelId, texto): escribe en un canal como la persona. Pide permiso la primera vez y tiene un límite de ritmo.

7.3 Guardar datos en esta PC: storage #

dart
server.storage.set('preferencias', jsonEncode(prefs));
final String? raw = server.storage.get('preferencias');
server.storage.remove('preferencias');
  • Es privado de tu módulo y de esta PC: no se comparte con nadie ni viaja a otra computadora.
  • Guarda texto (si necesitas estructura, jsonEncode/jsonDecode).
  • Tope: 256 KB por módulo. Pasarlo tira ModuleStorageQuotaExceededError.
  • Se escribe a disco con una pequeña demora (agrupa escrituras seguidas): llamarlo seguido no es caro.

Para lo que tiene que ver todo el servidor, usa el canal de sync (sección 8).

7.4 Hablar con otros módulos de esta PC: bus #

dart
// Lo tuyo: server.bus.prefix es 'custom.<id de tu módulo>.'
server.bus.publish('${server.bus.prefix}cambio', '{"x":1}');
// Un espacio compartido que declaraste en el manifest ("busShared": ["mapas"])
final cancelar = server.bus.subscribeWithSender('shared.mapas.*', (topic, payload, remitente) { ... });
server.bus.publish('shared.mapas.nuevo', '{"id":3}');
  • Cada módulo tiene su espacio privado, **custom.<id>.** (server.bus.prefix): nadie más publica ni escucha ahí.
  • Para hablar con otros módulos, declara espacios en el manifest.json ("busShared": ["mapas"]) y usa shared.<espacio>.…. Solo entran los módulos que declaran el mismo espacio.
  • subscribe(topic, (topic, payload) {...}) sigue igual. Con subscribeWithSender el handler recibe además el id del módulo que publicó (lo pone NEXUS, no el que publica); null si lo publicó la app.
  • Publicar tiene un límite de frecuencia (unas 10 por segundo, con ráfagas de 20); pasarlo tira ModuleBusRateLimitedError.
  • Los datos viajan como texto.
  • Es local: no sale de esta PC.

7.5 Lista compartida del servidor: moduleEntries #

Una lista simple por módulo (por ejemplo, «los servidores de juego que vigilamos»): server.moduleEntries, addModuleEntry(nombre, proceso) y removeModuleEntry(id). Cada entrada puede tener un estado online (o null si todavía no se sabe). Para datos más ricos, el canal de sync es más flexible.

7.6 Voz y transmisiones #

  • server.voiceChannels (con sus members: speaking, muted, deafened), server.myVoiceChannelId ('' si no estás en ninguno), server.myMuted y server.myDeafened.
  • server.requestJoinVoice(id) pide confirmación en un diálogo nativo: un módulo nunca enciende el micrófono por su cuenta.
  • server.leaveVoice() y server.muteMe() no piden nada (solo protegen a la persona).
  • server.streams: quién transmite (memberId, nickname, watching, mine). Muéstralo con ModuleStreamView: empezar a ver es un botón nativo, nunca el módulo.

7.7 Programas de la PC #

  • server.pickExecutablePath((String? path) { ... }) abre el diálogo nativo para elegir un .exe. No pide permiso: la ruta la eligió la persona.
  • server.launchExecutable(path) lo abre. Pide permiso, y **solo acepta rutas que salieron de pickExecutablePath**: una ruta inventada se rechaza.
  • server.checkProcess('juego.exe') dice si ese proceso está abierto. Pide permiso para ese nombre exacto. Responde al instante con lo último que se sabe (false al principio): el chequeo corre en segundo plano y tu módulo se reconstruye cuando cambia.

7.8 Servidores de juego: server.gameServer #

Para paneles de servidores dedicados (Minecraft hoy; más juegos con el módulo «Servidores de juego»). La persona elige la carpeta del servidor con pickFolder (eso es lo que la autoriza) y desde ahí puedes start, stop, leer la consola (lines, history), mandar comandos (command) y leer o escribir los archivos permitidos del servidor (readFile, writeFile). Pide el permiso game.server. El proceso es de la app, no de tu módulo: si se cierra la celda, el servidor sigue.

8. Compartir en tiempo real y la memoria del servidor #

Esto es lo que hace posible la Pizarra, el Tablero o una partida: datos que ven todos los miembros a la vez, y que el servidor recuerda. Si dibujas una Z, te vas a otro servidor y vuelves, la Z sigue ahí. Y la ven también los que entren después.

El modelo: objetos con id #

Cada topic de tu módulo (un nombre que tú eliges, como strokes o cards) es un conjunto de objetos vivos, cada uno con su id:

dart
// Agregar o reemplazar un objeto (el id lo eliges tú).
server.publishTopicOp('strokes', 'trazo_${server.myId}_$n', jsonEncode({'pts': puntos, 'color': c}));

// Sacar objetos.
server.removeTopicOps('strokes', ['trazo_1', 'trazo_2']);

// Leer todo lo que hay ahora.
for (final ModuleTopicOp op in server.topicOps('strokes')) {
  final Map datos = jsonDecode(op.dataJson);
  // op.id, op.authorId (quién lo creó), op.editorId (quién lo tocó último)
}
  • topicOps la primera vez devuelve [] y pide los datos al servidor. Cuando llegan, tu build se vuelve a llamar con todo.
  • publishTopicOp es «dispara y olvida»: lo tuyo vuelve como eco del servidor y ahí aparece en topicOps. Si quieres que se vea al instante, guárdalo aparte como «pendiente» hasta que llegue el eco (la Pizarra lo hace así).
  • Usar el mismo id reemplaza el objeto: así se mueve una tarjeta o se edita una nota.

Las reglas (las aplica el servidor, no tu código) #

  • Cada objeto es de quien lo creó. Reemplazarlo o sacarlo solo puede su autor… o alguien con server.canModerate (dueño o administrador).
  • Topics compartidos: si el nombre empieza con shared. (por ejemplo shared.cards), cualquiera puede reemplazar un objeto ajeno (mover la tarjeta de otro), pero sacarlo sigue siendo del autor o de un moderador.
  • Topes:
    • cada objeto puede ocupar hasta 16 KB;
    • hasta 5000 objetos vivos por topic;
    • el nombre del topic, hasta 64 caracteres, sin : ni saltos de línea;
    • de ritmo: 30 de golpe y después uno cada medio segundo por módulo. Si dibujas mucho, junta los puntos en un solo objeto por trazo.
  • No hay historial: sacar un objeto lo borra de verdad. Si quieres «deshacer», guárdalo tú.

Dónde se guarda, según el servidor #

ServidorDónde vive la memoria
Servidor dedicado (NEXUS Dedicated Server)En el servidor, en disco.
Servidor oficial de NEXUSEn la base de datos de NEXUS.
Red LANEn cada PC de la red: cada una guarda lo de esa red, y la que abre el módulo completa lo suyo con lo que tengan las demás.

Tu código es el mismo en los tres casos.

Para lo que tiene que viajar muchas veces por segundo (posiciones de una partida), el canal de sync no es lo ideal: es para estado que se recuerda. Una partida en red de verdad va mejor con un programa nativo (sección 9), cuyos paquetes la app transmite sin guardarlos.

9. Programas nativos (C/C++) #

Cuando necesitas rendimiento de verdad (un motor 3D, un emulador, audio), tu módulo puede traer un programa en C/C++.

Cómo funciona:

  • Subes el código fuente (native/, con CMake). ZENISO lo compila en un entorno sin red, y el ejecutable viaja firmado y verificado por su hash. Nunca se suben binarios.
  • Corre en Windows, aislado en un AppContainer propio:
    • no lee tus documentos, no tiene internet, no crea procesos, no usa el portapapeles;
    • sí escribe su propia carpeta de datos y sí tiene audio;
    • si NEXUS se cierra, el programa también.
  • Dibuja en una superficie compartida que tu módulo muestra con ModuleNativeSurface(session: 's1').
  • Tu módulo lo maneja con:
    • server.startNative(session, 'bin/mi.exe', configJson);
    • nativeState(session): none, starting, running, exited:N o error:…;
    • nativeSend(session, json) para mandarle mensajes, y nativeMessages(session) para leer los suyos;
    • stopNative(session).
  • Red sin red: el programa no tiene red propia. Con startNativeNetworked(...) y el permiso network.bridge, la app transmite sus paquetes a los otros jugadores, por el servidor de la celda o por NexusBridge (server.bridgeReady dice si se puede). El programa nunca ve una IP. Los slots van de 0 a native.maxPeers - 1 del manifest (2 a 16; sin declararlo, 10).
  • startNativeWithFolder(...) le deja leer (solo leer) una carpeta que la persona autorizó, por ejemplo el mundo de un servidor de Minecraft para dibujar el mapa.

Lo que más tiempo nos ahorró armando Averno:

  • Enlaza el runtime de C/C++ estático (/MT en MSVC, -static en MinGW). Aislado, tu programa solo carga DLL firmadas por Microsoft: una DLL propia junto al .exe no carga.
  • Para juegos en red, haz la simulación determinista: con las mismas entradas, el mismo resultado en cada PC. Compara un hash del estado cada tanto y corta la partida con «desincronización» en vez de seguir con mundos distintos.

En el teléfono los programas nativos no corren. Un módulo con programa nativo se instala y se usa desde la PC.

10. Permisos: qué se le pregunta al usuario #

Hay dos clases de permisos.

**Los que se declaran en el manifest.json** (y se piden al instalar o la primera vez):

PermisoQué permiteQué ve la persona
syncChannelPublicar en el canal de sync.«Enviar y recibir datos en tiempo real con otros miembros»
native.runCorrer su programa nativo.«Ejecutar su propio programa en tu PC, aislado: no ve tus archivos ni tus otros programas»
network.bridgeQue ese programa juegue en red.«Conectar su programa con otros miembros por la red NexusBridge del servidor (nunca con internet)»
game.serverAbrir servidores de juego (Minecraft, Source, 7 Days to Die, Arma 3 o un .exe con argumentos que confirma la persona) de una carpeta elegida, con los permisos del usuario y escuchando en la red.«Abrir servidores de juego (Minecraft, Source, 7 Days to Die, Arma 3 o cualquier .exe) de una carpeta que elijas, sin aislar, y manejar su consola y configuración»

Los que se piden cuando el módulo los usa: mandar mensajes a un canal, abrir un programa elegido y ver si un proceso está abierto (este último, por nombre de proceso).

Lo que nunca pide permiso, porque no expone nada: leer miembros, canales y voz, abrir menús nativos, maximizar, elegir un ejecutable o una carpeta (los elige la persona), salir de la voz o mutearse.

La revisión del Mercado compara lo que declaras con lo que tu código usa de verdad: si usas algo que no declaraste, el módulo no se publica.

11. Las reglas del sandbox (lo que más tiempo ahorra) #

El sandbox es dart_eval (una versión con arreglos propios). Casi todo Dart funciona, pero estos casos están comprobados y conviene evitarlos. La mayoría los marca el linter del creador con el número de línea, al guardar y al publicar.

Estructura e imports

  • Importa explícitamente lo que usas de Flutter. Por ejemplo, Stack necesita package:flutter/src/rendering/stack.dart además del widget. Si no, el sandbox dice «Class not found».
  • Entre tus archivos, rutas relativas, no package: propio (sección 3).
  • Un error raro como «Class/enum X not found» o «Cannot resolve top level variable» en código que se ve bien suele venir de cómo el sandbox descarta lo que cree que no se usa. Si te pasa, avísanos con el código: es un bug del sandbox, no tuyo.

**State y tiempo**

  • **Tu State no tiene mounted ni context**: solo widget, setState, initState, dispose y build. Para callbacks que llegan tarde (un diálogo, un timer), lleva tu propio bool disposed y míralo antes de setState.
  • Timer y Timer.periodic funcionan. Cancélalos en dispose.
  • Para medir tiempos en lógica de juego, cuenta ticks de tu timer en vez de usar DateTime.now().

Tipos

  • **int + double da num**: asignarlo a un double no compila. Escribe x.toDouble() + 0.5.
  • **is sobre un null revienta.** Un valor que falta en un mapa de jsonDecode es un null: chequea siempre v != null && v is String.
  • Declara los tipos en variables y listas (final List<Widget> rows = [];). Ayuda al sandbox y al analizador.

Closures dentro de bucles

  • Una función creada **dentro de un for que usa la variable del bucle y** otra variable que se reasigna lee un valor equivocado. El linter lo marca. La salida es una variable nueva con final, o crear la función en un método aparte que reciba el valor como parámetro:
dart
// Mal: la closure usa i y también `total`, que se reasigna
for (var i = 0; i < n; i++) {
  total = total + 1;
  botones.add(boton(() => elegir(i, total)));
}

// Bien
for (var i = 0; i < n; i++) {
  total = total + 1;
  final int t = total;
  botones.add(boton(() => elegir(i, t)));
}

Lo que no hay

  • No hay dart:io ni red, ni archivos, ni Process: todo eso pasa por ModuleServer.
  • No hay Future/async en las llamadas al SDK (sección 7).
  • No hay pinch ni zoom con dos dedos en GestureDetector.

Rendimiento

  • Tu build puede llamarse seguido (cada vez que cambia algo del servidor). Mantenlo liviano: arma listas, no hagas cálculos pesados.
  • Lo pesado va en un programa nativo.

12. Probar #

  • Vista previa del creador: corre tu módulo con miembros, canales y un canal de sync de mentira, en memoria. Es lo más rápido para iterar.
  • En un servidor de verdad: guárdalo, agrégalo a un espacio y pruébalo con alguien más. El canal de sync se prueba mejor con dos personas.
  • El linter del creador: al guardar y al publicar marca con el número de línea los casos de la sección 11. Arréglalos antes de enviar: la revisión del Mercado pasa el mismo linter.
  • Varios tamaños: prueba tu módulo de 1–2 celdas, de tamaño normal y maximizado.
  • Programas nativos: pruébalos desde la PC, en un servidor de verdad y, si juegan en red, con dos PC.

13. Publicar en el Mercado #

  1. Empaqueta: desde el creador, «Publicar»; o, si lo hiciste en tu IDE, «Subir proyecto .zip» en el Panel de creador. El paquete lleva manifest.json, lib/, native/ (solo fuente), README y LICENSE. Las carpetas tools/, bin/ y build/ no viajan.
  2. Revisión automática:
    • compila tu módulo en el sandbox y pasa el linter;
    • compara los permisos declarados con los que usa;
    • si hay programa nativo, lo compila ZENISO y revisa que no importe funciones prohibidas (crear procesos, registro, servicios).
  3. Revisión del equipo: una persona lo mira antes de que salga al público.
  4. Página del Mercado: título, descripción, imágenes y precio (gratis o con monedas).
  5. Actualizaciones: subes una versión nueva y pasa por la misma revisión. Las apps la bajan solas; si la versión nueva pide un permiso nuevo, se le pregunta de nuevo a la persona.

Antes de publicar:

  • minAppVersion apunta a una versión que tiene todo lo que usas.
  • Los permissions del manifest son exactamente los que usas.
  • Se ve bien de 1–2 celdas, normal y maximizado.
  • Los colores salen de server.accentColor (nada que choque con los fondos claros u oscuros).
  • Si usa el canal de sync, lo probaste con dos personas y respetas los topes.
  • README dice qué hace y LICENSE qué se puede hacer con el código.

Dónde se puede usar después:

  • En un servidor dedicado, el dueño elige qué módulos se admiten.
  • En las redes LAN y los servidores oficiales, los módulos del Mercado se usan con la membresía de NEXUS.

14. Módulos de referencia #

Los módulos oficiales de ZENISO usan el mismo SDK público que el tuyo. Agrégalos desde el Mercado para ver cómo resuelven cada cosa:

MóduloQué muestra
PizarraDrawSurface y el canal de sync: trazos, figuras, notas, selección, «pendientes» hasta el eco, borrar como moderador.
TableroUn tablero estilo kanban con topics shared. (todos mueven tarjetas).
Servidores de juegoserver.gameServer, ModuleBridgeAddress y ModuleServerPing.
AvernoUn juego completo con programa nativo, red en lockstep y audio.

15. Referencia rápida del SDK #

Lo que tu módulo puede usar, tal como lo ve el sandbox (se genera del código de la app).

package:lannexus_sdk/module.dart #

dart
abstract class CustomGridModule {
  Widget build(BuildContext context, double width, double height);
}
// Tu entrada:  CustomGridModule createModule() => MiModulo();

package:lannexus_sdk/server.dart #

dart
class ModuleServer {
  ModuleServer(BuildContext context);

  // Quién y dónde
  String get myId;
  List<ModuleMember> get members;        // id, nickname, online, roleName, roleColor, roleOrder,
                                          // status, statusText, inVoice, speaking
  List<ModuleChannel> get channels;      // id, name
  bool get canModerate;
  int get accentColor;
  String get appLanguage;                // 'es' o 'en' (desde la 2.0.36)
  String statusTextOf(String peerId);
  String statusLabel(String status);
  void openMemberMenu(String memberId);
  void openMyProfile();
  void openExternal(String uri);   // https o steam://install|store/<appid>, con confirmación (2.0.50)

  // Qué sabe hacer esta app (2.0.68)
  String get appVersion;
  List<String> get appFeatures;          // 'join.steam.connect', 'ping.tcp', 'draw.G'...
  bool supports(String feature);

  // Guardar y comunicarse
  ModuleStorage get storage;             // get / set / remove (256 KB, esta PC)
  ModuleBus get bus;                     // prefix, publish / subscribe / subscribeWithSender (custom.<id>.… o shared.<espacio>.…)
  void sendMessage(String channelId, String content);

  // Canal de sync (lo que el servidor recuerda)
  List<ModuleTopicOp> topicOps(String topic);          // id, authorId, editorId, dataJson
  void publishTopicOp(String topic, String opId, String dataJson);
  void removeTopicOps(String topic, List<String> ids);

  // Lista compartida
  List<ModuleEntry> get moduleEntries;   // id, name, processName, addedByMemberId, online
  void addModuleEntry(String name, String processName);
  void removeModuleEntry(String entryId);

  // Ventana
  bool get isMaximized;
  void requestMaximize();
  void requestRestore();

  // Voz y transmisiones
  List<ModuleVoiceChannel> get voiceChannels;   // id, name, members(id, nickname, speaking, muted, deafened)
  String get myVoiceChannelId;
  bool get myMuted;
  bool get myDeafened;
  void requestJoinVoice(String channelId);      // con confirmación
  void leaveVoice();
  void muteMe();
  List<ModuleStream> get streams;               // memberId, nickname, watching, mine

  // Programas de la PC
  bool checkProcess(String processName);
  void pickExecutablePath(Function onPicked);   // (String? path)
  void launchExecutable(String path);

  // Programa nativo propio
  void startNative(String session, String exe, String configJson);
  void startNativeNetworked(String session, String exe, String configJson, String networkJson);
  void startNativeWithFolder(String session, String exe, String configJson, String folder);
  void stopNative(String session);
  String nativeState(String session);
  bool nativeSend(String session, String json);
  List<String> nativeMessages(String session);
  bool get bridgeReady;

  // Servidores de juego
  ModuleGameServer get gameServer;  // folders, pickFolder, start, stop, kill, state, lines,
                                    // history, command, readFile, writeFile, versions, install…
}

package:lannexus_sdk/widgets.dart #

dart
Text(String data, {TextStyle? style, int? maxLines, bool? ellipsis, bool? center})
TextStyle({Color? color, Color? backgroundColor, double? fontSize, double? letterSpacing,
           double? wordSpacing, double? height, bool? bold, bool? italic})
Row / Column({mainAxisAlignment, mainAxisSize, crossAxisAlignment, verticalDirection, children})
Container({alignment, padding, color, width, height, margin, child})
ColoredBox({required Color color, Widget? child})
Icon(IconData icon, {double? size, Color? color, String? semanticLabel})
Stack({alignment, fit, children})
Flexible({int? flex, required Widget child})
FittedBox({fit, alignment, child}) · Baseline(...) · Directionality(...) · Tooltip({message, child})
GestureDetector({child, onTap, onDoubleTap, onLongPress, onPanStart, onPanUpdate, onPanEnd,
                 onScroll, onTapUp, onDoubleTapDown, onKey})       // DragInfo: localX, localY, deltaX, deltaY
ModuleTextField({required String value, required Function onChanged, hintText, colorHex,
                 multiline, fontSize, autofocus, bare, onSubmitted})
ModuleScrollView({required Widget child, bool? horizontal})
DrawSurface({required Function onPaint})                          // onPaint: () => String de comandos
ModuleBox({color, radius, circle, borderColor, borderWidth, padding, margin, width, height, child})
ModuleAvatar({required String memberId, double? size, bool? speakingRing, bool? statusDot})
ModuleStreamView({required String memberId, bool? cover})
ModuleNativeSurface({required String session, bool? capture})
ModuleBridgeAddress({required String memberId, required int port, Color? color, double? fontSize,
                     bool? join, bool? cta, String? launch})               // launch: 'steam.connect' (2.0.68)
ModuleServerPing({required String memberId, required int port, double? fontSize,
                  String? protocol})                                     // 'a2s' | 'tcp' | 'icmp' (2.0.68)

¿Te trabaste con algo que esta guía no cubre? Lo más probable es que otro creador también se trabe. Cuéntanos y lo agregamos.