Input y focus
Hit-testing, enfocabilidad, navegación direccional, teclado y mando — todo lo lleva el SDK, se deriva del layout y no se autora en ninguna parte.
Esta página va de qué pasa cuando un dedo, un ratón o una cruceta se encuentran con la UI. Abre el diario de misiones con un mando: uno de los botones de seguir ya está resaltado, pulsar abajo mueve el resalte a la fila siguiente y pulsar A lo acciona. Nada de eso se autoró. No hay ningún mapa de foco en el envelope —el sobre que el juego descarga—, ninguna lista que diga qué control está al lado de cuál y ningún orden de tabulación: el SDK lo deduce de dónde han caído realmente las cajas en pantalla.
Ese es el hilo conductor: como el SDK calcula la geometría, es también dueño del hit-testing (a qué nodo pertenece un punto), del foco (qué control es ahora mismo el destino del teclado y del mando) y de la navegación direccional (a qué control lleva una dirección). Una UI se navega con mando por lo que es, no porque alguien la cableara.
El hit-testing
El hit-testing responde a una sola pregunta: el jugador ha tocado este píxel, ¿de quién es? La respuesta tiene que ser la misma en todos los targets, así que se define contra el layout y no contra lo que se pintó.
La región de entrada de un nodo es su rect de layout. No su geometría pintada, y por eso el formato mantiene el invariante de que nada pinta fuera de su rect: si no, las dos cosas no coincidirían, y esa discrepancia sería invisible.
El orden de resolución de un punto:
- La capa de overlays, de arriba abajo. Los overlays se prueban antes que
el árbol, en orden inverso de capa. Un overlay
modalcaptura el punto: todo lo que hay debajo —incluidos los overlays inferiores— queda inalcanzable, que es lo que hace que un backdrop sea un backdrop. Un overlay no modal solo se queda el punto si cae sobre alguno de sus hijos. - El árbol, primero el nodo más profundo, y entre hermanos ganan los posteriores a los anteriores, ya que pintan los últimos.
El recorte corta la entrada. El recorte efectivo de un nodo es la
intersección del de todos sus ancestros, y un punto fuera de él poda el subárbol
entero: una fila que ha salido scrolleando de un
ScrollView ni se
dibuja ni es pulsable. El desbordamiento que no está recortado sigue siendo
alcanzable: solo un recorte corta la entrada, exactamente igual que solo un
recorte corta el pintado.
El radio de las esquinas se respeta. Las esquinas redondeadas de un recorte cortan también la región de entrada, así que un toque en la esquina, fuera de un panel redondeado, se cuela hasta lo que hay detrás.
Una vez alcanzado un nodo, el evento se atribuye al ancestro enfocable más
cercano: un toque sobre el Text que hay dentro de un Button es un toque en
el Button.
Los gestos cancelados
A un jugador que empieza a pulsar Comprar y se ve interrumpido no se le puede cobrar la espada. Todos los SDK tienen que estar de acuerdo en eso, así que lo que hace un gesto que se corta es parte del contrato y no la suposición de cada target.
normativeUn puntero no siempre acaba en una soltada. Un toque puede interrumpirlo el sistema, un gesto del navegador puede llevarse el puntero, un dispositivo puede desconectarse a media pulsación. Todos los gestos en vuelo terminan, y ninguno concluye:
| En vuelo | Al cancelar |
|---|---|
Una pulsación sobre un Button o un Toggle | Se suelta sin activar — ni acción, ni movimiento de valor. |
Un arrastre sobre un ScrollView | Se para; el offset al que llegó se queda. |
Un arrastre de selección en un TextInput | Se para; la selección a la que llegó se queda. |
| Una pulsación sobre el backdrop de un modal | No se cierra nada. |
Un arrastre de Slider | Se asienta: se dispara onCommit. |
El Slider es la única excepción porque su valor ya está en pantalla y se
escribió en su ruta bindeada en cada movimiento: negarle el commit dejaría al
juego sin el evento de «aplica lo caro» para un valor que el jugador dejó ahí de
verdad. Los demás no habían producido nada todavía, así que producirlo ahora
sería inventarse una intención.
Esta es la misma regla que la de una pulsación soltada fuera de su control, y se
aplica al mando: un mando desconectado a media pulsación la cancela, y un
Slider que estuviera moviendo se asienta.
La enfocabilidad
Enfocable significa que el teclado y el mando pueden aterrizar ahí: un control que el jugador puede alcanzar y accionar. No es algo que se encienda: sale de lo que el nodo es, así que el mismo tipo de nodo es enfocable en cualquier UI y en cualquier target. Eso es lo que hace navegable una pantalla sin que nadie la audite.
La enfocabilidad se deriva de la identidad del componente. No hay una prop
focusable:
normativeEnfocabilidad por tipo de nodo
| Tipo de nodo | Enfocable |
|---|---|
| Button | Sí |
| Toggle | Sí |
| Slider | Sí |
| TextInput | Sí |
| Collapse | Lo es su cabecera (children[0]); el Collapse en sí, no. |
| Container, Text, Image | No |
| ScrollView | No — scrollear es un gesto, no la interacción propia de un control. |
| Overlay | No — es una capa, y los candidatos son sus hijos. |
| Repeat, ProgressBar, Spinner | No |
El hover enciende exactamente ese mismo conjunto. Lo que acepta entrada es
lo que puede verse distinto bajo el puntero, así que un puntero sobre un
Container a secas no pone nada en hover, y un ratón y un mando ven el mismo
conjunto de controles vivos. Esto es lo que permite que un tooltip disparado por
hover llegue a un mando sin un segundo mecanismo: en un mando, el foco es el
hover.
disabled
Apagar una sección de la UI —una fila de tienda que el jugador no puede pagar,
un grupo de ajustes que necesita antes una casilla— tiene que apagar todo lo que
lleva dentro, y tiene que hacerlo igual en todas partes. Así que disabled se
escribe aquí entero: una prop, y todo lo que se sigue de ella.
disabled es la única excepción a la regla de arriba: un nodo que lo lleva
no es enfocable, sea del tipo que sea.
{ "type": "Container", "disabled": { "bind": "settings.custom" }, "children": [] }
Se hereda. El valor efectivo de un nodo es su propio disabled o el de
cualquier ancestro, igual que un recorte es la intersección del de todos sus
ancestros. Una sola prop en una sección apaga por tanto el formulario que lleva
dentro —que es el caso para el que existe— y un
Overlay reinicia la
cadena, porque la entrada de una capa es la cima de su propio ámbito de entrada:
un modal declarado dentro de un panel deshabilitado sigue siendo operable, y
sigue pudiéndose cerrar.
Todo lo que el modelo de interacción hace con él se sigue de estar fuera del conjunto de enfocables:
- Ni foco, ni hover. No es candidato de navegación, así que las flechas y la cruceta pasan de largo, y el puntero no enciende nada.
- Ni pulsación, ni acción. Ni un toque, ni Enter, ni la A del mando lo
activan, así que no se dispara ninguna named action y no se mueve ningún
valor. Una pulsación que caiga encima se cuela hasta lo que haya detrás:
un
Buttonmuerto dentro de unScrollViewno se traga el arrastre que scrollea la lista. - Lo que tuviera, lo suelta. Un control que el juego deshabilita mientras
tiene el foco, el hover o un dedo encima pierde las tres cosas. El foco se va
a nada y no a un vecino —el jugador no ha pedido moverse— y un gesto de
Slideren vuelo se cancela, no se confirma: el valor nunca se asentó. - Una sección deshabilitada se sigue pudiendo leer. Scrollear no es una
interacción de la que sea dueño un control, así que un
ScrollViewque haya dentro sigue funcionando. Un jugador que no puede usar un panel tiene que poder leerlo igualmente. - El juego no se queda bloqueado. El
canal del host va fuera de banda, como un
SetDatasobre una ruta bindeada:SetValueySetScrollsiguen llegando a un nodo deshabilitado. Lo quedisableddescribe es lo que puede hacer el jugador.
Por sí solo no da estilo a nada. No hay ningún look atenuado de serie,
exactamente igual que con cualquier otro estado: el aspecto de un control
deshabilitado es states.disabled.style, y un nodo sin sobrescritura declarada
pinta igual que siempre. Ver
Style › Los estados.
El foco inicial
autofocus: true marca el nodo que toma el foco cuando se abre su ámbito. Gana
el primero en orden de documento.
Un popover es la excepción: se abre sobre su selección —la opción marcada
del grupo que lleva dentro, para que una lista de veinte idiomas se abra donde
el jugador la dejó—, y si no la hay cae al autofocus del subárbol, y después a
su primer enfocable. Un menú que el jugador ha abierto es un menú en el que
está, y uno que empezara sin foco no se podría recorrer con las flechas. La
lista además scrollea hasta donde sea que aterrice.
La navegación direccional
El jugador pulsa abajo en la cruceta. ¿Qué control se enciende a continuación? Todos los SDK tienen que responder eso igual, o la misma pantalla se recorrería distinto en una consola que en un móvil — así que la respuesta es una regla aritmética sobre los rectángulos, no una heurística que se invente cada target.
normativeEl foco se mueve por un eje cada vez —arriba, abajo, izquierda, derecha— y el
destino se calcula a partir de los rects de layout vivos, así que sobrevive
a un relayout, a un hot-update o a un Collapse que acaba de cambiar la forma
de la pantalla.
Para una dirección (dx, dy) y el nodo que tiene ahora el foco, sobre cada
candidato enfocable del ámbito actual:
- Toma el vector entre los centros de los dos rects:
(deltaX, deltaY). projection = deltaX·dx + deltaY·dy— cuánto queda el candidato en la dirección del movimiento. Un candidato conprojection <= 0.5se descarta: no está hacia ese lado.orthogonal = |deltaX·dy| + |deltaY·dx|— cuánto se sale del eje.score = projection + orthogonal · 2. Gana la puntuación más baja.
Ejemplo resuelto — bajar por el diario de misiones. El foco está en el botón
de seguir de la primera misión y el jugador pulsa abajo, así que (dx, dy) es
(0, 1). El botón de seguir de una fila más abajo tiene su centro 72 px por
debajo y perfectamente en línea: projection es 72, orthogonal es 0, puntúa
72. El de dos filas más abajo puntúa 144. Y un enlace de «ver todo» que está
40 px por debajo pero 200 px a la izquierda puntúa 40 + 200·2 = 440, así
que pierde contra la fila de abajo aunque físicamente esté más cerca.
Pesar el doble la distancia ortogonal es lo que hace que gane el control más cercano en esa dirección frente a uno algo más próximo pero que está sobre todo de lado: el comportamiento que un jugador espera de una UI de consola.
Sin ningún foco, una dirección toma el autofocus del ámbito, o su primer
enfocable.
Revelado automático. Cuando el foco aterriza dentro de un ScrollView, el
scroller lo trae a la vista. Y burbujea: cada scroller revela el hijo propio que
contiene el foco. Sin eso, la navegación se metería en filas que están fuera de
la vista, y un mando no tiene rueda con la que ir a buscarlas.
El ámbito de foco y la trampa
Un ámbito de foco es el conjunto de controles a los que se le permite llegar a una dirección. Importa sobre todo por un caso: cuando hay un diálogo de confirmación levantado, la cruceta no puede irse a los botones de detrás.
El ámbito es normalmente la view entera. Mientras hay un overlay modal
levantado, el ámbito es el subárbol de ese overlay: la navegación no puede salir
de él, ni puede alcanzar nada que haya detrás.
La trampa se deriva de modal: no hay un campo aparte para ella. Es la
misma propiedad que hace que el overlay capture la entrada del puntero, porque
son la misma afirmación: esto es lo único con lo que puedes interactuar ahora
mismo. Un overlay no modal —un aviso, un tooltip— no atrapa nada, y sus propios
botones son candidatos ordinarios del ámbito de la view.
Cerrar un overlay devuelve el foco a quien lo tuviera antes.
El foco en una lista virtualizada
Una lista larga no construye todas las filas, solo las que el viewport puede enseñar. Así que scrollear puede borrar justo el nodo que tiene el foco, y lo que pase entonces es la diferencia entre una lista que se siente sólida con un mando y otra que pierde el sitio del jugador. Es normativo porque los dos targets tienen que perderlo y recuperarlo idénticamente.
normativeUn Repeat solo
materializa las filas que su viewport puede enseñar, así que scrollear una lista
destruye la fila que tiene el foco. Eso es el
renderer reciclando un
nodo, no el jugador cediendo el foco, y las dos cosas no se pueden confundir: el
foco pasa a ser lógico y se recuerda como el elemento sobre el que estaba
— la lista, la identidad del elemento y en qué parte de la fila estaba.
Mientras la fila no está materializada:
- Nadie lleva el foco puesto. Ningún nodo pinta enfocado, y pulsar el botón de activación no hace nada: no hay ningún control en pantalla que activar.
- El foco no se regala. Nunca cae de vuelta al
autofocusde la view. Un foco que salta al otro extremo de la pantalla porque el jugador ha scrolleado una lista es un fallo, y la rueda, un arrastre y el stick derecho lo producen los tres. - La lista sigue scrolleando. El stick sigue moviendo el
ScrollViewen el que estaba el foco, así que el gesto que echó la fila de la vista no se corta a la mitad.
La fila recupera el foco cuando se vuelve a materializar, sobre el mismo
nodo — por identidad, así que con una key sigue al elemento a través de una
reordenación. Dos cosas terminan la espera en vez de eso: una dirección,
porque el jugador ha pedido moverse y no hay rect desde el que hacerlo, así que
el recorrido vuelve a empezar desde el autofocus del ámbito; y cualquier
decisión de foco real — un toque, un modal que se abre, el juego.
El teclado
Una UI de juego se juega con mando, pero se construye y se prueba con teclado, y los dos no pueden separarse. Cada tecla de abajo resuelve a una intención que el mando también produce, y por eso el mapeo es fijo y no por juego.
normativeTeclado
| Tecla | Efecto |
|---|---|
| Flechas | Mueven el foco, según el algoritmo de arriba. |
| Enter | Activa el nodo con el foco — acciona un Button, conmuta un Toggle, envía un TextInput. |
| Espacio | Pulsa el nodo con el foco, exactamente como Enter — se hunde al bajar la tecla, se activa al soltarla. Dentro de un TextInput es un carácter. |
| Escape | Petición de cierre al overlay modal más alto. |
| Teclas de texto | Editan el TextInput con el foco. |
Enter y Espacio son una sola intención, y es la A del mando: la pulsación
enciende el estado pressed al bajar y activa al subir, así que una soltada que
no llega —el foco se movió, el mando se desconectó— cancela en vez de disparar.
La repetición automática se ignora: mantener la tecla no activa dos veces.
Mientras un TextInput tiene el foco
Escribir en un campo y navegar por una pantalla quieren las mismas teclas, y un campo de texto tiene que ganar las que editan texto sin tragarse las que lo abandonan. El reparto de abajo es exacto por esa razón.
Un campo con el foco reclama las teclas que lo editan antes de que las vea nada de lo de arriba; lo que no reclama se cuela hasta el tratamiento ordinario.
normativeTeclas que reclama un TextInput con el foco
| Tecla | Efecto |
|---|---|
| ← → | Mueven el cursor un carácter; con una selección levantada, lo colapsan contra el borde al que se empujó, en vez de avanzar además. Con Shift, extienden la selección. Sin nada seleccionado y con el cursor ya en ese extremo, el campo devuelve la tecla y el foco lo abandona. |
| Inicio / Fin | Cursor al principio o al final de la línea. Con Shift, seleccionan hasta ahí. |
| Cmd/Ctrl + ← → | Igual que Inicio/Fin — y con el mismo comportamiento con Shift. |
| Cmd/Ctrl + A | Selecciona el valor entero. |
| Retroceso / Suprimir | Borran la selección cuando la hay; si no, un carácter, hacia atrás o hacia delante. |
| Enter | Dispara onSubmit. Nunca inserta un salto de línea: en v1 el campo es de una línea. |
| Espacio | Un carácter, así que no pulsa nada. |
| Tab | Se traga. Aquí la navegación es espacial, así que un Tab solo entregaría el teclado a lo que la página tenga a continuación y dejaría el campo con pinta de seguir teniendo el foco. |
| ↑ ↓ | No las reclama — un campo es de una línea, así que las flechas verticales navegan fuera de él como en cualquier otro sitio. |
| Cmd/Ctrl + C / X / V / Z | No se interceptan — las ejecuta el campo propio de la plataforma y la edición vuelve por su evento de entrada. |
Dos controles reclaman las flechas para sí, y lo hacen distinto a propósito:
- Un
Sliderse queda las flechas de su propio eje y no las devuelve nunca. Las del eje cruzado siguen navegando: recorrer un rango es para lo que están esas teclas mientras un slider tiene el foco. - Un
TextInputse queda las flechas para mover su cursor, pero las devuelve en los extremos: al final del texto, una pulsación más abandona el campo. Salir de una cadena larga a golpe de tecla no es un precio razonable, y un campo de texto no es sitio para quedarse atrapado.
La navegación es espacial, así que no hay ningún orden de tabulación que implementar.
El mando
El mando es una fuente de entrada más, no un segundo modelo de entrada. Todo lo que lee resuelve a una intención que el teclado ya produce, y los dos pasan por los mismos manejadores — que es lo que impide que «navegar con la cruceta» y «navegar con las flechas» se separen.
Que es también por lo que el mapeo es normativo: un jugador que pase del mando al teclado a mitad de sesión no puede encontrarse otra UI.
normativeMando
| Control | Intención |
|---|---|
| Cruceta / stick izquierdo | Una dirección unitaria — el movimiento propio de las flechas. |
| A | Pulsa/activa el nodo con el foco (Enter). Soltarla suelta la pulsación. |
| B | Petición de cierre al modal más alto (Escape). |
| Stick derecho | Scrollea el ScrollView en el que vive el foco. |
Valores de referencia, compartidos por todos los targets (gamepad.ts): zona
muerta de navegación 0.5 con soltada en 0.35 —el hueco es histéresis, ya que
un stick que descansa sobre el umbral no puede dispararse repetidamente sin
moverse—, zona muerta de scroll 0.15, repetición al mantener tras 400 ms a
un paso cada 90 ms, y una velocidad de scroll de 1100 px/s a plena
deflexión.
El gesto de cancelación del puntero se aplica también al mando: una pulsación que termina fuera del control, mando desconectado incluido, cancela en vez de activar.
De quién son las teclas
Una view rara vez está sola en pantalla. En el editor convive con barras de herramientas y paneles; en la web comparte página con HTML corriente. En toda esta sección el host es esa aplicación de alrededor —la página, el editor, la propia interfaz del motor—, que es cosa distinta del juego cuya UI es la view. Cuando el jugador pulsa Enter y las dos partes podrían reclamarlo con razón, esto es lo que decide.
normativeEl puntero está acotado a su propia superficie por construcción. El teclado y el mando no lo están —las teclas son un evento de página y el mando es un dispositivo de toda la página—, así que quién los lee son dos preguntas, y se hacen en este orden.
El foco propio del host va primero
Cada control propio del host —una barra de herramientas, un panel, un campo de texto— tiene derecho a las teclas mientras tenga el foco. El renderer lee una tecla solo cuando el foco del host está sobre la view misma, o sobre nada. Con cualquier otra cosa no solo se niega a actuar: tampoco puede consumir la tecla, porque suprimirla es justo lo que impediría al host convertir ese Enter en una pulsación del botón que tiene el foco.
«Sobre la view misma» cubre dos cosas: la superficie sobre la que dibuja la
view, y el elemento editable a través del que escribe la plataforma en los
targets que necesitan uno — el campo oculto del renderer web, que tiene que
vivir fuera del canvas porque un canvas no puede componer IME. Un TextInput
con el foco es exactamente el caso en el que las teclas llegan legítimamente con
otra cosa enfocada.
La superficie es enfocable (tabindex en la web), así que el foco puede
entrar y salir de la view igual que entra y sale de cualquier otro control, y
pulsarla se lleva el foco además de la entrada.
Y después, qué view
Cuando el foco no apunta a ninguna view en particular, puede haber más de una montada, y exactamente una de ellas es dueña de la entrada:
- La primera view montada es la dueña, así que una página con una sola view se comporta como si la regla no existiera.
- Tocar una view se la entrega: una pulsación en cualquier punto de su superficie, caiga donde caiga. Pulsar sobre nada en concreto sigue siendo usar esa view.
- Enfocar la superficie de una view también se la entrega, así que las dos preguntas no pueden apuntar nunca a views distintas.
- Destruir a la dueña se la entrega a la view más antigua que quede, y suelta el foco del host si lo tenía.
La propiedad de la entrada no se deriva del foco del host, y por eso es una pregunta aparte: una view en la que nadie ha hecho clic todavía, en una página cuyo foco está sobre nada, sigue leyendo el teclado.
Todo lo demás se queda por view. Cada una conserva su propio foco, su propio hover y sus propios offsets de scroll — estas dos preguntas deciden quién oye las teclas y consulta el mando, no dónde vive el foco.
El foco, el hover, la pulsación, el offset de scroll, el estado abierto/marcado/seleccionado y el texto de un campo son estado de ejecución del que es dueño el SDK. Nada de eso se serializa en la IR, y nada de eso sobrevive a una recarga como dato autorado. El juego llega a ello a través del canal del host, no a través del formato.