Editor de GUIs#
El Editor de GUIs arma cada panel en pantalla que no es el cuarto en sí — la barra HUD
siempre activa, las pantallas de Opciones y Guardar/Cargar, el popup de inventario, un
verb-coin, o cualquier modal a medida que necesite tu proyecto (un teclado numérico, un
teléfono, una lista de opciones de diálogo). Una GUI es un descriptor: datos puros que
listan un id, un rect/backdrop opcional, y un arreglo ordenado de widgets. Acá no hay
UI hardcodeada — computeLayout() y hitTest() (core/guis/layout.js) convierten esos datos
en geometría de pantalla y blancos de clic, ambas funciones puras sin acceso a canvas ni DOM; los
píxeles reales los pinta drawGui() en shell/canvas_render.js. Guarda en el guis.json del
proyecto (la barra HUD como mainBar/mainGui, todo lo demás bajo modals).
Llegas a él desde el Hub (con el dev server corriendo). El desplegable GUI de la barra de herramientas elige qué descriptor está cargado; el canvas del centro es un preview vivo y editable — no hay una pestaña de preview aparte, arrastras y redimensionas widgets directamente ahí — y el panel de la derecha muestra las propiedades de lo que esté seleccionado. Haz zoom en el canvas con los botones 🔍 / 🔍− de la barra o la rueda del mouse, ⊞ Fit encuadra toda la GUI en el área de preview, y puedes hacer pan para trabajar de cerca en un rincón cargado; el zoom es puramente visual, así que las coordenadas de los widgets nunca cambian al hacer zoom.
Anatomía de un descriptor#
- id — la clave de la GUI. La barra HUD se resuelve vía
mainGuiId()/getMainGui()(core/guis/index.js): un descriptor puede optar explícitamente conrole: 'main', o caer al id convencionalmainBar/mainGui. Todo lo demás se abre conOPENGUI:<id>y se cierra conCLOSEGUI, condicionado porstate.activeGui— mientras un modal está abierto, el input de sala deja de responder. - rect — los límites propios del panel, sobre todo para el backdrop/debug.
- backdrop — un oscurecido opcional de pantalla completa pintado antes de los widgets (usado por los modales).
- widgets — la lista ordenada descrita abajo. El orden importa tanto para pintar (los
widgets posteriores se pintan encima) como para el hit-test (
hitTest()recorre la lista al revés, así que gana el widget más "arriba" bajo el cursor).
Tipos de widget#
Cualquier widget puede llevar un conjunto compartido de propiedades sin importar el tipo:
visibilidad (isVisible), estado habilitado (isEnabled), relleno (bgColor/bgOpacity),
color/fuente/alineación de texto, y una lista ordenada onClickAction de tokens de efecto.
isLocked es contabilidad solo-editor — el runtime puro la ignora. Los tipos:
- panel — un rect o círculo de color, normalmente colocado primero (detrás de todo) como fondo o como oscurecido de un modal. Puede llevar un sprite overlay y una animación enlazada opcional.
- label — texto que sale de hasta cuatro fuentes, resueltas cuadro a cuadro por orden de
prioridad: un mensaje condicional (
condMsgs— una lista de filas{condición, mensaje}; gana la primera fila cuya condición se cumple, con el mismo picker de condición que las reglas de puzzle y hotspot), luego un valor enlazado (binda una ruta de estado comoparty.countosettings.musicVol), luego un ticker auto-rotante (una lista de mensajes que ciclan solos contra el reloj FX —secspor mensaje,gapSecsen blanco entre ellos, y una entrada en blanco es un beat de pausa válido; sin reglas ni timers), y finalmente el text estático, que también hace de placeholder cuando nada de lo anterior aplica. El ticker y los mensajes condicionales se autoran monolingües como todo contenido, cada uno respaldado por su propio lid para el Editor de Traducción. - sentence — la línea-frase estilo SCUMM ("Usar llave con puerta…"). Su contenido no se
autora acá para nada — lo calcula cuadro a cuadro
core/guis/sentence.jsa partir del blanco bajo el mouse y el ítem armado. Su visibilidad sigue para qué sirve la línea: con un modal abierto (el inventario, por ejemplo) se mantiene legible encima del atenuado del modal, porque ahí es el feedback del ítem que estás por elegir; mientras corre un diálogo se esconde junto con los botones de verbo, porque la lista de opciones toma esa franja y la sentence sólo estaría mostrando lo que hacías antes de que arrancara la conversación. - button — el control de propósito general, especializado según qué ref lleve (ver abajo).
- sprite — una imagen, con animaciones de cuadros inline opcionales (
anims) para que un build final nunca necesite listar una carpeta para animarla. - inventory — una grilla
cols × rowsque se expande en una celda hoja por slot al momento del layout (autoFitderiva el tamaño del slot desde el rect del widget); las celdas reflejanstate.inventoryen vivo y arman/desarman/combinan ítems al clic. Dibujo de la celda elige cómo se pinta un slot: iconos (por defecto) o nombres en texto, el look SCUMM v5 que usa la barraclassic9legacy. Cambia sólo el dibujo — las mismas celdas, los mismos objetivos de clic, el mismo estado armado. En el pintor de iconos, Mostrar nombres bajo el icono enciende o apaga el rótulo, así puedes tener una grilla sólo de iconos; viene encendido salvo que digas lo contrario. El toggle no se ofrece en el pintor de texto, donde el nombre es la celda. - list — filas de texto +
onClickAction, usado para cosas como una lista de opciones de diálogo reutilizada como GUI. - textbox — un campo de texto engine-bound (
bind,placeholder,maxLength); usado para el nombre de guardado en la pantalla de Guardar. El foco y la escritura los enrutashell/main.js(state.guiFocus), no el widget en sí. - slider — un control
min/max/stepengine-bound atado a una ruta de estado (usado en los sliders de volumen/brillo/contraste de la pantalla de Opciones); el manejo de arrastre vive enshell/main.js. - saveSlots — se expande en
Nceldas de slot de guardado al momento del layout; el contenido mostrado viene depersistence.listSlots()al momento de dibujar, no del descriptor.
Tipos de botón#
El comportamiento de un widget button viene de qué campo de referencia lleve — el desplegable
kind del editor cambia entre ellos y limpia los demás, así que solo aplica uno a la vez:
- verbRef — resuelve label/sprite desde el registro
VERBS; acción por defectoSELECTVERB:<id>. Así se arma la barra clásica de 9 verbos — nueve botones, cada uno converbRefapuntando a un verbo. - charRef — resuelve name/sprite desde el registro
CHARACTERS; acción por defectoSWITCHCHAR:<id>. - partyRef — un toggle del roster de grupo (agrega/quita un personaje del party activo);
acción por defecto
PARTYTOGGLE:<id>. Si el personaje que eliges no está en el pool del proyecto (Hub Config → Proyecto), el editor avisa ahí mismo — un botón que ofrece a alguien que el pool nunca retiene al arrancar significa que puede aparecer en pantalla antes de que nadie lo elija. - partySlotRef — un botón de slot de party committeado. No lleva ningún id de personaje
fijo — en runtime se resuelve a
chosenOrder(state)[N-1](el N-ésimo personaje en el orden de selección actual) y disparaSWITCHPARTYSLOT:<N>.computeLayout()es puro y no puede resolver "quién está en el slot 2 ahora mismo", así que esa resolución pasa al momento de dibujar/clickear en el shell. - toggleRef — invierte un flag arbitrario del juego; acción por defecto
TOGGLEFLAG:<flag>. El campo de flag es un input de texto libre con autocompletado (los flags son un namespace abierto que define el autor, así que no puede ser un picker cerrado), que lista cada flag ya usado en el proyecto más los flags reservados del motor — nombres que el motor mantiene solo, que ningún barrido del proyecto puede encontrar y que si no habría que saber de memoria. - scrollRef — paginado de inventario
'up'/'down'. Su acción está bloqueada (INVENTORY_UP/INVENTORY_DOWN) — el editor no te deja sobrescribirla. - plain — sin ninguna ref: label libre más los tokens
onClickActionque agregues con el widget compartido de tokens de efecto (el mismo que usan las reacciones de hotspot, las opciones de diálogo y las cinemáticas — cada argumento que porta un ID es un picker tipado, nunca texto libre).
Layout y posicionamiento#
Las coordenadas de un widget son píxeles lógicos absolutos a la resolución de canvas del
proyecto (1920×1080 por defecto), no porcentajes — core/guis/constants.js guarda la geometría
compartida (tope del panel, tamaño de celda de la grilla de verbos, tamaño de slot de
inventario…) que leen tanto las plantillas integradas como el renderer. Un proyecto en una
resolución distinta a la por defecto la aplica una vez al boot con applyCanvasResolution(), que
mueve esas constantes y reconstruye los cuatro modales del motor (Opciones, Guardar/Cargar,
confirmación de salida, inventario) centrados sobre el canvas real — el editor los surface de la
misma forma, así que materializar uno nunca escribe un widget fuera del borde de tu juego. La barra
recibe el mismo trato por otro camino: cada barra que shippea el motor declara el canvas para el que
fue dibujada (designResolution) y se escala al tuyo con un único factor uniforme, después se
dockea abajo — así un solo descriptor classic9legacy autorado a 320×200 es también una barra de
1280 de ancho a 1280×1024. Un descriptor que autoraste tú queda exactamente como lo dibujaste,
salvo que lleve su propio designResolution — ese campo es cómo una plantilla materializada sigue
escalando después de volverse contenido tuyo. También sirve en tus modales, donde significa
"re-céntrame en el canvas real" (y encógeme sólo si no entro): un diálogo centrado para un canvas de
1920 no es demasiado grande para uno de 1280, está en el lugar equivocado, y esa diferencia sólo la
puede ver el canvas declarado. Todo lo demás lo reposicionas a mano acá.
computeLayout() toma un descriptor y devuelve una lista plana de nodos hoja (cada uno con un
rect resuelto) — los widgets contenedor como inventory, list y saveSlots se expanden en
una hoja por celda/fila/slot con un id compuesto (${parentId}.${index}). hitTest() recorre esa
lista plana de atrás hacia adelante y devuelve el primer rect que contiene el punto de clic,
saltando lo invisible o deshabilitado.
Los labels de botones y verbos se autoajustan a su celda de la misma manera: el fontSize de un
widget es un literal en el renderer, así que a diferencia de la geometría de arriba no escala solo
— un label sin escalar en un canvas chico puede imprimirse más ancho que la celda que lo contiene
(la barra clásica de 9 verbos a 640×400 solía leerse "DARGARRUSAR"). fitFontSize() encoge un
label hasta que entra en su caja en ambos ejes, y elipsiza en vez de encoger más allá de un piso
legible; un label que ya entra queda en su tamaño autorado, así que un proyecto que ya se ve bien
no cambia. Las celdas de verbo se ajustan como grupo, no cada una por su cuenta — la barra
toma el tamaño más chico que entra en todas las celdas que comparten un tamaño base, así que nunca
terminas con un verbo legible a 22px al lado de otro apretado a 15px; una celda a la que un autor
le puso un fontSize deliberadamente distinto queda afuera de ese grupo. La línea de sentence (lo
que el jugador está por hacer) recibe el mismo ajuste de caja, medido contra la altura de línea de
la propia fuente en vez de la tinta real, así que su tamaño no cambia cada vez que cambia el largo
de la descripción del hotspot bajo el mouse.
Conectar widgets a la lógica del juego#
El comportamiento de cualquier widget clickeable es un arreglo ordenado onClickAction con los
mismos tokens de efecto que se usan en todo el motor (SETFLAG, GIVEITEM:<item>|<char>,
SAY…), ejecutados por applyEffects(). El runtime de GUIs suma algunos propios: OPENGUI:<id>
/ CLOSEGUI abren y cierran un modal (condicionados por state.activeGui),
SELECTVERB/SWITCHCHAR/PARTYTOGGLE/SWITCHPARTYSLOT/TOGGLEFLAG son las acciones por
defecto que derivan automáticamente los tipos-ref de arriba, y SETLANG:es/SETLANG:en (usado
por la pantalla de Opciones) cambian el idioma activo. El onClickAction propio de un widget, si
está seteado, siempre gana sobre el default derivado de un tipo — el editor solo re-sincroniza el
default cuando la acción actual sigue siendo ese default, así que un override genuino nunca se
pisa.
Texto y localización#
El texto de un widget de GUI se autora monolingüe, en el defaultLocale del proyecto — la
misma regla de content-i18n que en todos lados. Acá no hay un objeto inline {es, en} que
llenar (algunos de los descriptores integrados del motor, como save.js y options.js, todavía
llevan mapas de idioma inline heredados de antes de esta migración — el editor los lee bien vía
L(), pero cualquier edición desde el campo de texto del editor los reescribe como un string
plano). Lo que pasa realmente al guardar:
- El literal que escribes se guarda en el descriptor como un string plano (la fuente de verdad
del
defaultLocale— a esto cae un locale si no existe traducción). - El editor también hace upsert de un lid derivado de la dirección en el
strings.jsondel proyecto —guiTextLid(guiId, widgetId)para eltextprincipal de un widget, y lids por propiedad equivalentes para placeholders, tooltips y texto de fila de lista. Los labels de botón-verbo son un caso especial: overlayean a través del propio lid del verbo (verb_<id>_label), así que traducir un verbo una vez cubre toda barra o coin que lo referencie. - En runtime,
_guiText()deshell/canvas_render.jsresuelve primero por ese lid (cayendo al literal, y luego aL()), así que el texto dibujado nunca es el string crudo del descriptor una vez que existe una traducción. - Esos lids aparecen bajo la categoría GUI en el Editor de Traducción, donde se completan los pares reales de español/inglés (o cualquier otro locale configurado) — nunca inline, acá.
Los sprites de widget de GUI siguen la misma idea de overlay para el arte: se prefiere una
variante de imagen por-locale (<base>.<lang>.<ext>) sobre el PNG base cuando existe, para
imágenes que hornean texto en los píxeles.
Estados de un botón#
Un botón puede verse distinto según qué esté haciendo el jugador con él: en reposo, con el cursor encima, apretado, o seleccionado (el verbo elegido, el personaje que estás manejando, un miembro del grupo, un toggle encendido). Hay dos cosas que puedes cambiar por estado, y se suman:
- Arte — un PNG por estado. Completa solo los que necesites: un estado que dejes vacío cae a la imagen por defecto, así que un botón con nada más que su sprite base es perfectamente válido.
- Color — un relleno de fondo y un color de texto por estado, en colores por estado dentro del panel de propiedades. Misma regla: vacío significa "usa el color base".
El botón auto al lado de un color de hover o de clicked deriva ese tono del color base — más
claro para hover, más oscuro para apretado — y lo escribe en el campo, donde después lo puedes
ajustar a mano. Necesita que el color base sea un valor hex (#rrggbb) para tener de dónde
derivarlo.
El canvas del editor es para autorar, así que siempre dibuja los botones en reposo. El 👁 que está junto a cada estado dibuja el widget seleccionado en ese estado — arte y colores juntos — para que puedas revisar cómo queda un hover sin lanzar el juego. Clic de nuevo y vuelve a la normalidad.
El estado selected solo existe en los botones que realmente pueden estar seleccionados: verbo, personaje, grupo y toggle de flag. Un botón plain nunca entra en él, así que esos campos no aparecen en uno.
Un widget de inventario toma los mismos colores, autorados una sola vez para toda la grilla: después cada celda elige su propio estado según por dónde ande el jugador — con el cursor encima es la celda que está debajo, apretada es la que está sosteniendo, y selected es la que tiene el ítem que levantó y todavía no usó. En el pintor de celda nombres de texto los colores son toda la historia, porque ese modo no tiene caja que teñir: el nombre mismo cambia de color en cada estado.
Animaciones#
Un widget (sprite, button o panel) puede enlazar una animación autorada en el Editor de
Animaciones bajo su propia categoría de GUI (assets/guis/<guiId>/animations/<animName>/,
registrada en animations.json). El cuadro actual de la animación enlazada sobrescribe el PNG
estático del widget al dibujar; quitar el enlace cae de vuelta al sprite estático. El toggle
Animate de la barra de herramientas reproduce en vivo cada animación enlazada en el canvas
del editor para que puedas revisar el timing sin salir de la herramienta.
Flujo de trabajo#
- New GUI… y elige un id, un kind (modal / panel / coin), y una plantilla de
partida — vacía, o un clon de una integrada (
classic9la barra de 9 verbos,modern4,coinHud, oclassic9legacy, los mismos nueve verbos sin nada de cromo: puro texto, dibujada para 320×200 y escalada desde ahí). Un kind panel se convierte en la barra HUD del proyecto; un kind modal recibe un backdrop paraOPENGUI; un kind coin materializa el verb-coin radial como contenido editable. - + Add widget… y arrástralo/redimensiónalo directamente en el canvas; Snap alinea los arrastres a una grilla de 4px.
- Fija los campos específicos del tipo en el panel derecho — un
verbRef/charRef/partySlotRef/toggleRefpara un botón,bindpara un label/textbox/slider,cols/rows/autoFitpara una grilla de inventario. - Conecta los clics con la lista compartida de tokens onClickAction — el mismo widget de picker tipado que usan las reacciones de hotspot y las opciones de diálogo.
- Autora el text del widget una sola vez, en el idioma por defecto de tu proyecto; las traducciones se completan después en el Editor de Traducción.
- Enlaza una animación desde la categoría GUI del Editor de Animaciones si el widget necesita reproducir cuadros.
- Save para escribir el
guis.jsondel proyecto.
Borrar una GUI#
Un solo botón en la barra de herramientas maneja quitar una GUI, y lee el descriptor cargado en ese momento para decidir cuál de dos cosas muy distintas hace:
- Sobre una de las cinco ranuras por defecto del motor (la barra principal, el
verb-coin, y los modales de inventario/guardar/opciones) dice Revertir al default:
sacarla del
guis.jsonno la saca del juego — esas cinco se reconstruyen solas desde las plantillas propias del motor en cuanto la clave no está, así que la barra o el modal nunca desaparece, solo se pierden tus cambios guardados sobre esa versión (y esa pérdida no se puede deshacer). - Sobre un modal propio tuyo dice Borrar GUI, en rojo, porque no hay ningún default
detrás: borrarla es permanente, y cualquier
OPENGUI:<id>que la siga apuntando queda colgando (el token es null-safe — no hace nada en silencio —, pero el diálogo de confirmación lista exactamente qué reacciones, vigías o botones de GUI todavía la nombran, así los puedes arreglar antes en vez de descubrirlo jugando). - Sobre una entrada virtual — un default que se ofrece para editar, o una GUI nueva que todavía no guardaste — el botón está deshabilitado: no hay nada en disco que sacar todavía. Guárdala una vez y se habilita.
Guardar una barra principal puede ofrecer mover el área jugable#
roomViewport en project.json es dónde termina el área de sala: la franja arriba de la barra
en la que se dibujan las salas. El wizard la deja en el borde superior de la barra al crear el
proyecto, pero después nadie las mantenía en paso: mueves o materializas una barra principal y el
área jugable del proyecto sigue describiendo la anterior, mientras el runtime le cree al archivo del
proyecto. O la sala se dibuja debajo de la barra y pierde su banda inferior, o queda una franja que
no es de nadie: la sala no la puede usar y la barra no la cubre.
Así que al guardar un panel (una barra principal) se compara el borde superior de la barra con el
área jugable, y si difieren por más de un par de píxeles se ofrece alinearlas, diciendo los números:
esta barra empieza en y=740, pero el área de sala llega hasta 1024: la barra tapa los últimos 284 px
de cada sala. Es una oferta, nunca automático: dejarla como está es lo que pasa por defecto, y el
chequeo corre después de que el GUI ya se guardó, así que rechazarla no cuesta nada. Aceptar
parchea sólo el alto de roomViewport — un x/y/ancho autorado queda intacto — y una vez que
coinciden, la oferta deja de aparecer.
Los ids de widget son load-bearing para
partySlotRefy cualquier otro campo ref — pero eliden sí es solo una etiqueta que el widget porta, no una clave de contenido estable como un id de hotspot o de ítem. Los valores que de verdad se referencian en otros lados son los campos ref: el número de slot departySlotRefse resuelve en runtime contrachosenOrder(state), no contra ningún personaje fijo — renombra el roster de personajes y el botón sigue funcionando. El único id que sí es load-bearing es el id propio de la GUI, ya que tantoOPENGUI:<id>como la resolución de la barra principal dependen directamente de él; renombrar una GUI después de que otro contenido ya la abre conOPENGUI:<idViejo>rompe ese enlace en silencio (el token es null-safe — simplemente no hace nada — así que la falla es silenciosa, no un crash).