Hablar solo en el momento justo.
Advertencia
Rod and Reef todavía no ha sido lanzado, y no se pueden mostrar videos ni GIFs de este sistema hasta que salga.
Resumen. En Rod and Reef el protagonista, X, comenta lo que el jugador está haciendo: lanza la caña, espera el pique, pierde un pez, navega o camina hacia un encargo. A estas frases cortas se les llama barks, y el juego tiene 74 grabadas. Para que cada una sonara en el momento correcto hacía falta poder preguntar “qué está haciendo el jugador ahora”, y el juego no tenía esa respuesta en ningún lado: estaba repartida en variables privadas de cada controller. Este artículo describe las dos piezas que construí para resolverlo. La primera es PlayerStateMachine, un conjunto de máquinas de estados finitos que corren en paralelo, una por cada sistema dueño, con pesos que eligen un estado efectivo y que detectan transiciones que ningún controller escribe. La segunda es el BarkDirector, que convierte momentos del juego en líneas usando un catálogo de datos, probabilidades por momento, prioridades, un espaciado global y un mazo barajado en vez de un dado. Sobre la misma máquina se apoya PlayerAvailability, una sola definición de “el jugador está ocupado” que comparten los barks, los menús, los prompts y el renderizado de los demás jugadores.
1. El problema
El actor de voz grabó 74 líneas a partir de un guion de unas 94, organizadas por situación: estar quieto, caminar, manejar el bote, pescar, chistes sueltos, reacciones después de entregar una quest a cada NPC, momentos raros (un pez legendario, uno que brilla) y un cambio de tono para la parte final de la historia. Cada línea solo tiene gracia en su momento. “Come on, come on. Bite already.” funciona con la caña en el agua y no en un muelle.
El estado del jugador existía, pero no se podía consultar. FishingController lo guardaba en una variable local, currentState, que se asignaba en 19 lugares distintos. BoatController sabía cuándo el jugador se sentaba al timón. Las quests tenían sus propias señales. Ninguna de esas cosas era visible desde afuera, así que un sistema nuevo tenía que deducirlo todo otra vez, y deducir falla. El juego ya tenía un ejemplo: LocomotionStateMachine infiere el estado a partir del Humanoid en cada cuadro y devuelve Idle para alguien que maneja un bote, porque técnicamente está sentado y quieto. Unos barks montados encima de eso habrían soltado líneas de “aquí parado sin hacer nada” en plena navegación.
Había además una restricción que pesaba más que las otras. El estado de pesca no es informativo, es portante: FishingController tiene guardas como “si estoy en COOLDOWN, no lances”, y son lo único que impide lanzar dos veces o castear durante el minijuego. Si ese estado pasaba a un objeto global que cualquiera pudiera escribir, un bug en el bote podría romper el lance, con el síntoma muy lejos de la causa.
2. Varias máquinas en paralelo
2.1 Una máquina por dueño
Un jugador está en varios estados a la vez. Pescar desde un bote es estar pescando y estar en el bote, y ninguno de los dos es falso. Una sola máquina de estados no puede representar eso sin inventar un estado por cada combinación, así que PlayerStateMachine es un conjunto de máquinas pequeñas, una por dueño.
Sea el conjunto de dueños (Foot, Boat, Fishing, Steal, Dialogue, Story y otros). Cada dueño tiene un conjunto finito de estados , y su máquina tiene a lo sumo uno activo:
donde significa que ese dueño no tiene nada activo. Entrar a un estado de reemplaza el que tenía, y eso es lo que hace que cada dueño sea una máquina de verdad y no una bolsa de booleanos: Fishing no puede estar en Fishing.Casted y Fishing.Minigame al mismo tiempo, por construcción. La idea es la de las regiones ortogonales de los statecharts de Harel [1], donde un estado compuesto se descompone en varias máquinas que avanzan de forma independiente.
La composición evita enumerar el producto. Con los dueños que tienen estados hoy (Foot con 1, Boat con 1, Fishing con 4, Story con 2, Dialogue con 1 y Steal con 2), el número de configuraciones es
y cada una de las 9 etiquetas escritas a mano (sección 2.4) duplica esa cuenta, así que una máquina plana necesitaría nombrar más de combinaciones. Aquí cada controller escribe solo su región y nadie tiene que pensar en las demás.
2.2 El estado efectivo
Aun así hace falta una respuesta única a “qué está haciendo”, porque es lo que quiere un bark. Cada estado tiene un peso y el estado efectivo es el más pesado de los activos:
Los demás siguen activos y se pueden consultar con IsStateActive, solo que no son el titular. El peso es por dueño y no por estado: dos estados del mismo dueño son mutuamente excluyentes, así que sus pesos nunca se comparan entre sí. Los niveles son Foot 10, Boat 20, Fishing 30, Steal 40, Dialogue 50 y Story 60, con huecos para poder meter un dueño nuevo entre dos existentes sin renumerar nada.
El orden sigue una regla, que el titular es lo más específico que el jugador está haciendo. Pescar pesa más que el bote desde el que pescas, y el bote más que caminar. Un robo pesa más que la pesca porque la persecución es lo que estás haciendo ahora y el lance que abandonaste ya no. Una conversación pesa más que el robo porque el servidor no empieza un robo contra alguien que está hablando, así que la única forma de tener las dos cosas es una persecución que ya corría cuando un NPC empezó a hablar, y en ese caso lo que se ve en pantalla es el NPC. Una cinemática pesa más que todo, porque ahí el jugador no controla nada.
Los empates se rompen por nombre, en orden lexicográfico. Con el vocabulario actual no pueden pasar, porque los pesos son por dueño y cada dueño tiene uno distinto, pero el código no depende de eso: el orden de iteración de una tabla en Luau no está definido, y dos estados del mismo peso tienen que resolverse igual en cualquier máquina y en cualquier cuadro.
2.3 La transición que nadie escribe
Este es el punto fino del diseño. StateChanged no se dispara desde Enter ni desde Exit, sino desde un solo lugar, refreshEffective, que recalcula después de cada escritura y avisa solo si cambió:
Un ejemplo muestra por qué. El jugador pesca desde el bote, con y . Termina de pescar, y FishingController hace Clear("Fishing"). Ningún controller dijo “entra a Boat.Driving”, pero el estado efectivo pasa a ser el bote, porque algo más pesado se fue. Ese momento, “dejé de pescar y sigo navegando”, es real y es justo el tipo de cosa que X comentaría. Una implementación que avisa desde las escrituras crudas no lo ve nunca, porque cada escritura individual es correcta.
Las escrituras redundantes no hacen nada. Entrar al estado que ya tienes devuelve de inmediato, y salir de un estado que el dueño ya no tiene tampoco es un error, porque los caminos de limpieza se disparan sin saber desde qué fase están limpiando.
2.4 Tags
La otra mitad del sistema son los tags: booleanos sueltos para cosas que son ciertas del jugador pero que nunca podrían ser su actividad principal. Hoy hay nueve escritos a mano (Sprinting, FishEquipped, UsingBait, HasObjective, Night, PostReveal, MenuOpen, UsingMicrophone y Afk) y uno más por cada tipo de subzona del mapa, generados a partir de la lista de subzonas para que el vocabulario y quien lo escribe salgan del mismo código.
La regla para decidir entre estado y tag es preguntarse si la cosa podría responder “qué está haciendo ahora”. Manejar un bote podría, así que es un estado. Llevar carnada no, así que es un tag. Tampoco hay un tag InBoat: Boat.Driving sigue activo mientras pescas desde el bote, así que IsStateActive ya responde esa pregunta, y un tag al lado sería otra cosa que mantener sincronizada con la primera.
Quien consulta no debería tener que recordar cuál era cuál, así que hay un predicado unificado:
Es lo que usa el catálogo de barks para sus condiciones (sección 4.2).
2.5 Escrituras con dueño y vocabulario cerrado
El dueño está escrito en el nombre: Fishing.Casted pertenece a Fishing, y solo Fishing puede entrar a él o salir de él. Una escritura de otro dueño se rechaza con una advertencia. Así las guardas de FishingController siguen siendo suyas, porque los controllers conservan su lógica y solo publican hacia la máquina. Las lecturas son globales y las escrituras privadas, y la migración se pudo hacer un controller a la vez.
El vocabulario completo (estados, pesos, tags y sus dueños) se registra en el momento en que se requiere la máquina, y no cuando arranca algún controller. ClientStart recorre los módulos en un orden que nadie controla, y un controller que publicaba un estado antes de que estuviera definido recibía una advertencia y no pasaba nada. El síntoma era “a veces los barks no funcionan”, que es de los peores que hay. Con el registro en el require, tener una referencia a la máquina garantiza que los nombres existen. Por la misma razón, todos los nombres se usan como constantes y nunca como texto escrito a mano, porque un nombre con una errata es una escritura que no hace nada y no avisa.
3. Una sola definición de “ocupado”
3.1 Conjuntos y predicados
Dos sistemas necesitaban la misma respuesta y la tenían escrita dos veces: el director de barks, para que X no murmure sobre el clima encima de la línea de un NPC, y la librería de menús, para que no se pueda abrir el inventario en medio de una cinemática. Escritas dos veces, esas listas se separan. Alguien agrega una sexta interrupción a la lista de barks, nadie se acuerda de la de menús, y el juego termina con un inventario que se puede abrir durante una cinemática en la que X se calla muy educadamente.
PlayerAvailability define esa respuesta una sola vez, como conjuntos de estados y predicados sobre la máquina:
son los estados en los que al jugador le están mostrando algo, las interrupciones y los estados en los que algo ocupa la pantalla entera. Con ellos:
- Los barks pueden sonar si ningún estado de está activo, no hay un menú abierto y el micrófono no está en vivo.
- Los menús se pueden abrir si ningún estado de está activo.
- Los prompts del mundo se dibujan si ningún estado de está activo, porque durante el minijuego su tecla competiría con la del dial.
- Los demás jugadores se dibujan si ningún estado de está activo.
Las diferencias entre listas son decisiones. Un robo no esconde a los demás jugadores porque esconderlos escondería a la persona que estás persiguiendo. El minijuego no cuenta como interrupción, porque el jugador lo eligió y lo está jugando, pero sí ocupa la pantalla. Un micrófono abierto calla a X porque el jugador está en una conversación real, y un subtítulo apareciendo encima sería el juego hablando por encima de una persona, pero no impide abrir el inventario.
El módulo lee la máquina y nada más. No sabe qué es una cinemática, solo que Story.Cutscene está activo, así que agregar una interrupción nueva es declarar un estado y ponerlo en la lista, sin acoplar nada.
3.2 Puentes por nivel, no por flanco
La librería de menús del juego, Pretty, es una librería general de interfaz y no tiene por qué saber que el juego tiene cinemáticas o robos. La máquina, por su lado, no sabe quién la consume. El único módulo que conoce a los dos es PrettyStateBridge, que conecta en ambos sentidos. Un menú apilado que está en pantalla se convierte en el tag MenuOpen, y una interrupción activa se convierte en un bloqueo con nombre sobre los menús, con un mensaje para el jugador (*Not during a cutscene.).
Lo importante es cómo calcula el bloqueo. En electrónica se distingue entre disparar por flanco (reaccionar a cada transición) y por nivel (reaccionar al valor actual). Un bloqueo manejado por flancos solo sobrevive si cada entrada y cada salida se ven exactamente una vez y en orden, y las cinemáticas, los diálogos y los robos se solapan y tienen caminos de limpieza que se disparan en cualquier orden. Por eso el puente recalcula el bloqueo desde cero en cada cambio:
Si se pierde un flanco, el siguiente lo corrige, en vez de dejar los menús bloqueados el resto de la sesión. Y como depende solo del estado, escucharla dos veces por el mismo cambio (una por StateChanged y otra por TagChanged) no cuesta nada, porque la segunda llamada no encuentra nada que cambiar.
4. El director
4.1 Momentos con nombre
El único contrato entre el sistema que sabe que algo pasó y la línea que lo comenta es un nombre, el cue. Un controller llama Barks.Fire(Barks.Cue.FishingCatchFail) y se olvida, como en el patrón observador [2]. Si se dice algo, y qué, lo deciden el catálogo y el director, y por eso agregar líneas nuevas no obliga a tocar ningún controller.
Hay dos tipos de cue y la diferencia es de fondo. Los derivados (State.Fishing.Casted.Enter, State.Idle.Dwell y así) no los dispara nadie, porque el director se suscribe a StateChanged y los levanta él mismo, incluidas las transiciones de la sección 2.3. Los instantáneos (Fishing.CatchSuccess, Catch.Legendary, Quest.HandedIn y otros) los dispara quien conoce el momento, con un payload opcional como la rareza o el NPC. La regla es usar la máquina para lo que dura y un cue suelto para lo que ocurre. “Acabo de pescar un legendario” no es un estado. Modelarlo como estado obligaría a alguien a limpiarlo después, y terminarías con un JustCaughtLegendary colgado. En total hay 8 cues derivados y 6 instantáneos.
4.2 El catálogo
BarkCatalog es una tabla de datos con una fila por línea grabada. Cada fila dice a qué cues puede responder, qué nombres tienen que ser verdaderos (Require), cuáles no pueden serlo (Forbid), qué tiene que traer el payload (Match), cuánto tarda en poder repetirse (Cooldown) y su prioridad: 0 para lo ambiental, 1 para lo relacionado con la acción y 2 para los momentos raros. Es el criterio editorial de “esta línea tiene sentido aquí”, escrito como tabla en vez de como ifs repartidos por los controllers.
Algunas filas muestran para qué sirve cada campo. “Breaking news! Local fisherman still hasn’t figured out how bait works!” responde a un pez perdido, pero exige UsingBait, porque el chiste solo funciona si hay carnada que usar mal. “Yeah, I could do this forever. Just point me at the horizon.” responde a navegar un rato, pero prohíbe Sprinting, porque es contemplativa. “This one’s a 2. Rude little guy.” solo responde a una captura con Rarity = Common. Y las líneas del tramo final de la historia exigen PostReveal y entran en los mismos grupos que las normales, compitiendo con ellas. El cambio de tono que pedía el guion se resolvió con un tag y cero estados nuevos.
Las líneas se asignaron por lo que el actor dijo de verdad en la toma, no por la sección del guion de la que venían, porque improvisó y algunas quedaron más cerca de otro momento. 73 de las 74 están en el catálogo. La que falta habla de la lluvia, y el juego no tiene clima: no hay una sola referencia a lluvia o tormenta en el código, así que no existe un momento honesto al que colgarla. Otras dos estaban escritas para sacar una bota o una piedra del agua, que tampoco existe en el juego, y se movieron al pez perdido, que es lo más parecido. Las de “la pesca más grande de mi vida” necesitaban un récord personal de peso que no se guarda en ningún lado, y quedaron en la captura legendaria.
4.3 Elegibilidad
Para un cue con payload , una línea del grupo de es elegible en el instante si
donde es la última vez que sonó y su cooldown, de 300 s por defecto y de 600 a 900 s para los momentos raros. El valor por defecto es largo a propósito, porque oír el mismo chiste dos veces en una sesión es lo que hace que un sistema de barks se sienta barato.
La idea de elegir líneas con reglas sobre el estado del juego viene del sistema de diálogo dinámico que Valve usó en Left 4 Dead, que la lleva mucho más lejos, con consultas sobre cientos de hechos y la regla más específica como ganadora [3]. Con 73 líneas no hacía falta tanto, y una tabla de condiciones explícitas es más fácil de revisar a mano.
4.4 Un mazo, no un dado
Elegir al azar de forma independiente entre líneas suena roto muy rápido. Es el problema del cumpleaños [4]: la probabilidad de que las primeras elecciones sean todas distintas es
y el índice esperado de la primera repetición es , con , la función de Ramanujan [5]. Para el grupo de “esperando el pique”, que antes del final de la historia tiene 6 líneas, eso da 3.8: en promedio, la cuarta línea que oyes ya es repetida, y la probabilidad de oír una repetición en los primeros cuatro lances es . Con las 9 líneas de pez perdido es 0.54 y con las 13 de estar quieto es 0.40. Los jugadores leen eso como un bug y no como mala suerte.
Por eso las líneas se reparten de un mazo barajado, con el algoritmo de Fisher y Yates en la versión de Durstenfeld [6], y el mazo solo se vuelve a barajar cuando se acaba. Queda una costura: la última carta de un mazo puede ser la primera del siguiente, y es el único lugar donde sobreviviría una repetición seguida. Al barajar, si la primera carta coincide con la última que salió, se cambia por la última del mazo nuevo. Con eso, para un grupo de líneas, ninguna se reparte dos veces antes de que se hayan repartido todas, y nunca sale la misma dos veces seguidas.
Las cartas que no son elegibles en ese momento (en cooldown, o con un Require falso) se descartan de esa pasada. draw recorre a lo sumo dos pasadas, lo que queda del mazo y uno recién barajado, así que una línea que se salta en las dos no está mal ubicada, simplemente no es elegible ahora. El orden inicial del mazo se ordena alfabéticamente para que sea determinista, y la barajada lo vuelve aleatorio.
4.5 El orden de los filtros
Fire(c, π) rechaza en este orden:
- El sistema no arrancó, el jugador apagó los barks en la configuración, o algún sistema los está callando (sección 4.8).
AllowsAmbientBarks()es falso.drawno encuentra ninguna línea elegible.- Sonó un bark hace menos de 8 s y la línea no es de prioridad 2.
- Hay un bark hablando con prioridad igual o mayor. Si la nueva tiene más prioridad, corta a la anterior con
StopSessiony se queda con la pantalla. - Falla la tirada de probabilidad del cue.
Si pasa todo, la línea suena por el SubtitleController, se marcan y la hora del último bark, y se toma un candado de “hablando” con un vencimiento de 30 s, por si la sesión nunca termina (una cancelación a mitad, la interfaz destruida en un respawn).
El orden importa en dos sentidos. Los rechazos baratos van antes de draw, porque draw gasta una carta del mazo: si las interrupciones se comprobaran después, cada cue disparado durante una cinemática se comería la línea que X habría dicho, y una lista Forbid en cada una de las ~60 entradas tendría ese mismo problema además de tener que mantenerse al día. Y la tirada de probabilidad va al final, porque tirar primero y después descubrir que no había nada elegible haría que cada probabilidad del catálogo fuera más baja de lo que dice. Tampoco hay cola. Una línea ambiental habla del momento en que se disparó, y guardada para después de una cinemática sería un comentario sobre nada.
El espaciado global existe por una secuencia concreta. Lanzar, que piquen y sacar el pez son tres cues en unos 4 segundos. Sin el espaciado X hablaría encima de sí mismo, y peor, el SubtitleController apila las sesiones simultáneas en filas separadas, así que también se verían dos líneas de subtítulo a la vez.
4.6 Probabilidad por momento
La probabilidad no es un número global. Un 50% plano en los momentos que pasan todo el tiempo, como echar a caminar o lanzar, hace que X narre la mitad de lo que haces y cansa en una hora. Ese mismo 50% en los momentos que pasan una vez por sesión desperdicia la mitad de lo que se grabó para ellos. Los momentos comunes quedan callados y los raros casi siempre suenan:
| Cue | Tipo | Probabilidad |
|---|---|---|
| Empezar a caminar | derivado | 0.15 |
| Caminar un rato | derivado | 0.25 |
| Tomar el timón | derivado | 0.40 |
| Navegar un rato | derivado | 0.30 |
| Lanzar | derivado | 0.25 |
| Esperar el pique | derivado | 0.30 |
| Terminar una captura | derivado | 0.15 |
| Quedarse quieto | derivado | 0.35 |
| Sacar un pez | instantáneo | 0.30 |
| Perder un pez | instantáneo | 0.45 |
| Acelerar el bote | instantáneo | 0.35 |
| Pez legendario | instantáneo | 0.90 |
| Pez mutado | instantáneo | 0.90 |
| Entregar una quest | instantáneo | 1.00 |
Juntando todos los filtros, la probabilidad de que un cue produzca una línea es
donde es la prioridad de la línea elegida. Con los indicadores en 1, la probabilidad que se lee en la tabla es la que se obtiene.
4.7 Quedarse un rato
Los cues de tipo dwell (“lleva un rato haciendo esto”) salen de temporizadores del director: 25 s quieto, 45 s caminando, 30 s navegando y 12 s con la caña en el agua. Un cambio de estado efectivo reinicia el temporizador. Se revisan con un latido de una vez por segundo y no con task.delay, porque el temporizador tiene que poder cancelarse con un cambio de estado, y un delay que ya se disparó no se puede retirar. Un chequeo por segundo cuesta mucho menos que etiquetar cada callback con una generación, y la latencia queda en un segundo como mucho.
El temporizador se rearma después de cada ventana, intente o no decir algo. Quedarse cinco minutos quieto puede producir más de una línea, pero nunca más de un intento por ventana, así que el ritmo tiene un techo:
Con 13 líneas en ese grupo y 300 s de cooldown cada una, el cooldown casi nunca es lo que limita.
4.8 Callarse
Fire solo decide si una línea empieza. Una que ya está sonando cuando abre una cinemática es un caso aparte, porque nadie vuelve a preguntar después de que empezó. Por eso el director escucha StateChanged y TagChanged, y si AllowsAmbientBarks() pasa a falso corta la línea con StopSession, que quita el texto junto con el audio. Si solo cortara el audio, el subtítulo se quedaría en pantalla.
Hay dos interruptores y no se mezclan. El del jugador (Graphics > Ambient Barks) es un booleano. El de los sistemas es un conjunto de fuentes con nombre, y X está callado si el conjunto no está vacío. El primer usuario es el tutorial de combate, que es un tramo de varios minutos con el maestro de combate hablando. Si el tutorial escribiera el mismo booleano que el jugador, al terminar le devolvería la preferencia como “encendido” sin importar cómo la tenía. Con dos estados separados, soltar un bloqueo restaura lo que el jugador eligió, y soltar uno que nunca se tomó no hace nada, así que se puede soltar sin condiciones al salir.
Todo esto corre solo en el cliente. X habla consigo mismo, nadie más en el servidor lo oye, y por eso no hay nada que replicar.
5. Limitaciones
La garantía del mazo es sobre lo que se reparte y no sobre lo que se oye. draw saca la carta antes del espaciado, del candado y de la tirada, así que una línea que pierde la tirada se descarta de esa pasada sin sonar. El mazo sigue rotando aunque X se quede callado, lo cual no es grave, pero significa que no todas las líneas se oyen antes de que alguna se repita.
El contenido depende de los sistemas que existen. Sin clima, sin capturas basura y sin récords personales, tres momentos del guion no tienen dónde sonar. El catálogo deja anotado a dónde mover cada línea el día que alguno exista.
El dwell se reinicia con cualquier cambio de estado efectivo, incluidas las transiciones derivadas, así que entrar y salir de algo pesado reinicia la cuenta de lo que estaba debajo. Y la disponibilidad se calcula en el cliente: el servidor tiene sus propias comprobaciones para lo que es autoritativo, y el único tag que lee es Afk, que el cliente le reporta por un paquete.
6. Lo que me dejó
Separar lo que es verdad (la máquina), lo que se dice sobre eso (el director) y quién puede interrumpir (la disponibilidad) hizo que cada pieza fuera chica y que las tres se pudieran cambiar sin tocar las otras. La segunda lección fue preferir valores derivados del estado a valores mantenidos por eventos. La transición derivada de la sección 2.3, el bloqueo de menús de la 3.2 y el corte de líneas de la 4.8 son el mismo patrón: en vez de confiar en ver cada evento una vez y en orden, se recalcula la respuesta desde el estado actual, y un evento perdido deja de ser un bug permanente.
Referencias
- D. Harel. “Statecharts: A Visual Formalism for Complex Systems”. Science of Computer Programming, 8(3), 1987.
- R. Nystrom. Game Programming Patterns. Genever Benning, 2014.
- E. Ruskin. “AI-driven Dynamic Dialog through Fuzzy Pattern Matching”. Game Developers Conference, 2012.
- W. Feller. An Introduction to Probability Theory and Its Applications, Vol. 1, 3.ª ed. Wiley, 1968.
- P. Flajolet y R. Sedgewick. Analytic Combinatorics. Cambridge University Press, 2009.
- R. Durstenfeld. “Algorithm 235: Random Permutation”. Communications of the ACM, 7(7), 1964.
Este texto de Mastedore. Todos los derechos reservados.