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.
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ón | Forma en web | Qué hace |
|---|---|---|
| SetData | setData(path: string, value: unknown): void | Escribe en el almacén de datos. Cada binding que lee esa ruta se actualiza, y el layout se vuelve a correr donde haga falta. |
| SetOpen | setOpen(id: string, open: boolean): boolean | Abre o cierra un Collapse. |
| SetSelectedTab | setSelectedTab(id: string, index: number): boolean | Selecciona una pestaña de un grupo "exclusive-select", por el id del contenedor del grupo. |
| SetChecked | setChecked(id: string, checked: boolean): boolean | Fija un Toggle. |
| SetValue | setValue(id: string, value: number): boolean | Mueve un Slider — exactamente el gesto que habría hecho el jugador, enganches incluidos. |
| SetText | setText(id: string, text: string): boolean | Escribe el texto de un TextInput, como si se hubiera tecleado. |
| SetScroll | setScroll(id: string, x: number, y: number): boolean | Mueve 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:
SetValueacota y cuantiza almin/max/stepdel slider, disparaonChangey después disparaonCommit: el gesto entero, de la pulsación a la soltada, en una sola llamada.SetCheckeddispara elonChangedel toggle y, dentro de un grupo, el del grupo.SetTextsustituye el búfer y deja el cursor al final, que es donde empezaría a escribir alguien a quien le dan un valor prerrellenado.SetScrollse 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
onDataChangedexactamente 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
| Callback | Forma en web | Cuándo se dispara |
|---|---|---|
| Acción | onAction(action: string, context?: ActionContext) | Se disparó una named action declarada en la IR — onClick, onChange, onCommit, onSubmit, onDismiss. |
| Datos cambiados | onDataChanged(path: string, value: unknown) | Un control escribió su valor en una ruta bindeada. |
| Diagnóstico | onDiagnostic(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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| view | string | la primera view del envelope | Id de la view que renderizar. |
| onAction | (action, context?) => void | ninguno | Named actions, con el contexto del elemento cuando lo hay. |
| onDataChanged | (path, value) => void | ninguno | La vuelta del canal de datos. |
| onDiagnostic | (diagnostic) => void | la consola | A dónde van los diagnósticos del contrato de carga. |
| background | string | "#101218" | Color con el que se limpia el canvas (hexadecimal CSS). |
| dpr | number | el del navegador | Ratio de píxel de dispositivo al que renderizar, en vez de devicePixelRatio. |
| onFrame | (stats) => void | ninguno | Se 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
| Miembro | Tipo | Qué es |
|---|---|---|
| viewIds | string[] | 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. |
| ready | Promise<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) => void | Hot-update, por el mismo camino de carga que usa un SDK publicado. |
| snapshot() | () => ViewSnapshot | Las medidas del frame — ver más abajo. |
| stats() | () => FrameStats | Lo que costó el último frame pintado — ver más abajo. |
| dispose() | () => void | Libera el canvas, los recursos de GL y los listeners. Es idempotente. |
Las siete operaciones —setData, 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 costUn 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
| Campo | Tipo | Significado |
|---|---|---|
| view | string | El id de la view que hay en pantalla. |
| size | { width, height } | El canvas en px CSS. |
| focus / hover / pressed | string | null | El ref del nodo que tiene cada estado. |
| layer | LayerSnapshot[] | Los overlays en (z, orden de documento), el más de abajo primero, cada uno con su presence (0..1 mientras se funde). |
| tree | NodeSnapshot | El á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.
| Campo | Significado |
|---|---|
| drawCalls / vertices / indices | La geometría enviada del frame. |
| atlases / atlasBytes | Atlas de glifos vivos, y los bytes de CPU de sus bitmaps. |
| resolved | Nodos que visitó el pase de resolución — el trabajo de CPU previo al layout. Cero en un frame de solo repintado. |
| textLayouts | Textos vueltos a partir en líneas. Un frame estable sobre una escena estática tiene que quedarse en cero. |
| bufferGrowths | Búferes de geometría que tuvieron que crecer. Cero una vez que la escena se ha pintado a tamaño completo. |
| repaintOnly | El frame se saltó todo el pipeline antes de teselar: no cambió nada salvo los píxeles, como un cursor que parpadea. |