Saltar al contenido
zabloo

El canal del host

La API de ejecución con la que el juego maneja la UI: siete operaciones que entran, tres callbacks que vuelven, y el mount, el handle y la introspección del target web.

El envelope describe una pantalla; no describe una sesión. El jugador recoge 120 de oro. El juego quiere que se abra la sección de audio del panel de opciones. Una build nueva de la tienda tiene que sustituir a la que hay ahora en pantalla. Todo eso pasa mientras la UI está corriendo, y el juego lo dice llamando al SDK: ui.setData("player.gold", 1250) es la idea entera. Ese conjunto de llamadas es el canal del host — siete operaciones que entran, tres callbacks que vuelven.

Es la contraparte de las actions que van en el otro sentido: las acciones viajan de la UI al juego, y todo lo de esta página viaja de vuelta.

Está deliberadamente fuera de la IR. Son operaciones de ejecución —«abre ese collapse», «el jugador tiene ahora 1250 de oro»— y un documento no tiene sitio donde ponerlas. Que es también por lo que son iguales en todas partes: las operaciones, sus argumentos y sus efectos son parte del contrato; solo la forma de escribirlas sigue las convenciones de cada motor.

Las tres páginas que explican el hot-update

Enviar UI a un juego que ya está en manos de los jugadores es para lo que existe este formato, y hay tres páginas que se lo reparten. Versionado dice si un SDK y un payload pueden siquiera encontrarse. Loading dice qué hace el SDK con el payload una vez se encuentran: qué repara, qué rechaza y qué le cuenta al juego. Esta página es la llamada que entrega el payload nuevo, reload, y el callback por el que vuelven sus diagnósticos.

game ──── SetData / SetOpen / SetChecked / … ────▶ UI
     ◀─── onAction / onDataChanged / onDiagnostic ───

Las firmas de abajo son las del target web (@zabloo/renderer-web), puestas aquí como la forma concreta de escribir cada operación. Unity —el SDK de referencia de v1— expone el mismo contrato en ZablooDocument (SetData, Reload) y en ZablooView (View.SetOpen).

Las operaciones

Estas siete son todo lo que un juego puede hacerle a una UI en marcha. La lista es cerrada y normativa: un juego escrito contra el SDK de un motor está escrito contra todos ellos, porque las operaciones, sus argumentos y lo que hacen son idénticos en todas partes.

normative
OperaciónForma en webQué hace
SetDatasetData(path: string, value: unknown): voidEscribe en el almacén de datos. Cada binding que lee esa ruta se actualiza, y el layout se vuelve a correr donde haga falta.
SetOpensetOpen(id: string, open: boolean): booleanAbre o cierra un Collapse.
SetSelectedTabsetSelectedTab(id: string, index: number): booleanSelecciona una pestaña de un grupo "exclusive-select", por el id del contenedor del grupo.
SetCheckedsetChecked(id: string, checked: boolean): booleanFija un Toggle.
SetValuesetValue(id: string, value: number): booleanMueve un Slider — exactamente el gesto que habría hecho el jugador, enganches incluidos.
SetTextsetText(id: string, text: string): booleanEscribe el texto de un TextInput, como si se hubiera tecleado.
SetScrollsetScroll(id: string, x: number, y: number): booleanMueve el offset de un ScrollView.

Direccionar por id

Todas las operaciones menos SetData nombran un nodo por su id, así que los nodos que un juego maneja llevan uno. Se espera que los ids sean únicos dentro de una view; los duplicados cargan con un aviso y resuelven a la primera coincidencia.

Las operaciones por id responden si han encontrado el control. Un false significa que ningún nodo de ese tipo lleva ese id —una errata, una view que se hot-updateó por debajo de quien llama, un nodo cuyo visible lo sacó del árbol— y que no se aplicó nada. No es una excepción: un juego que recorre ids no puede morirse porque una pantalla haya cambiado. El target web además registra el fallo ([zabloo] setChecked: no Toggle with id "…").

Un id dentro de la plantilla de un Repeat lo llevan puesto todas sus instancias, y la búsqueda se queda con la última materializada. Direccionar una fila concreta por id no es algo que v1 haga: una pulsación dentro de una fila vuelve en cambio con su action context, que dice qué elemento era.

Ejemplo resuelto — la tienda. El juego llena la pantalla con una llamada por ruta: setData("player.gold", "1,250") y setData("shop.items", [...]), y cada binding que las lee se actualiza. El jugador pulsa Comprar sobre la espada; el juego oye onAction("buy", { path: "shop.items.0", key: "sword-01", index: 0 }), hace su propia aritmética y empuja el resultado de vuelta con setData("player.gold", "1,130"). La UI no calculó nada: se lo dijeron dos veces.

Las escrituras de datos se cachean y se reproducen

SetData escribe en un almacén, no en el árbol. Los datos empujados antes de que se monte una view, o antes de que exista un nodo bindeado, se aplican en cuanto existe: un juego empuja su estado cuando lo tiene, y una UI cargada después sale igualmente rellena.

El almacén es también lo que lee un Repeat. Escribir el array (shop.items) mueve los bindings que hay dentro (shop.items.3.name), y escribir dentro de un elemento mueve un binding que vigila el array entero.

Manejar un control es el gesto del jugador

Las operaciones de valor no toquetean estado: recorren el mismo camino que el dedo del jugador, para que un juego y un jugador produzcan resultados idénticos:

  • SetValue acota y cuantiza al min/max/step del slider, dispara onChange y después dispara onCommit: el gesto entero, de la pulsación a la soltada, en una sola llamada.
  • SetChecked dispara el onChange del toggle y, dentro de un grupo, el del grupo.
  • SetText sustituye el búfer y deja el cursor al final, que es donde empezaría a escribir alguien a quien le dan un valor prerrellenado.
  • SetScroll se acota a los límites del último relayout.
  • Un control cuyo valor es un binding de lectura/escritura escribe el valor nuevo de vuelta por él, así que el juego se entera por onDataChanged exactamente igual que si hubiera venido de un gesto real.

Los callbacks

Tres cosas viajan en el otro sentido, y entre ellas son todo lo que el juego aprende de la UI: el jugador ha hecho algo, el jugador ha cambiado un valor, o el payload tenía algo mal. No vuelve nada más.

normativeCallbacks del host

CallbackForma en webCuándo se dispara
AcciónonAction(action: string, context?: ActionContext)Se disparó una named action declarada en la IR — onClick, onChange, onCommit, onSubmit, onDismiss.
Datos cambiadosonDataChanged(path: string, value: unknown)Un control escribió su valor en una ruta bindeada.
DiagnósticoonDiagnostic(diagnostic: Diagnostic)El contrato de carga encontró algo, tanto en mount como en reload.

onDataChanged no se dispara nunca por un SetData. Ese valor vino del juego; devolvérselo como eco convertiría cada escritura en una ida y vuelta.

ActionContext solo está presente en una acción disparada desde dentro de un elemento repetido, y describe el más interno — path, key e index, según la tabla de Bindings y actions.

Un Diagnostic lleva un code estable, el path del envelope sobre el que está, un level ("warn" o "fatal") y un message autocontenido; ver Loading para la tabla completa de códigos. Un warn se reparó y el envelope cargó sin la parte rota; un fatal significa que no cargó nada, y llega antes de que mount lance. Sin el callback, los avisos se van a la consola.

Montar una view

import { mount } from "@zabloo/renderer-web";

const canvas = document.querySelector("canvas") as HTMLCanvasElement;
const envelope = await fetch("/zabloo.ir.json").then((r) => r.text());

const ui = mount(canvas, envelope, {
view: "main-menu",
background: "#11141d",
onAction: (action, context) => {
  if (action === "play") startGame();
  if (context) console.log("fired from item", context.path, context.index);
},
onDataChanged: (path, value) => console.log("player wrote", path, "=", value),
onDiagnostic: ({ level, code, path, message }) => showInEditor(level, code, path, message),
});

await ui.ready;

ui.setData("player.gold", 1250);
ui.reload(nextEnvelope);
ui.dispose();

mount(canvas, envelope, options?) acepta el envelope como texto JSON o como objeto ya parseado.

PropTipoPor defectoDescripción
viewstringla primera view del envelopeId de la view que renderizar.
onAction(action, context?) => voidningunoNamed actions, con el contexto del elemento cuando lo hay.
onDataChanged(path, value) => voidningunoLa vuelta del canal de datos.
onDiagnostic(diagnostic) => voidla consolaA dónde van los diagnósticos del contrato de carga.
backgroundstring"#101218"Color con el que se limpia el canvas (hexadecimal CSS).
dprnumberel del navegadorRatio de píxel de dispositivo al que renderizar, en vez de devicePixelRatio.
onFrame(stats) => voidningunoSe dispara una vez por frame realmente pintado, con lo que ha costado.

dpr es fijo durante toda la vida del montaje. El renderer lee el ratio en todos los sitios donde convierte píxeles lógicos en píxeles de dispositivo —el backing store, la escala del atlas de glifos, la rejilla de píxeles a la que se pegan los quads— así que sobrescribirlo significa reconstruir los atlas. Un host que lo ofrezca como control (el selector de DPR de una previsualización, un arnés de golden fijado a un ratio concreto) vuelve a montar.

onFrame es la única forma de conseguir una tasa de frames. stats() responde a lo que costó el último frame, y consultarlo en bucle no puede llegar a ser una tasa porque el renderer pinta bajo demanda: una escena quieta no pinta nada en absoluto, y el requestAnimationFrame de quien llama estaría midiendo la página y no el renderer. Recibe un FrameStats más un ms — el tiempo dentro de teselar y enviar, sin la ejecución asíncrona propia de la GPU.

mount lanza un EnvelopeError si el payload es inservible: no hay ninguna UI anterior que proteger, y quien llama tiene que enterarse de que su payload nunca llegó a ser una view. Es el único punto de entrada que lanza.

El handle

MiembroTipoQué es
viewIdsstring[]Los ids de view del envelope actual. Es un getter: un hot-update puede añadir, quitar o renombrar views, así que un selector de view lo vuelve a leer tras cada reload en vez de quedarse con el array que recibió al montar.
readyPromise<void>Resuelve en cuanto la view ha cambiado a su propio rasterizador de texto y ha repintado con él. Todo lo que compare métricas —un test golden, una captura— espera a esto. No rechaza nunca: una carga fallida se queda con las métricas del navegador.
reload(envelope)(string | object) => voidHot-update, por el mismo camino de carga que usa un SDK publicado.
snapshot()() => ViewSnapshotLas medidas del frame — ver más abajo.
stats()() => FrameStatsLo que costó el último frame pintado — ver más abajo.
dispose()() => voidLibera el canvas, los recursos de GL y los listeners. Es idempotente.

Las siete operacionessetData, setOpen, setSelectedTab, setChecked, setValue, setText, setScroll— son también miembros de este mismo handle. Están tabuladas arriba por su cuenta porque son la superficie normativa que implementan todos los targets, mientras que estas seis son propias del binding web.

reload no lanza nunca. Un payload que el validador rechaza —truncado, corrupto, de una versión mayor que este lector no implementa— se reporta por onDiagnostic y se descarta: la view que hay en pantalla se queda exactamente como está. Un hot-update malo le cuesta al jugador una actualización, nunca su sesión.

Una recarga salta. No hay valor anterior desde el que interpolar, así que el movimiento arranca desde el frame nuevo; con montar pasa lo mismo.

Después de un dispose(), las operaciones por id devuelven false y la view avisa una vez, no una vez por llamada.

En la consola del navegador

La previsualización de zabloo dev pone el handle de la view que ha montado en window.zabloo, así que la propia consola del navegador es un REPL contra la UI en marcha — la tercera forma de empujar un valor, junto al panel de bindings de la previsualización y el propio juego:

zabloo.setData("player.gold", 1250);
zabloo.setData("shop.items", [{ id: "sword-01", name: "Iron sword", price: 120 }]);
zabloo.setChecked("sfx", true);
zabloo.snapshot();                    // where every rect landed
zabloo.stats();                       // what the last painted frame cost
La referencia se sustituye en cada montaje

Un guardado corriente es un reload y conserva el mismo handle, pero cambiar la view o el DPR monta uno nuevo — así que un const ui = zabloo que sobreviva a cualquiera de las dos cosas es una view destruida cuyas operaciones por id responden false. Lee zabloo fresco cada vez. Mientras no hay ninguna view montada, la propiedad es undefined en vez de un handle rancio.

La introspección

snapshot() — las medidas del frame

Un snapshot es el frame puesto por escrito como datos: dónde ha caído cada rectángulo, qué ha hecho el texto, qué tiene el foco. Existe para que «los dos targets renderizan esto igual» sea algo que un test pueda afirmar en vez de algo que alguien tenga que mirar a ojo, y por eso su forma es normativa.

ViewSnapshot es el contrato entre targets: el mismo envelope cargado en otro SDK tiene que producir este mismo documento. Responde a lo que una captura de pantalla no puede explicar: dónde cayó cada rect, dónde se partió el texto y sobre qué líneas base se apoya, qué salió del layout, qué recorta a qué, en qué orden pinta la capa y dónde acabaron el foco, el hover y la pulsación. Los píxeles están deliberadamente ausentes.

normativeViewSnapshot

CampoTipoSignificado
viewstringEl id de la view que hay en pantalla.
size{ width, height }El canvas en px CSS.
focus / hover / pressedstring | nullEl ref del nodo que tiene cada estado.
layerLayerSnapshot[]Los overlays en (z, orden de documento), el más de abajo primero, cada uno con su presence (0..1 mientras se funde).
treeNodeSnapshotEl árbol, desde la raíz.

Un NodeSnapshot lleva su type, un ref (el id del nodo, o su ruta posicional desde la raíz — "0.2.1") y después solo lo que dice algo: rect, measured, states, style (tokens ya colapsados, transiciones aplicadas), text (líneas, anchos, líneas base, truncated), clip, scroll, value, field, window y children. Ausente significa el valor por defecto —un nodo sin foco no lleva states, uno sin recorte no lleva clip— y un nodo que está fuera del layout lleva out y nada más.

Tres reglas mantienen legible un diff: las claves se escriben en un orden fijo, ausente significa el valor por defecto, y los decimales se redondean una vez (3 decimales) para que los últimos bits de un FMA no reescriban nunca un fichero golden.

Lee un nodo con findNode(snapshot, "buy-btn"), o serializa el conjunto entero con serializeSnapshot(snapshot).

stats() — lo que costó el frame

FrameStats es telemetría solo del target web y no es normativa: nada de esto es una métrica entre targets, y por eso vive al lado de snapshot() y no dentro. Es contra lo que se afirman los presupuestos de rendimiento del renderer.

CampoSignificado
drawCalls / vertices / indicesLa geometría enviada del frame.
atlases / atlasBytesAtlas de glifos vivos, y los bytes de CPU de sus bitmaps.
resolvedNodos que visitó el pase de resolución — el trabajo de CPU previo al layout. Cero en un frame de solo repintado.
textLayoutsTextos vueltos a partir en líneas. Un frame estable sobre una escena estática tiene que quedarse en cero.
bufferGrowthsBúferes de geometría que tuvieron que crecer. Cero una vez que la escena se ha pintado a tamaño completo.
repaintOnlyEl frame se saltó todo el pipeline antes de teselar: no cambió nada salvo los píxeles, como un cursor que parpadea.

Relacionado