Saltar al contenido
zabloo
zabloo/uiUna plataforma de UI para videojuegosGRATIS Y OPEN SOURCE

Aprende UI una vez.Publícala en todos los motores.

Construye la UI de tu juego en la web, con React. Expórtala una vez y úsala directamente en Unity y el navegador; Godot y Unreal, después.

La misma pantalla de tienda —un título, un contador de oro que marca 1.250 y un botón Buy— dibujada dentro de cuatro motores, uno al lado de otro: Unity y el navegador hoy; Godot y Unreal después, atenuados. Un único archivo exportado, llamado your-ui.zabloo, alimenta los cuatro.
Unity es una marca de Unity Technologies. Logo de Godot por Andrea Calabró (CC BY 4.0). Los nombres y las marcas identifican a cada motor; no implican ningún respaldo.

Renderer real

Componentes, movidos en vivo.

Esto no es un vídeo. El frame ejecuta el mismo renderer que ejecuta el SDK —su propio layout, su propio teselador, su propio atlas de glifos— y un script está escribiendo en las rutas de datos que la pantalla enlaza. En cuanto lo tocas, el script se detiene: el panel de al lado escribe esas mismas rutas y la consola lee las actions que la UI le devuelve al juego.

settings.viewIR v1
Viewport

Viewport: 960 × 500

Un panel de ajustes de juego: un deslizador de volumen de música lleno hasta un tercio con un 30 leído a su lado, un interruptor de notificaciones apagado sobre otro de guardado en la nube encendido, y una lista de misiones de tres filas —Missing caravan en Dustfall, Wolves at the gate en North Road y Ledger audit en Guild hall—, cada una con su botón Track.
ESTADO
  • hover — inactivo
  • pressed — inactivo
  • focused — inactivo
  • selected — inactivo
  • disabled — inactivo

Se leen del frame, no se le imponen.

Los datos, cambiando

  • audio.music=30 → 80
  • alerts.enabled=true
  • quests[]=+= "Harbour blockade"

Cada línea se enciende cuando su escritura se dispara, y el frame responde en ese mismo instante. Ese es todo el contrato: tus datos entran, sale layout.

Aún no se ha dibujado ningún frame

import { Button, Column, List, Row, Slider, Switch, Text } from "@zabloo/react";


/** Fits the 310 px a 390-wide viewport leaves inside both paddings. */
const TRACK_LENGTH = 300;

const TRACK = { background: "{color.slot}", radius: "{radius.pill}" } as const;
const FILL = { background: "{color.brand}", radius: "{radius.pill}" } as const;
const THUMB = { background: "{color.text}", radius: "{radius.pill}" } as const;

const ROW = { background: "{color.slot}", radius: "{radius.md}" } as const;

export default function Settings() {
  return (
    <Column
      // `align: "stretch"` on the ROOT is what hands the panel the view's own
      // width; centring it would pin the panel to its content and no change of
      // viewport could reach it.
      layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.5}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        layout={{ padding: "{space.4}", gap: "{space.4}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        <Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>SETTINGS</Text>

        {/* The node that answers the viewport: two columns with a width, so the
            wrap point is arithmetic. 300 + 16 + 340 = 656 fits the 872 a desktop
            leaves and does not fit the 302 a phone does. */}
        <Row layout={{ wrap: true, gap: "{space.4}", align: "start" }}>
          <Column layout={{ width: 300, grow: 1, gap: "{space.4}", align: "stretch" }}>
            <Column layout={{ gap: "{space.2}", align: "stretch" }}>
              <Row layout={{ justify: "space-between", align: "center" }}>
                <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Music volume</Text>
                {/* Same path as the slider below. The readout follows the game's
                    write and the player's drag alike, because both are the data. */}
                <Text bind="audio.music" style={{ color: "{color.text}", fontSize: "{text.sm}" }} />
              </Row>
              <Slider
                id="music"
                value={{ bind: "audio.music" }}
                min={0}
                max={100}
                step={1}
                onChange="music-preview"
                onCommit="music-apply"
                length={TRACK_LENGTH}
                style={TRACK}
                fill={FILL}
                thumb={THUMB}
              />
            </Column>

            <Switch
              id="alerts"
              checked={{ bind: "alerts.enabled" }}
              onChange="alerts-changed"
              track={{ background: "{color.slot}", radius: "{radius.pill}" }}
              checkedTrack={{ background: "{color.brand-strong}" }}
              knob={{ background: "{color.text}", radius: "{radius.pill}" }}
            >
              <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Notifications</Text>
            </Switch>

            {/* Nought or one item: with an empty array this list holds no rows
                and takes no height, and the row that arrives pushes everything
                under it down. No empty slot — "nothing to say" is the honest
                shape of a digest that is switched off. */}
            <List
              items="alerts.digest"
              as="d"
              keyPath="id"
              layout={{ gap: "{space.2}", align: "stretch" }}
            >
              {(d) => (
                <Row
                  layout={{ padding: "{space.2}", align: "center" }}
                  style={{
                    background: "{color.brand-soft}",
                    radius: "{radius.md}",
                    borderWidth: "{border.hairline}",
                    borderColor: "{color.brand}",
                  }}
                >
                  <Text
                    bind={d("label")}
                    style={{ color: "{color.text}", fontSize: "{text.xs}" }}
                  />
                </Row>
              )}
            </List>

            {/* What the digest row pushes down when it arrives. A layout that
                re-flows has to have something under the change, or "it enters
                the flow" is a claim nothing on screen can confirm. */}
            <Switch
              id="cloud"
              checked={{ bind: "saves.cloud" }}
              onChange="cloud-changed"
              track={{ background: "{color.slot}", radius: "{radius.pill}" }}
              checkedTrack={{ background: "{color.brand-strong}" }}
              knob={{ background: "{color.text}", radius: "{radius.pill}" }}
            >
              <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Cloud saves</Text>
            </Switch>
          </Column>

          <Column layout={{ width: 340, grow: 1, gap: "{space.2}", align: "stretch" }}>
            <Row layout={{ justify: "space-between", align: "center" }}>
              <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Quests</Text>
              <Text bind="quests.count" style={{ color: "{color.faint}", fontSize: "{text.xs}" }} />
            </Row>

            {/* One template, instantiated per element. The row the demo appends
                is not a different node — it is this one, with one more item in
                the array. */}
            <List
              items="quests.open"
              as="q"
              keyPath="id"
              layout={{ gap: "{space.2}", align: "stretch" }}
              empty={
                <Text style={{ color: "{color.faint}", fontSize: "{text.sm}" }}>
                  No quests in your log
                </Text>
              }
            >
              {(q) => (
                <Row
                  layout={{ height: 38, padding: "{space.2}", gap: "{space.3}", align: "center" }}
                  style={ROW}
                >
                  <Column layout={{ grow: 1, gap: 2 }}>
                    <Text
                      bind={q("name")}
                      style={{ color: "{color.text}", fontSize: "{text.sm}" }}
                    />
                    {/* Where the arrival announces itself: the game writes
                        "just landed" here, then the item's real area a second
                        later. A style could not carry that — v1 binds data. */}
                    <Text
                      bind={q("area")}
                      style={{ color: "{color.faint}", fontSize: "{text.xs}" }}
                    />
                  </Column>
                  {/* One action for the whole list; which row it came from
                      travels in the action's context. */}
                  <Button
                    variant="secondary"
                    onClick="track"
                    layout={{ width: 74, height: 30, justify: "center", align: "center" }}
                  >
                    <Text style={{ color: "{color.text}", fontSize: "{text.xs}" }}>Track</Text>
                  </Button>
                </Row>
              )}
            </List>
          </Column>
        </Row>
      </Column>
    </Column>
  );
}
El renderer se carga cuando arranca la demo, y no antes: el frame en reposo es una imagen. El frame lleva la misma descripción en texto, y las pestañas de código son el equivalente accesible de la imagen.

Layout

Un subconjunto de Flexbox que todos los targets implementan igual. La fila que llega empuja el layout porque el layout se mide, nunca se hornea.

Bindings

Texto, valores y estado checked leen de rutas de datos que posee el juego. El volumen de arriba es una ruta, leída por el deslizador y por el número de al lado.

Named actions

La UI emite nombres, no callbacks. El juego se suscribe a «track» y nunca sabe qué dibujó el botón que lo envió.

Tokens, recargados

Una pantalla. Cuatro pieles. Sin recompilar.

El mismo panel de la sección de arriba, con otra piel y en vivo. Cada cambio de abajo entrega a la vista en marcha una carga nueva —la propia llamada de hot-update del SDK— con la misma pantalla y otro diccionario. El color, el radio de las esquinas, el espaciado, la duración de las transiciones y la escala tipográfica son valores de ese fichero: por eso cambiar la escala redimensiona el texto y recoloca el panel a su alrededor.

Un panel de ajustes de juego: un deslizador de volumen de música lleno hasta un tercio con un 30 leído a su lado, un interruptor de notificaciones apagado sobre otro de guardado en la nube encendido, y una lista de misiones de tres filas —Missing caravan en Dustfall, Wolves at the gate en North Road y Ledger audit en Guild hall—, cada una con su botón Track.
Tema
Escala tipográfica

handle.reload(envelope)

Cada paleta se comprueba contra sí misma al construir: ninguna piel publica texto que este sitio no pueda leer.

El selector es de esta web; el re-tematizado es del formato. zabloo/ui trae los tokens y la recarga, no un editor de presets.

Tokens

Colores, espaciado, radios y duraciones se resuelven a través de un diccionario del envelope. Cambiar la piel es una carga, no una pantalla que alguien haya vuelto a abrir.

Degradation

Que el SDK sea más antiguo que el contenido que recibe es un caso normal. Los nodos desconocidos degradan por regla, y una carga que el lector rechaza deja intacta la pantalla que ya estaba mostrando.

En el motor

Carga un envelope. Escucha las actions.

Esa es toda la superficie de integración. El SDK es dueño del dibujo y del input; tu juego, de los datos y de qué significa cada nombre.

  • Ni prefab por pantalla ni cableado de escenas
  • Un solo draw path, sea cual sea la pantalla
  • Cambia el envelope en runtime y conserva la sesión
ShopScreen.csUnity
var ui = Zabloo.Mount("shop");

ui.OnAction("buy", ctx => {
    Economy.Purchase(ctx.Path);
});

ui.SetData("player.gold", 1250);

Pon tu UI en la próxima build.

El SDK es open source y gratis. Empieza con una pantalla y mira hasta dónde te lleva el formato.