Señalar un botón que no es tuyo.
Resumen. OpenRAHS es la versión de código abierto de la fila de hints contextuales que hice para Rod and Reef: la pila pequeña en la esquina de la pantalla que dice [E] Talk o [LMB] Cast y cambia según lo que esté haciendo el jugador. Dibujar la fila es lo fácil. El glifo depende del dispositivo que tiene en las manos, de la marca del control, de la distribución del teclado y, con el Input Action System de Roblox, de la tecla a la que el jugador haya reasignado la acción, y cada hint lo prende y lo apaga un sistema distinto que no sabe nada de los demás. Este artículo describe cómo la librería reparte ese problema. Los hints son objetos globales que se piden por id, y si uno está en pantalla o no es una conjunción de cuatro condiciones, así que ningún sistema necesita conocer el orden de arranque ni el estado de los otros. El input se observa y nunca se enlaza. Los glifos se resuelven en el momento en que se leen, con una tabla de nombres escrita a mano y otra aparte con los ids subidos. Y el dibujo pasa por un contrato de trece eventos, con un frontend por defecto que además cubre cada evento que un frontend propio deje sin definir. La librería tiene unas 2700 líneas de Luau, más tres scripts de Python que suben a Roblox sus 640 íconos CC0.
1. El problema
Una fila de hints le dice al jugador qué botón hace lo que tiene enfrente. En teclado lanzar la caña es un clic izquierdo, en un control es el gatillo derecho y en el celular es un toque, así que un solo hint ya tiene tres caras. Los controles agregan un segundo eje: Roblox llama ButtonA al botón frontal de abajo, que en un control de Xbox es una A verde y en un DualSense es una cruz azul. Los teclados agregan un tercero, porque Enum.KeyCode nombra posiciones de un teclado QWERTY, y un jugador francés que aprieta la tecla con la A impresa manda Enum.KeyCode.Q.
La otra mitad del problema es quién prende los hints. En Rod and Reef varios sistemas escriben en la fila. El minijuego de pesca, por ejemplo, la usa para decirle al jugador cuándo hacer clic, con una función que responde si un clic en ese momento acertaría. Ninguno de esos sistemas debería tener que saber qué hints están mostrando los demás, en qué orden cargaron los scripts o si una cinemática está escondiendo la fila.
Eso deja cuatro requisitos. El glifo tiene que ser el correcto para el dispositivo, la marca, la distribución y la asignación actual. Cualquier sistema tiene que poder mostrar u ocultar cualquier hint en cualquier momento sin coordinarse con el resto. La fila no puede interferir con el input que describe, porque cada tecla que nombra la enlaza otro sistema. Y el aspecto tiene que poder reemplazarse, porque cada juego tiene su propia interfaz, sin dejar de funcionar antes de que alguien haya subido un solo ícono.
2. Los hints como dato
2.1 Se piden por id
OpenRAHS:Hint(id) devuelve el hint con ese id y lo crea la primera vez que se pide. Nunca devuelve nil, nunca lanza un error y los hints nunca se destruyen. Por eso el script que declara qué dice un hint y el script que decide cuándo se muestra pueden correr en cualquier orden, desde cualquier módulo, sin un paso de registro:
OpenRAHS:Hint("Fishing_Cast")
:SetInput({ Keyboard = Enum.UserInputType.MouseButton1, Gamepad = Enum.KeyCode.ButtonR2 })
:SetLabel("Cast")
-- en otro lado, antes o después
OpenRAHS:Hint("Fishing_Cast"):Show()
El precio es que un id mal escrito crea un hint nuevo en vez de un error. Un hint que nunca se declaró no tiene inputs, y un hint sin inputs nunca se dibuja, así que la errata se nota como un hint que no aparece y no como uno equivocado.
2.2 Qué significa “en pantalla”
Para un hint , sea si el juego lo pidió (Set(true)), si está retirado (sección 2.3), si está en la ventana corta entre retirarse y salir, los inputs que declara para el dispositivo en uso, y el conjunto de razones por las que la fila está suprimida. El hint se dibuja cuando
Cada término vive en un lugar distinto. Los dos primeros forman el campo Visible del hint, que es lo que controla el código de gameplay. El tercero es IsRenderable(), y es la forma en que un hint solo de teclado no aparece en un celular sin que haya un chequeo de plataforma en ninguna parte: un hint declarado sin input de Touch sencillamente no tiene nada que decir ahí. El cuarto es de la fila.
La supresión es un conjunto de razones con nombre y no un booleano. Con un booleano, una cinemática que termina vuelve a prender la fila encima de un menú que sigue abierto. Un contador arregla eso, pero se desfasa para siempre la primera vez que algo libera dos veces. Con un conjunto, SetSuppressed(true, "Cutscene") es idempotente, cada sistema libera solo su propia razón y la fila vuelve cuando queda vacío.
2.3 Hints que enseñan una vez
Un hint marcado como Once se retira la primera vez que el jugador aprieta su tecla, y un hint retirado queda apagado el resto de la sesión por más que el juego lo pida, que es justo lo que necesita una línea de tutorial. Retirarlo al instante cortaría la animación que confirma la pulsación, así que se retira con 0.32 s de retraso. Ese es el término de arriba: durante esa ventana el hint cuenta como retirado pero se sigue dibujando. El retiro vive solo en memoria, así que un juego que quiera que sobreviva a una reconexión guarda el dato por su cuenta y llama a Retire() en la siguiente entrada.
2.4 Un orden estable
Los hints visibles se ordenan por un número, Order, y los empates se rompen por id:
El desempate está porque los hints viven en un diccionario y Luau no define el orden en que se recorre uno. Sin él, dos hints con el mismo orden podrían cambiar de lugar entre dos sincronizaciones y la fila temblaría. Los hints sin orden reciben 100, así que cualquiera ordenado a propósito queda antes que ellos.
2.5 Condiciones en vez de llamadas
Casi todos los hints dependen de una condición, y prenderlos y apagarlos desde cada lugar que la afecta reparte un mismo dato por varios archivos. ShowWhen(condition, triggers) convierte el hint en una función del estado: la condición se revisa al momento y otra vez cada vez que se dispara uno de los triggers. En el lugar de prueba de la librería, el hint de recargar es
hint:ShowWhen(function()
return Holding("Blaster") and Gameplay.Ammo < Gameplay.Magazine and not Gameplay.Reloading
end, { character.ChildAdded, character.ChildRemoved, Gameplay.Changed })
así que aparece después del primer disparo y se va durante la recarga y cuando el cargador está lleno. Cada hint tiene como mucho una condición y una segunda llamada reemplaza a la primera, por lo que volver a enlazarlo al personaje que reaparece es una sola línea sin nada que limpiar. Sin triggers la condición corre en cada Heartbeat, que está bien para una lectura barata. Una condición que lanza un error se reporta una vez y deja el hint como estaba.
3. Dispositivos
3.1 Qué dispositivo
El dispositivo es el que el jugador usó por última vez, no lo que soporta el hardware. Una laptop con pantalla táctil y un control conectado es cualquiera de los tres según cuál se tocó al último. Hay tres fuentes, en orden. Un juego que ya decide si un jugador está en táctil para su propio HUD puede pasar esa función, y así la fila nunca contradice al resto de la pantalla. Si no hay una, OpenRAHS lee UserInputService.PreferredInput, la propiedad que sigue el propio InputActionLabel de Roblox [6]. En versiones del motor sin esa propiedad, recurre al último tipo de input.
Ese respaldo necesita una suposición inicial antes de que el jugador toque nada, y solo considera computadora a un dispositivo que reporta teclado y mouse a la vez, porque algunos celulares reportan un KeyboardEnabled fantasma mientras que casi nada reporta MouseEnabled sin un mouse. Las consolas quedan fijas en control, porque su teclado en pantalla reporta input de teclado y, si no, cambiaría todos los glifos a teclas mientras el jugador escribe un nombre.
3.2 Qué control
UserInputService:GetStringForKeyCode(Enum.KeyCode.ButtonA) devuelve "ButtonCross" cuando el último control conectado fue uno de PlayStation, y "ButtonA" para uno de Xbox, uno desconocido o ninguno. El conjunto de arte, que en el código se llama familia, es el dispositivo más esa marca. La tabla de PlayStation no es una traducción letra por letra de la de Xbox, porque Roblox nombra los botones por posición: ButtonA es el de abajo, que es la cruz, y ButtonX es el de la izquierda, que es el cuadrado.
3.3 Nada se guarda en caché
Un hint guarda sus inputs tal como se declararon y nunca guarda el glifo que dibujó la última vez. GetInputs, GetGlyphs e IsPressed resuelven contra el dispositivo y las teclas apretadas en el momento en que se llaman, y un cambio de dispositivo dispara una sola señal, Refreshed, después de la cual el frontend vuelve a leer todo. Por eso un hint no puede quedarse con el arte de Xbox después de que se soltó el control, ni con una tecla iluminada que se soltó en otra ventana.
4. De un input a un glifo
4.1 Dos tablas
Un input pasa por dos saltos antes de convertirse en imagen. Bindings lo traduce a un nombre del atlas, como keyboard_e, y está escrita a mano. Assets traduce el nombre a un id subido y se genera. Tenerlas separadas tiene dos consecuencias. Volver a subir el arte regenera Assets y nunca toca la mitad escrita a mano. Y el estado presionado pasa a ser una operación sobre nombres: el paquete de Kenney [5] trae casi todas las teclas dos veces, keyboard_e rellena y keyboard_e_outline hueca, así que el glifo dibuja la hueca en reposo y la rellena mientras la tecla está apretada, y usa la única imagen que haya cuando no existe el par.
Los ids son de quien subió los íconos, así que el paquete viene con la tabla Assets vacía. Mientras un juego no tenga ids, cada glifo se dibuja como una tecla de texto, lo que significa que la librería funciona en cuanto se instala.
4.2 Distribuciones de teclado
Las letras salen de GetStringForKeyCode, que conoce la distribución del jugador. Por eso un hint enlazado a Enum.KeyCode.Q le muestra “A” a un jugador con AZERTY, que es la tecla que de verdad tiene que apretar, y usa keyboard_a como ícono. Solo aplica a las letras, y un juego que enseña posiciones en vez de letras lo puede apagar.
4.3 Respaldos
Un nombre se busca primero en la familia actual y después en las demás, así que un hint que nombra ButtonA sin una tabla por dispositivo igual dibuja un botón de control para un jugador de teclado en vez de nada. Cuando no hay ninguna correspondencia, el glifo recibe un botón neutro. Los nombres escritos como texto se devuelven tal cual aunque el atlas no los conozca, así que una errata se nota como un ícono faltante en vez de cambiarse por algo que parece correcto.
Los textos de las teclas siguen su propia cadena: un reemplazo por input, después las tablas de la librería, después la abreviatura del motor y al final el nombre del enum. Cada símbolo de esas tablas se probó con Builder Sans, porque algunas opciones obvias salen como una caja vacía. La cruz de PlayStation es × (U+00D7) porque la X de multiplicación en U+2715 es una de las que faltan.
4.4 El mouse existe dos veces
Roblox escribe un clic izquierdo como Enum.KeyCode.MouseLeftButton y también como Enum.UserInputType.MouseButton1, pero InputBegan solo reporta el segundo. Un hint declarado de la primera forma se dibujaría bien y nunca se iluminaría, que es el tipo de bug que sobrevive a una prueba. Una tabla de alias lleva cada KeyCode antiguo del mouse a su UserInputType antes de buscarlo y antes de seguir sus pulsaciones.
5. Observar el input sin adueñarse de él
OpenRAHS escucha InputBegan e InputEnded, que reportan el input y nunca lo consumen, y jamás enlaza nada con ContextActionService ni con el Input Action System. Cada tecla que describe un hint la enlaza otro sistema, y un observador [1] no puede robarle esa tecla ni pelearse por ella. Un hint equivocado puede confundir a un jugador, pero no puede cambiar lo que hace el juego.
Ignora gameProcessedEvent a propósito, porque un clic sobre un botón igual cumple un hint de “clic para lanzar”. La excepción es un TextBox con el foco: escribir una “m” en el chat no es apretar la tecla del menú, y contarlo retiraría un hint Once por toda la sesión.
Las teclas apretadas se cuentan en vez de guardarse como booleanos,
y la razón es la pantalla táctil. Dos dedos apoyados son dos InputBegan sobre Touch y levantar uno es un solo InputEnded, así que un booleano apagaría el glifo con un dedo todavía en la pantalla. Perder el foco de la ventana o desconectar un control suelta todo de una vez, porque una tecla apretada durante un alt-tab nunca manda su InputEnded.
Apretar una tecla que nombra un hint visible dispara Fulfilled con el hint y la posición de esa tecla dentro de él. El frontend usa esa posición para reproducir la animación en el glifo que se apretó, así que en un hint de dirección con A y D los anillos solo salen de la D cuando aprietas la D. Una versión anterior los mostraba en todos los glifos del hint, y el arreglo fue llevar el índice en el evento en vez de adivinarlo en el frontend.
6. El Input Action System
Los juegos hechos sobre el Input Action System de Roblox [6] ya declaran cada tecla una vez, en un InputBinding, y SetInputAction(action) hace que un hint lo siga. La señal obvia para eso, InputAction.InputBindingsChanged, resultó ser de seguridad RobloxScript: un script del juego que se conecta a ella recibe “lacking capability RobloxScript”. Así que los bindings se leen como hijos de la acción y se siguen con ChildAdded, ChildRemoved y las señales de propiedades de cada binding, que alcanza para que un menú de reasignación que escribe binding.KeyCode redibuje el hint en el mismo frame.
Las teclas de un binding salen en el orden en que las lee un jugador: los modificadores antes de la tecla, y un binding direccional como Arriba, Izquierda, Abajo, Derecha, que es como están W, A, S y D en el teclado. A qué dispositivo pertenece un binding se lee de su tecla principal, donde Button*, DPad* y Thumbstick* son del control. Si , y son las teclas encontradas para teclado, control y táctil, el hint recibe
así que la posición es el glifo en todos los dispositivos. Un binding de teclado Shift + E junto a uno de control con ButtonX queda como : dos glifos en teclado y uno en control. Antes se ordenan los bindings por nombre, porque GetChildren no garantiza un orden y cuál cuenta como el de teclado no debería cambiar entre dos llamadas. Un binding de UIButton no dibuja nada a propósito, porque el botón ya está en la pantalla del jugador. El tope de cuatro es WASD; con más, el texto queda demasiado lejos de las teclas para leerse como un solo hint.
7. El contrato del frontend
7.1 Cuándo contra cómo
El núcleo nunca crea una Instance y el frontend nunca decide la visibilidad. Un frontend es una tabla de hasta 13 handlers (Mount, Unmount, Build, Appear, Update, Press, Release, Fulfill, Reorder, Disappear, Revive, Step y Destroy), y un stage los llama en el momento correcto. El stage mantiene una vista por id de hint mientras el hint esté en pantalla, más lo que dure su animación de salida. Un hint que se vuelve a mostrar en medio de su salida recibe Revive sobre la misma vista en vez de una segunda fila.
Todos los handlers son opcionales, y los que un frontend no define usan los del frontend por defecto, así que cambiar el estilo de un momento de la animación es una sola función. Llamar otra vez a Start cambia el frontend en vivo y los hints no se enteran, porque todo lo que sabe el stage sale de tres señales y de leer la lista de visibles.
7.2 Salidas
Disappear devuelve cuánto dura la salida y el stage destruye la fila en ese momento. Un frontend que anima desde Step puede devolver nil y hacer que Step devuelva false cuando termine. Si nunca lo hace, la fila se destruye igual a los cinco segundos. Ese límite es una red de seguridad contra un Step que se olvida de devolver false, que si no mantendría vivas todas las filas descartadas durante toda la sesión.
7.3 Código ajeno dentro de la librería
Un frontend es código del usuario corriendo dentro de los eventos de la librería, así que cada llamada pasa por xpcall. Un error en un handler cuesta esa llamada y nada más. Se reporta una vez con su traceback y los errores siguientes del mismo handler se silencian, para que un Step roto no imprima sesenta veces por segundo. Sin protección, un error en Appear cortaría la sincronización a la mitad y dejaría sin construir todas las filas siguientes. La configuración recibe el mismo trato: una clave de reemplazo que no es un evento, como Appaer, se reporta en vez de ignorarse mientras el handler por defecto sigue corriendo, y un token del tema con el tipo equivocado se reporta y se salta.
8. El frontend por defecto
8.1 Anchos que nadie anima
Cada frame de la fila por defecto se ajusta a su contenido con AutomaticSize, y en Studio resultó que eso cuenta todos los descendientes, incluidos los escalados. Una tecla encogida con un UIScale al apretarla achicaba su propio espacio, y un anillo que crecía dentro de un glifo ensanchaba la fila, así que el texto de al lado temblaba de lado en cada pulsación. El arreglo fue mantener todo lo que se anima lejos de los anchos: una tecla apretada baja 2 píxeles en vez de encogerse, y los anillos se dibujan en una capa aparte, ubicados a partir del AbsolutePosition del glifo.
Una sorpresa parecida fue que un UISizeConstraint sobre un TextLabel de tamaño automático se aplica al área de adentro de su padding. Un ancho mínimo de 32 con 9 píxeles de padding por lado dibujó una tecla de 50 píxeles, y por eso el mínimo se calcula como KeySize - 2xKeyPadding.
8.2 Desvanecer respetando los valores de reposo
Poner todas las transparencias de la fila en el mismo valor haría que una sombra que reposa en 0.5 saltara a opaca a mitad del desvanecido. Cada parte registra un canal con su valor de reposo , y llevar la fila a una opacidad asigna
que da con y con . Un Build modificado que agrega un fondo registra un canal más, y ese fondo se desvanece con el resto.
8.3 Las filas que salen conservan su lugar
Las filas están en un UIListLayout ordenado por LayoutOrder. Una fila que va de salida tiene que conservar su espacio mientras las demás se mueven, pero una fila nueva puede llegar al mismo índice, y el layout rompe los empates por orden de inserción, lo que hacía que las filas nuevas cayeran un lugar corridas y después saltaran. Las filas que se quedan reciben números pares y las que salen, impares,
donde es el índice de la fila y vale 1 mientras sale. Un impar nunca es igual a un par, así que no quedan empates que romper.
8.4 Escala
La pila está diseñada para una altura de referencia . Se achica en pantallas más bajas, hasta un piso para que en un celular chico la fila se siga leyendo, y nunca crece más allá de la escala del propio tema:
La conexión al tamaño de la pantalla se rehace cada vez que cambia CurrentCamera, porque una que quede en la cámara vieja deja de seguir a la ventana después de reaparecer.
8.5 Detalles que el jugador nota
Las imágenes se descargan la primera vez que se usan, que para la variante rellena de un glifo es la primera vez que el jugador aprieta la tecla, justo cuando la está mirando. El stage precarga las dos variantes de cada glifo cuando aparece su fila. El frontend por defecto también respeta la opción de movimiento reducido del jugador [4]: las filas se siguen desvaneciendo y cambiando de color, pero nada rebota, se desliza, se hunde ni hace anillos. Los textos no se traducen automáticamente salvo que el juego lo active, porque las palabras cortas de una interfaz traducidas sin contexto suelen quedar mal (un cliente en español convirtió “Sprint” en “Esprintar”). Las teclas nunca se traducen, porque “Shift” es lo que está impreso en la tecla.
9. Animar desde un reloj
El frontend por defecto anima con tweens. El repositorio también trae un frontend completo escrito solo con la API pública, una píldora oscura por hint, que guarda unos cuantos relojes por fila y redibuja la fila a partir de ellos en cada frame dentro de Step. Donde se nota la ventaja es en las interrupciones: una pulsación durante la entrada es un segundo reloj corriendo al lado del primero, no dos tweens peleándose por la misma propiedad.
Para ese estilo la librería trae funciones de easing, entre ellas la curva cubic-bezier de CSS [2], así que una curva diseñada en un navegador se puede llevar tal cual con sus números. Con y fijos, cada coordenada es una Bézier cúbica en un parámetro ,
y lo mismo para . La curva se define como en función de , así que evaluarla significa resolver para y después calcular . El solver arranca el método de Newton [3] en , que ya está cerca salvo que los puntos de control sean extremos:
con
Devuelve el resultado en cuanto el error baja de . Si ocho pasos no alcanzan, o si la pendiente cae por debajo de y un paso de Newton se dispararía, pasa a bisección. CSS exige , y eso hace que sea no decreciente: los coeficientes de en forma de Bernstein son , y , y cuando el del medio es negativo, impide que la cuadrática baje de cero. Sobre una función monótona la bisección no puede perder la raíz, y 24 mitades dejan a menos de de ella. Los puntos de control que se pasan en (un rebote con ) no son problema, porque solo tiene que ser monótona.
10. Los íconos
El repositorio incluye 640 íconos del paquete Input Prompts de Kenney [5], publicados bajo CC0, en seis familias: teclado y mouse (257), PlayStation (129), Xbox (97), adornos (93), botones genéricos (36) y gestos táctiles (28). Tres scripts de Python, de unas 600 líneas y solo con la biblioteca estándar, los convierten en ids que le pertenecen al juego. upload.py manda cada PNG por la API de Assets de Open Cloud, de a cuatro a la vez, y escribe cada resultado en un log apenas llega, así que una corrida cortada a la mitad se retoma sin pagar nada dos veces. Un 429 se reintenta con una espera creciente, y un 401 o un 403 detiene todo el lote, porque cada subida siguiente fallaría igual. generate.py escribe la tabla de Luau a partir del log, y verify.py revisa que cada nombre que pueden pedir los bindings tenga un PNG y un id.
Las subidas son Images y no Decals, cosa que aprendí por las malas. Los dos tipos se suben bien y vuelven aprobados, pero el id de un Decal es el de un envoltorio alrededor de la imagen, y ImageLabel.Image = "rbxassetid://<id del decal>" nunca carga: IsLoaded se queda en false sin ningún error ni advertencia. Una subida como Image devuelve el id que entiende un ImageLabel.
11. Publicarla
La versión publicada depende de tres librerías comunes, Janitor, Promise y GoodSignal, que se piden como hermanas del paquete porque ahí las ponen tanto Wally como una carpeta Packages armada a mano. Un detalle del empaquetado casi se cuela: la lista include de Wally no restringe nada por sí sola, y sin un exclude = ["**"] al lado el paquete habría incluido los 640 íconos. La API está documentada con Moonwave a partir de comentarios en el código, y el repositorio trae un lugar de prueba que se construye entero al ejecutarse, con una espada, un blaster, un carro que se maneja y un NPC, donde cada hint aparece y desaparece según lo que hace el jugador.
12. Limitaciones
- No hay tecla Command en Mac. La única llamada que podría detectar macOS,
UserInputService:GetPlatform, lanza un error desde el código del juego, así que todas las teclas Super se dibujan como la tecla de Windows. Un hint que sabe que está en Mac puede nombrarkeyboard_commanddirectamente. - La detección de marca conoce Xbox y PlayStation y nada más. Cualquier otro control se dibuja con el arte de Xbox, y con dos controles conectados sigue al último.
- La distribución del teclado solo se respeta en las letras. Las teclas de puntuación se siguen dibujando por su posición QWERTY.
- Un id mal escrito crea un hint que nunca se dibuja en vez de un error (sección 2.1).
- El retiro no sobrevive a una reconexión sin ayuda del juego.
- Un hint dibuja como mucho cuatro inputs.
- Los handlers no pueden pausar (yieldear), salvo
Mount, porque corren dentro del evento que los causó.
13. Lo que me dejó
La decisión que repetiría es que la librería sea un observador. Nunca enlaza ni consume una tecla, así que no hay orden de inicialización en el que pueda romper un control, y ningún otro sistema tiene que saber que existe. Muchas cosas se simplificaron una vez que eso estuvo decidido. Fulfilled pudo ser una simple observación, y un hint nunca necesita saber si la acción que describe pasó de verdad.
La segunda es resolver al leer en vez de guardar resultados. Un hint guarda lo que se declaró y calcula el glifo cuando alguien lo pide, que es la misma regla que sigo en Rod and Reef: derivar en vez de mantener. Un cambio de dispositivo, una reasignación de tecla y una tabla nueva de ids terminan siendo un solo redibujo en vez de tres cachés que invalidar.
Referencias
- R. Nystrom. Game Programming Patterns. Genever Benning, 2014.
- W3C. CSS Easing Functions Level 1. W3C Candidate Recommendation.
- R. L. Burden y J. D. Faires. Numerical Analysis, 9.ª ed. Brooks/Cole, 2011.
- W3C. Web Content Accessibility Guidelines (WCAG) 2.1, criterio de conformidad 2.3.3, Animation from Interactions. 2018.
- Kenney. Input Prompts. Paquete de assets, CC0. kenney.nl.
- Roblox. Creator Documentation:
UserInputServicey el Input Action System. create.roblox.com/docs.
Este texto de Mastedore. Todos los derechos reservados.