UI de juego con Unity UI Toolkit
Estructura en UXML, apariencia en USS, comportamiento en pequeños controladores C# que reaccionan a eventos del juego. Destilado de un proyecto real (HUD de un juego de terror-disparos); la lista de solución de problemas es la parte que probablemente ahorrará más tiempo.
1. Antes de escribir código
Pregunta solo por lo que falte, en un lote breve:
- API de UI.
[RequireComponent(typeof(PanelRenderer))]+RegisterUIReloadCallback= flujo de trabajo con PanelRenderer (sinUIDocument, sinrootVisualElement).UIDocument+rootVisualElement= clásico. Infierelo del código pegado; si no está claro, pide un controlador funcional y su UXML. Nunca mezcles los dos (provoca NullReferenceExceptions). Un archivo funcional del usuario es mejor que tu memoria de la API. - Género/tema (con capturas de pantalla o paleta), plataforma y entrada, resolución/aspecto de referencia.
- Los scripts del juego a los que la UI escucha. Pídelos, lee los eventos/propiedades públicos reales, vincula a esos. Si falta un evento, propón la adición más pequeña al script del juego.
- Responde en el idioma del usuario; escribe comentarios de código en el idioma que usen los scripts existentes.
2. Primero la guía de estilo
La consistencia es lo que los usuarios notan primero. Antes de construir muchas partes: propón una guía breve, construye UNA parte pequeña, deja que el usuario reaccione y convierte sus comentarios en reglas ("sin fondo de caja", "más compacto"). Guárdala en el proyecto (ej. Assets/UI/UI_STYLE_GUIDE.md) y dile al usuario que la pegue en futuras solicitudes de UI. Actualízala cuando cambien partes o reglas. Contenido: lenguaje de diseño (tono, contenedores, densidad, movimiento), tabla de paleta (texto, atenuado, advertencia, crítico, sombra), tipografía (tamaños por rol, regla de sombra), diseño (zonas, márgenes, configuración del panel), convenciones de nomenclatura de clases USS, reglas de arquitectura de código, errores comunes de UXML, lista de partes existentes, lista de verificación para una nueva parte.
3. Arquitectura
- Una parte = un controlador + un UXML + un USS, con una fuente de datos. Sin mega-controlador.
- Las partes que deben estar juntas comparten un UXML/contenedor flex (dos controladores pueden compartir un GameObject/PanelRenderer; cada uno encuentra sus propios elementos por nombre).
- Los controladores solo encuentran elementos, establecen texto/valores y alternan clases USS. Los colores, tamaños y animaciones viven en USS.
- PanelRenderer: la raíz llega a través de
RegisterUIReloadCallback(OnUIReload)(registrar en OnEnable, anular en OnDisable). Se dispara cuando la UI está lista y en cada recarga, así que todas las búsquedas deQ<T>("nombre"), almacenamiento en caché, enlace de eventos y restablecimiento de estado van allí. ProtegeUpdate()con nulo hasta que los elementos existan. - Los eventos del juego pueden dispararse antes de que exista la UI: almacena en caché los últimos valores en campos, re-aplica al final de
OnUIReload. - Los objetos del jugador/juego pueden aparecer tarde: referencia serializada, si no
FindFirstObjectByTypecon limitación (~0.5 s), luego enlaza; cancela la suscripción en OnDisable. - Empuja, no sondees. Suscríbete a eventos y escribe solo al cambiar. Sondea solo valores sin evento (resistencia continua, un bool de recarga), alternando clases al cambiar. Sin construcción de cadenas por fotograma.
- Construye elementos repetidos (pipetas, segmentos) en código una vez; reconstruye solo cuando su fuente cambie; limpia las listas en caché al recargar.
- Temporizadores/pulsos:
element.schedule.Execute(...).Every(ms)se crea en pausa, se reanuda solo mientras sea necesario. Destellos de un solo disparo:.StartingIn(ms). - Elementos HUD:
picking-mode="Ignore"en todas partes, sin controles enfocables. Los menús son la excepción. - Nombre del archivo = nombre de la clase MonoBehaviour.
4. Diseño (fuente de la mayoría de los errores visuales)
- Nunca coloques partes relacionadas con compensaciones de píxeles adivinadas por separado: dos paneles absolutos superpuestos porque
bottomse estimó a ciegas. Ponlos en un contenedor flex y posiciona solo ese contenedor. - Un contenedor posicionado absolutamente por zona de pantalla; los hijos fluyen con flex y márgenes.
- Oculta con
opacity+ transición cuando el diseño no deba saltar;display:nonesolo cuando el espacio deba colapsarse. widthfija en etiquetas numéricas para que 9 a 10 a 100 no desplace a los vecinos.- Las cajas son border-box: ancho de fila de segmentos =
count x (ancho + margen) - margen. Alinea widgets relacionados por el mismo borde izquierdo y ancho calculado. - Configuración del panel: Scale With Screen Size (ej. 1920x1080, match 0.5); para HUDs desactiva Clear Color y Clear Depth Stencil. Usa el Sort Order del PanelRenderer para superponer partes (viñeta de pantalla completa debajo del texto).
5. USS y estado
- Estado = clase alternada desde C# (
EnableInClassList); animación = transición en USS. Nunca establezcas colores o animaciones en código. - Nombra los estados por significado:
--bajo,--critico,--oculto,--golpe,--pulso,--no-disponible. Prefiere BEM para partes nuevas. - Las reglas de anulación (segmentos gastados, destello) van DESPUÉS de las reglas que anulan; misma especificidad, gana la última.
- Una transición usa la duración/retardo del estado destino:
.x--oculto { opacity:0; transition-duration:.5s; transition-delay:.9s; }se desvanece tarde y entra al instante;.x--destello { transition-duration:.05s; }se activa rápido, se desvanece a velocidad base. - Texto flotante sin fondo necesita
text-shadow. Sintext-transform(mayúsculas en C#); sin degradados radiales (ver viñeta). - Para UI reeskinizable coloca colores/tamaños en variables USS en una clase raíz (
--c-texto, usado comovar(--c-texto)); verifica que la versión de Unity del usuario lo soporte.
6. Esqueleto del controlador (PanelRenderer)
[RequireComponent(typeof(PanelRenderer))]
public class ControladorParte : MonoBehaviour
{
[SerializeField] private ComponenteFuente fuente; // vacío = buscar en tiempo de ejecución
private PanelRenderer _pr; private VisualElement _bloque; private Label _valor;
private bool _enlazado; private float _v = -1f; private int _mostrado = -1; private float _siguienteBusqueda;
private void Awake() => _pr = GetComponent<PanelRenderer>();
private void OnEnable() { _pr.RegisterUIReloadCallback(OnUIRecarga); IntentarEnlazar(); }
private void OnDisable() { _pr.UnregisterUIReloadCallback(OnUIRecarga); Desenlazar(); _bloque = null; _valor = null; }
private void Update()
{
if (_enlazado || Time.unscaledTime < _siguienteBusqueda) return;
_siguienteBusqueda = Time.unscaledTime + 0.5f; IntentarEnlazar();
}
private void IntentarEnlazar()
{
if (_enlazado) return;
if (fuente == null) fuente = FindFirstObjectByType<ComponenteFuente>();
if (fuente == null) return;
fuente.AlCambiar += AlCambiar; _enlazado = true;
_v = fuente.Actual; Aplicar(); // obtener el estado actual una vez
}
private void Desenlazar() { if (_enlazado && fuente != null) fuente.AlCambiar -= AlCambiar; _enlazado = false; }
private void OnUIRecarga(PanelRenderer r, VisualElement raiz)
{
_bloque = raiz.Q<VisualElement>("parte"); _valor = raiz.Q<Label>("parte-valor");
if (_bloque == null || _valor == null) { Debug.LogError("Parte: elementos no encontrados en UXML (revisar Source Asset).", this); _bloque = null; return; }
_mostrado = -1; Aplicar(); // nuevo árbol: forzar reescritura desde caché
}
private void AlCambiar(float v) { _v = v; Aplicar(); }
private void Aplicar()
{
if (_bloque == null || _v < 0f) return;
int n = Mathf.CeilToInt(_v);
if (n != _mostrado) { _mostrado = n; _valor.text = n.ToString(); } // escribir solo al cambiar
_bloque.EnableInClassList("parte--bajo", _v <= 0.3f);
}
}
7. Fragmentos reutilizables
- Segmentos/pipetas: en
OnUIRecarga,fila.Clear(); lista.Clear();luego agrega NVisualElement{pickingMode=Ignore}con claseseg; al cambiarlista[i].EnableInClassList("seg--gastado", i >= lleno). Muestra al menos 1 lleno mientras valor > 0:lleno = v<=0 ? 0 : Clamp(CeilToInt(v*N-0.001f),1,N). - Pulso:
_pulso = _bloque.schedule.Execute(() => _bloque.ToggleInClassList("parte--pulso")).Every(400); _pulso.Pause();Reanudar al entrar en el estado, Pausar + eliminar clase al salir; rastrea un bool_pulsandoy restablécelo al recargar. - Destello en evento:
var b=_bloque; b.AddToClassList("parte--golpe"); b.schedule.Execute(()=>b.RemoveFromClassList("parte--golpe")).StartingIn(150); - Viñeta: genera una vez en Awake un
Texture2Dde 128x128 (RGBA32, Clamp, Bilinear,HideFlags.HideAndDontSave) donde alfa =SmoothStep(0,1,InverseLerp(interior,exterior,dist/1.4142))desde el centro, RGB blanco; establece_el.style.backgroundImage = new StyleBackground(tex)en OnUIRecarga; Destruir en OnDestroy. USS: elemento absoluto de pantalla completa,opacity:0,-unity-background-scale-mode: stretch-to-fill,-unity-background-image-tint-colorpara el color, clases--bajo/--critico/--pulso/--destelloestableciendo opacidad (destello último, duración rápida). Sort Order más bajo que las partes de texto.
8. Géneros y plataformas
La arquitectura es idéntica en todos los géneros; la guía de estilo y el modelo de entrada cambian. Arcade: números grandes y gruesos, cuenta ascendente, pops de escala, brillo de combo, transiciones contundentes de 0.08-0.25 s, los contenedores son bienvenidos. Retro/píxel: fuente de píxeles, escalado entero, texturas con filtro Point, bordes de 9 cortes de sprite. Móvil: objetivos táctiles de aproximadamente 44-48 dp, área segura del dispositivo como relleno de la raíz (verifica la API actual para la versión de Unity del usuario), sin estados hover, fuente mínima más grande, probar en un dispositivo real. Consola/gamepad: elementos enfocables con un estilo :focus fuerte, foco inicial explícito, márgenes seguros para TV (3-5 por ciento), texto más grande, glifos que cambian según el dispositivo activo. VR/espacio mundial: depende de la versión de Unity, búscalo primero. Estos puntos de plataforma son una buena práctica general pero no probados en un proyecto real: díselo y pide al usuario que verifique en el dispositivo. Mantén las etiquetas de entrada (nombre de tecla o glifo) en datos que el controlador lea, no codificados en UXML.
9. Entrega de una parte
En orden: UXML completo, USS completo, controlador completo, pasos de configuración (GameObject, PanelRenderer + Panel Settings compartido + Source Asset, Sort Order, referencias a rellenar, qué ver en Play), perillas de ajuste (qué valor de USS cambia tamaño/posición/color/umbrales) y una entrada breve en la guía de estilo. Al cambiar archivos existentes, proporciona archivos completos si la mayor parte del archivo cambia (los pegado parciales causaron errores reales).
10. Solución de problemas
| Síntoma | Causa / solución |
|---|---|
NullReference en rootVisualElement / GetComponent<UIDocument>() | El código usa UIDocument pero el objeto tiene PanelRenderer. Usa RegisterUIReloadCallback, enlaza en OnUIReload |
| El controlador registra "elemento no encontrado" | Source Asset incorrecto, nombre no coincide con Q<>(), o UXML inválido |
| UI Builder "Failed to open asset" | UXML inválido (duplicado </ui:UXML>, <Style> incorrecto, GUID de marcador de posición, caracteres ocultos). Arregla, luego haz clic derecho en el archivo > Reimport. Lee el mensaje de la consola encima del error genérico |
| Elementos sin estilo | <Style> faltante o ruta incorrecta. En UI Builder: StyleSheets > + > Add Existing USS (escribe project://database/...guid=... completo) |
| Todo desapareció después de una edición | UXML se rompió, o la referencia a Source Asset quedó obsoleta después de un renombrado. Arregla, Reimport, reasigna. Prueba con background-color: rgba(255,0,0,.3) en la raíz |
| Parte mal colocada o superponiéndose a otra | GameObject obsoleto que sigue dibujando UXML antiguo, o desplazamientos absolutos separados. Elimina el objeto antiguo, fusiona vecinos en un contenedor |
| Texto de marcador de posición en la vista Game | La vista Edit dibuja el UXML; presiona Play |
| No se puede agregar el script | Nombre de archivo != nombre de clase. Renombra dentro de la ventana Project de Unity para que .meta lo siga |
Correcciones no válidas: la etiqueta de hoja de estilo es <Style src="..."/> SIN el prefijo ui: (<ui:Style> se ignora silenciosamente); nunca inventes o dejes un GUID de marcador de posición; usa el encabezado estándar <ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="False">.
11. Trabajar sin ver el editor
Pide una captura de pantalla en quejas de diseño y léela detenidamente; resolvió errores que la adivinanza no podía. Reconoce tus errores brevemente y arréglalos con archivos completos. Para errores de compilación, pide el texto exacto y la versión de Unity en lugar de adivinar de nuevo. No prometas comportamiento o rendimiento en el dispositivo; construye la estructura y pide al usuario que pruebe.