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

Tu UI deja de ser parte de la build.

El juego incluye un SDK pequeño. Las pantallas viajan como datos: un envelope IR versionado que el SDK tesela en geometría de GPU. Cambia una pantalla, publica el envelope, y la build que ya está en las máquinas de los jugadores dibuja la nueva.

terminal
$ npm create zabloo-app
$ zabloo dev
→ preview on :5078
$ zabloo build
→ dist/zabloo.ir.json

Velo en marcha

No es una imagen de la UI. Es la UI.

Este frame ejecuta el mismo renderer que ejecuta el SDK: su propio layout, su propio teselador, su propio atlas de glifos. Cambia los datos, mira cómo se recoloca y lee las actions que la UI devuelve al juego.

inventory.viewIR v1
Viewport

Viewport: 960 × 420

Un panel de inventario de juego: el título INVENTORY con un contador de oro que marca 1.250, una cuadrícula de cuatro por dos de huecos de objeto con los tres primeros llenos y el primero resaltado como equipado, una barra de peso cargado a 64 de 100, y un botón Equip junto a un botón Drop.
ESTADO
  • hover — inactivo
  • pressed — inactivo
  • focused — inactivo
  • selected — inactivo
  • disabled — inactivo

Se leen del frame, no se le imponen.

Datos

Lo que el juego ha empujado a player.gold, ya formateado: la IR no tiene expresiones, así que formatear es trabajo del juego.

Escribe los dos paths que enlaza el panel: bag.weight, el número que lee el jugador, y bag.load, el 0–1 en el que está definida la barra.

Actions

  1. Pulsa algo en el frame y las actions aparecerán aquí.

Aún no se ha dibujado ningún frame

import { Button, Column, Image, ProgressBar, Row, Text } from "@zabloo/react";


/** Runs at authoring time — it is a plain array, not game data. */
const SLOTS: Array<{ id: string; icon?: string; tint?: string; equipped?: boolean }> = [
  { id: "relic", icon: "icons/star.png", tint: "{color.brand}", equipped: true },
  { id: "shield", icon: "icons/shield.png", tint: "#6ee7b7" },
  { id: "charge", icon: "icons/bolt.png", tint: "{color.gold}" },
  { id: "empty-1" },
  { id: "empty-2" },
  { id: "empty-3" },
  { id: "empty-4" },
  { id: "empty-5" },
];

/**
 * 68, not 76, and that is the whole reason the grid survives a phone: four
 * slots and their gaps come to 296, which still fits the 302 a 390-wide view
 * leaves after the paddings. A slot row that cannot fit the narrowest preset
 * would be clipped there rather than re-laid out.
 */
const SLOT_SIZE = 68;
const SLOT_COLUMN = SLOT_SIZE * 4 + 8 * 3;

export default function Inventory() {
  return (
    <Column
      // `align: "stretch"` on the ROOT is what hands the panel the view's own
      // width; centring it instead would pin the panel to its content and no
      // change of viewport could reach it.
      layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.6}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        // `align: "stretch"` is what gives the rows below the panel's full
        // width — without it they measure to their content and there is no
        // leftover space for `space-between` or `grow` to distribute.
        layout={{ padding: "{space.5}", gap: "{space.4}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        {/* Header: title + the gold pill, whose amount is bound. */}
        <Row layout={{ justify: "space-between", align: "center" }}>
          <Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>INVENTORY</Text>
          <Row
            layout={{
              gap: "{space.1}",
              align: "center",
              padding: "{space.1}",
              width: 92,
              justify: "center",
            }}
            style={{ background: "{color.slot}", radius: "{radius.pill}" }}
          >
            <Image
              src="icons/coin.png"
              layout={{ width: 13, height: 13 }}
              style={{ color: "{color.gold}" }}
            />
            <Text bind="player.gold" style={{ color: "{color.gold}", fontSize: "{text.sm}" }} />
          </Row>
        </Row>

        {/* The body, and the one node that answers the viewport: a wrapping row
            whose two children carry a width, so the wrap point is arithmetic.
            296 + 16 + 260 = 572 fits the 872 a desktop leaves and does not fit
            the 302 a phone does, so the stats block drops under the slots
            exactly there — and `grow` then hands the stats block the rest of
            its line, which is what keeps the weight bar spanning the panel. The
            BUTTONS under it keep a width of their own: a primary action that
            stretches to 700px on a desktop is the leftover space reaching a
            child that had no use for it. */}
        <Row layout={{ wrap: true, gap: "{space.4}", align: "start" }}>
          {/* Eight slots in two rows of four. The map is authoring-time sugar. */}
          <Column layout={{ width: SLOT_COLUMN, gap: "{space.2}" }}>
            {[0, 4].map((offset) => (
              <Row key={offset} layout={{ gap: "{space.2}" }}>
                {SLOTS.slice(offset, offset + 4).map((slot) =>
                  slot.icon === undefined ? (
                    <Column
                      key={slot.id}
                      layout={{ width: SLOT_SIZE, height: SLOT_SIZE }}
                      style={{
                        radius: "{radius.md}",
                        borderWidth: "{border.hairline}",
                        borderColor: "{color.line}",
                      }}
                    />
                  ) : (
                    <Button
                      key={slot.id}
                      id={slot.id}
                      variant="slot"
                      onClick="slot-select"
                      layout={{
                        width: SLOT_SIZE,
                        height: SLOT_SIZE,
                        justify: "center",
                        align: "center",
                      }}
                      style={
                        slot.equipped
                          ? { background: "{color.brand-soft}", borderColor: "{color.brand}" }
                          : undefined
                      }
                    >
                      <Image
                        src={slot.icon}
                        layout={{ width: 26, height: 26 }}
                        style={{ color: slot.tint }}
                      />
                    </Button>
                  ),
                )}
              </Row>
            ))}
          </Column>

          <Column layout={{ width: 260, grow: 1, gap: "{space.4}", align: "stretch" }}>
            {/* Carried weight: the label and the bar read two different paths —
                the number the player sees, and the 0..1 the bar is defined in. */}
            <Column layout={{ gap: "{space.2}", align: "stretch" }}>
              <Row layout={{ justify: "space-between", align: "center" }}>
                <Text style={{ color: "{color.muted}", fontSize: "{text.xs}" }}>Weight</Text>
                <Row layout={{ gap: "{space.1}" }}>
                  <Text
                    bind="bag.weight"
                    style={{ color: "{color.muted}", fontSize: "{text.xs}" }}
                  />
                  <Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>/ 100</Text>
                </Row>
              </Row>
              <ProgressBar
                id="weight-bar"
                value={{ bind: "bag.load" }}
                size={5}
                style={{ background: "{color.slot}", radius: "{radius.pill}" }}
                fill={{ background: "{color.brand}", radius: "{radius.pill}" }}
              />
            </Column>

            {/* The two actions the game subscribes to. */}
            <Row layout={{ gap: "{space.2}" }}>
              <Button
                id="equip"
                variant="primary"
                onClick="equip"
                layout={{ width: 180, height: 40, justify: "center", align: "center" }}
              >
                <Text style={{ color: "{color.on-brand}", fontSize: "{text.sm}" }}>Equip</Text>
              </Button>
              <Button
                id="drop"
                variant="secondary"
                onClick="drop"
                layout={{ width: 96, height: 40, justify: "center", align: "center" }}
              >
                <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Drop</Text>
              </Button>
            </Row>
          </Column>
        </Row>
      </Column>
    </Column>
  );
}
El renderer se carga al pulsar Ejecutar, y solo entonces: el frame en reposo de arriba es una imagen. Cada frame de esta página lleva la misma descripción en texto, y la pestaña de código es el equivalente accesible de la imagen.

Qué lleva el formato

Seis cosas que el SDK sabe hacer.

Layout

Un subconjunto de Flexbox que todos los targets implementan igual: direction, justify, align, gap, grow, wrap.

Tokens y theming

Colores, espaciado y radios se resuelven a través de un diccionario en el envelope. Cambia la piel sin tocar una pantalla.

Bindings

Texto, visibilidad y estado checked leen de rutas de datos que posee el juego. Escribe una vez y la UI se recoloca.

Named actions

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

Input y focus

Puntero, teclado y gamepad. La navegación direccional y los focus traps son parte del formato, no de tu código.

Degradation

Un SDK más viejo que el contenido que recibe es un caso normal. Los nodos desconocidos degradan por regla, nunca a un crash.

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.