Cambiar tema

Diseño de máquina de estados para minijuegos: del inicio a la batalla y la liquidación

Easton editorial illustration: central gameplay state rotor linking home, battle, pause, and result states with a nested combat ring

"El 80 % de los bugs en juegos de cartas provienen de un diseño de estados deficiente; la máquina de estados resuelve eficazmente el problema de juicios de estado dispersos."

- Artículo de arquitectura de juegos de cartas en Sohu

"La máquina de estados anidada se ha convertido en el patrón de diseño dominante en juegos complejos; Battle State puede incluir cuatro subestados: BeginBattle, HeroTurn, EnemyTurn y EndBattle."

Tu minijuego acaba de publicarse y los jugadores reportan que la pantalla de liquidación se queda atascada: el juego ya terminó, pero la interfaz sigue mostrando el estado de batalla. Abres el código y ves if (isPlaying && !isPaused && !isGameOver && hasPlayerLeft) por todas partes. En ese momento solo quieres decir: ¿quién escribió esto? Ah, fui yo.

Cuando hice un minijuego de cartas, yo también caí en esa trampa. Los juicios de estado eran variables booleanas por todos lados; cambiar una función obligaba a revisar una docena de archivos. Lo peor fue en multijugador: el servidor pasó a liquidación y un cliente seguía reproduciendo la animación de batalla — los estados se desincronizaron. Tras refactorizar con una máquina de estados, el código quedó mucho más limpio.

En este artículo hablamos de cómo diseñar la máquina de estados de un minijuego. Del inicio a la batalla y la liquidación, la desglosamos en tres capas: flujo del juego, flujo de batalla y detalles de combate. También comparto experiencia real: bloqueo de estados, validación en servidor, mecanismos de tiempo de espera — eso es lo que de verdad sirve en producción.

¿Por qué un minijuego necesita una máquina de estados?

En pocas palabras, un juego es un conjunto de estados que van cambiando.

Inicio, batalla, liquidación, pausa, modo espectador — cada pantalla es un estado. El jugador pulsa «Empezar partida», desaparece el inicio y aparece la batalla. Termina la batalla y sale la liquidación. Suena simple, pero al codificarlo todo se enreda.

Los tres pecados del «cascada de if-else»

Mira este código; seguro que te resulta familiar:

// Juicios de estado dispersos
if (isPlaying && !isPaused && !isGameOver) {
  // El jugador puede actuar
}

if (isGameOver && !hasShownResult) {
  // Mostrar pantalla de liquidación
}

if (isPlaying && currentPlayer === 'player1') {
  // Turno del jugador 1
}

Este código tiene tres pecados:

Difícil de leer: para saber el estado actual tienes que buscar variables booleanas por todo el archivo. isPlaying, isPaused, isGameOver — fragmentos sueltos; tú mismo debes armar el panorama completo.

Difícil de modificar: ¿añadir un estado «pausa»? Hay que tocar if en una docena de sitios. Se te escapa uno y aparece el bug. Una vez añadí «modo espectador», dos días de cambios y al publicar seguía fallando — una animación no se pausaba.

Difícil de depurar: si el orden de transiciones se desordena, no sabes dónde falló. isGameOver pasa a true pero hasShownResult sigue en false — ¿qué condición faltó?

Un artículo de Sohu menciona que el 80 % de los bugs en juegos de cartas vienen de un mal diseño de estados. El número asusta, pero quien lo ha vivido sabe que es real.

¿Cómo lo resuelve la máquina de estados?

La máquina de estados concentra esos juicios dispersos en un solo lugar.

Cada estado tiene su comportamiento: qué hacer al entrar, en ejecución y al salir. Las reglas de transición quedan claras: desde el inicio solo puedes ir a batalla, no directo a liquidación.

Compara:

// Enfoque con máquina de estados
class HomeState {
  enter() { showHomeUI(); }
  exit() { hideHomeUI(); }
  handleEvent(event) {
    if (event === 'START_BATTLE') {
      manager.changeState(new BattleState());
    }
  }
}

class BattleState {
  enter() { showBattleUI(); startBattle(); }
  exit() { hideBattleUI(); cleanupBattle(); }
}

El estado queda encapsulado. Cambiar la lógica de HomeState no afecta a BattleState. Para un estado nuevo, solo escribes otra clase State y añades una regla de transición.

Better Programming señala que la máquina de estados anidada es ya el patrón dominante en juegos complejos. Los minijuegos no son la excepción: estados claros, código claro.

Arquitectura de máquina de estados en tres capas

En un minijuego conviene diseñar la máquina de estados por capas. Sin capas, la lógica de batalla se mete en el flujo del juego y el código se hincha.

La divido en tres capas: flujo del juego, flujo de batalla y detalles de combate.

Primera capa: flujo del juego

Es el esqueleto principal, del arranque a la liquidación:

BootState: al iniciar, carga recursos e inicializa el motor. La escena Boot de Cocos Creator hace exactamente eso — primero Boot, luego Home.

HomeState: menú principal. El jugador elige nivel, personaje, consulta ranking. Lógica relativamente simple: mostrar UI y manejar clics.

BattleState: escena de batalla. No es el final del camino, sino un contenedor — dentro hay otra submáquina de estados.

SettlementState: pantalla de liquidación. Muestra victoria/derrota, estadísticas y libera recursos de batalla.

El flujo: Boot → Home → Battle → Settlement → vuelta a Home (o salida).

Boceto en código:

Arranque → BootState
          ↓ (carga completa)
         HomeState
          ↓ (clic en empezar)
         BattleState ← submáquina de batalla aquí
          ↓ (fin de batalla)
      SettlementState
          ↓ (clic en volver)
         HomeState

Segunda capa: flujo de batalla

Dentro de BattleState hay otra submáquina. Better Programming indica que Battle State puede incluir BeginBattle, HeroTurn, EnemyTurn y EndBattle.

BeginBattleState: inicializa la batalla. Parámetros de nivel, datos de personajes, sincroniza el estado de todos los jugadores. En multijugador, esta fase lleva bloqueo — prohibido unirse o salir a mitad.

PlayerTurnState: turno del jugador. Jugar cartas, atacar, defender — todo en este estado. Al terminar el turno, pasa al siguiente o a liquidación.

EnemyTurnState (opcional): acción de la IA. En un minijuego en solitario puedes omitirlo; en multijugador es el turno del rival.

EndBattleState: lógica de liquidación. Decide ganador, calcula recompensas, dispara animación de cierre.

Flujo:

Entrar en BattleState → BeginBattleState (bloqueo, sincronización)
                      ↓ (listo)
                  PlayerTurnState
                      ↓ (fin de turno)
                  EnemyTurnState (opcional)
                      ↓ (fin de batalla)
                  EndBattleState
                      ↓ (liquidación completa)
             Salir de BattleState → SettlementState

Tercera capa: detalles de combate

Algunos estados se subdividen más. Por ejemplo, dentro de PlayerTurnState:

Máquina de estados de animación: al atacar, animación de ataque; al recibir daño, animación de herida. Unity y Cocos Creator traen máquinas de animación integradas; aquí no profundizamos.

Máquina de estados de turno: jugar carta → atacar → defender → fin de turno. Cada acción es un subestado.

El juego Beast Card Clash encapsula setup, scoring y pantalla de resultados en clases de estado independientes. La ventaja: cambiar la lógica de una fase no afecta a las demás.

La ventaja de tres capas es responsabilidades claras. Cambias el inicio sin romper la batalla. Cambias detalles del turno sin afectar la liquidación. También facilita el trabajo en equipo — una persona en flujo de batalla, otra en detalles.

Diseño de la interfaz central de la máquina de estados

Con la arquitectura en tres capas explicada, veamos el código.

Un artículo de Zhihu sobre Unity menciona que la interfaz unificada del nodo de estados es OnEnter, OnUpdate, OnExit, OnHandleEvent. Me inspiré en eso y escribí un conjunto en TypeScript.

Interfaz IGameState

Cada estado implementa esta interfaz:

interface IGameState {
  name: string;                    // Nombre del estado, útil para depuración
  
  enter(params?: any): void;       // Inicialización al entrar
  update(dt: number): void;        // Actualización por fotograma (opcional)
  exit(): void;                    // Limpieza al salir
  handleEvent(event: GameEvent): void;  // Maneja eventos y dispara transiciones
}

Los cinco métodos:

name: para depurar. En logs ves si estás en «HomeState» o «BattleState», más claro que mirar booleanos.

enter: se llama al entrar. En HomeState muestra la UI de inicio y carga datos del jugador. En BattleState inicializa parámetros y entra en la submáquina de batalla.

update: actualización por fotograma. Mucho en batalla — cuenta atrás, detectar acciones. En inicio casi no se usa.

exit: limpieza al salir. Libera recursos, oculta UI, cancela listeners.

handleEvent: transiciones por eventos. Clic en «Empezar partida» dispara START_BATTLE; HomeState lo maneja y pasa a BattleState.

GameStateManager — gestor de estados

La máquina necesita un gestor para cambiar de estado. Un artículo de Stack Exchange habla del diseño de CGameEngine: Init, Cleanup, ChangeState, PushState, PopState.

Lo simplifiqué así:

class GameStateManager {
  private currentState: IGameState | null = null;
  private stateStack: IGameState[] = [];  // Soporta estados superpuestos (pausa sobre batalla)
  
  // Inicialización
  init(firstState: IGameState) {
    this.currentState = firstState;
    this.currentState.enter();
  }
  
  // Cambio de estado
  changeState(newState: IGameState, params?: any) {
    if (this.currentState) {
      this.currentState.exit();
    }
    this.currentState = newState;
    this.currentState.enter(params);
  }
  
  // Apilar estado superpuesto (pausa, ventana modal)
  pushState(state: IGameState) {
    if (this.currentState) {
      // El estado actual no sale, solo se pausa
      this.stateStack.push(this.currentState);
    }
    this.currentState = state;
    state.enter();
  }
  
  // Desapilar estado superpuesto
  popState() {
    if (this.currentState) {
      this.currentState.exit();
    }
    this.currentState = this.stateStack.pop();
    // No hace falta enter de nuevo: solo estaba en pausa
  }
  
  // Actualización por fotograma
  update(dt: number) {
    if (this.currentState) {
      this.currentState.update(dt);
    }
  }
  
  // Manejo de eventos
  handleEvent(event: GameEvent) {
    if (this.currentState) {
      this.currentState.handleEvent(event);
    }
  }
}

changeState: cambio directo. Sale el viejo, entra el nuevo. De inicio a batalla.

pushState / popState: estados superpuestos. En batalla pulsas pausa: PauseState en la cima sobre BattleState. Al quitar pausa, PauseState sale y BattleState continúa.

Los estados superpuestos importan. Pausa, modales, confirmaciones — cubren el estado actual sin destruirlo.

Errores típicos en producción y soluciones

Con la teoría lista, hablemos de trampas reales. Sohu resume experiencia en juegos de cartas; amplío varios problemas clásicos.

Deriva de estados: servidor y cliente desincronizados

Es la trampa más común en multijugador.

Escenario: el servidor pasó a liquidación pero un cliente sigue en animación de batalla — retraso de red, no llegó el broadcast del cambio de estado. La UI dice «en batalla» pero la partida ya terminó.

Causa: mensaje de cambio de estado perdido o con demasiado retraso.

Solución:

  1. Sincronización de estado: al cambiar, el servidor hace broadcast a todos los clientes; al recibirlo, sincronizan de inmediato.

  2. Detección por latido: el cliente envía latidos al servidor cada pocos segundos con el estado actual. Si hay inconsistencia, el servidor fuerza un mensaje de sincronización.

// Latido en el cliente
class BattleState {
  enter() {
    this.startHeartbeat();
  }
  
  startHeartbeat() {
    setInterval(() => {
      socket.send({
        type: 'HEARTBEAT',
        state: this.manager.currentState.name,
        roomId: this.roomId
      });
    }, 3000);  // Cada 3 segundos
  }
}

Conflicto de concurrencia: varios jugadores actúan a la vez

En la mesa, dos jugadores juegan carta a la vez y el orden de transiciones se desordena.

Causa: sin bloqueo de estado; operaciones concurrentes sin cola.

Solución: bloqueo de estado + mecanismo de «operador actual».

class DealingState implements IGameState {
  private lock: boolean = true;
  private currentOperator: string = '';
  
  enter() {
    this.lock = true;  // Bloqueo en fase de reparto
    // Prohibir unirse o salir
    // Sincronizar datos iniciales de todos
    
    setTimeout(() => this.unlock(), 3000);  // Desbloqueo a los 3 segundos
  }
  
  unlock() {
    this.lock = false;
    this.currentOperator = this.getFirstPlayerId();
    this.manager.changeState(new PlayerTurnState());
  }
  
  handleEvent(event: GameEvent) {
    if (this.lock) {
      // En bloqueo, rechazar operación
      return;
    }
    
    // Solo el operador actual puede disparar eventos
    if (event.playerId === this.currentOperator) {
      // Procesar operación
    }
  }
}

El bloqueo asegura que en momentos críticos (reparto, liquidación) solo corre un flujo, sin interrupciones del jugador.

Seguridad en liquidación: cliente modificado sube puntuación falsa

El cliente sube datos de liquidación: puntuación, victoria/derrota. Si un cliente crackeado altera la puntuación, ¿cómo lo detecta el servidor?

Causa: el servidor confía en los datos del cliente.

Solución: cálculo independiente en servidor; no confiar en el resultado del cliente.

// Lógica de liquidación en servidor
class EndBattleState {
  enter() {
    // No aceptar puntuación subida por el cliente
    // El servidor calcula según el registro de batalla
    const result = this.calculateResultFromLog(battleLog);
    
    // Enviar resultado a todos los clientes
    this.broadcastResult(result);
  }
  
  calculateResultFromLog(log: BattleLog) {
    // Calcular según registro (orden de cartas, datos de ataque)
    // El log del cliente puede falsificarse; datos clave validados en servidor
  }
}

Principio central: el servidor no confía en el cliente. El cliente muestra; el servidor decide.

Bloqueo por tiempo de espera: un estado sin condición de salida

Un estado se queda colgado y nunca pasa al siguiente.

Causa: sin mecanismo de tiempo de espera; el evento esperado no llega.

Solución: temporizador de tiempo de espera en cada estado.

class PlayerTurnState implements IGameState {
  private timeoutTimer: number;
  
  enter() {
    this.timeoutTimer = setTimeout(() => {
      // Salto automático por tiempo de espera
      this.manager.handleEvent({
        type: 'TIMEOUT_SKIP',
        playerId: this.currentPlayer
      });
    }, 30000);  // 30 segundos de tiempo de espera
  }
  
  exit() {
    clearTimeout(this.timeoutTimer);  // Limpiar al salir
  }
}

El tiempo de espera importa. Jugador desconectado, red colgada — el estado no puede esperar indefinidamente; hace falta un plan B.

Cuatro trampas cubiertas. Esta experiencia pesa más que la teoría — yo sufrí la deriva de estados dos días hasta encontrar la causa. Tras añadir detección por latido, el problema desapareció.

Caso práctico en Cocos Creator

Con teoría, interfaz y trampas, veamos cómo aterrizarlo en Cocos Creator.

En el artículo anterior «Estructura de proyecto para minijuegos en Cocos Creator» hablamos de Boot, escenas y pantalla de liquidación. Aquí seguimos esa línea: cómo combinar la máquina de estados con Layer.

Máquina de estados con arquitectura de escena única

Cocos Creator recomienda para minijuegos escena única + varios Layer. Una escena, varios Layer que se muestran u ocultan. La máquina de estados encaja con esa arquitectura.

BootLayerHomeLayerBattleLayerSettlementLayer

Cada Layer corresponde a un estado:

import { director, Node } from 'cc';

// HomeState
class HomeState implements IGameState {
  name = 'Home';
  private homeLayer: Node | null = null;
  
  enter() {
    // Mostrar HomeLayer
    const scene = director.getScene();
    this.homeLayer = scene?.getChildByName('HomeLayer') ?? null;
    if (this.homeLayer) {
      this.homeLayer.active = true;
      this.initHomeUI();
    }
  }
  
  exit() {
    // Ocultar HomeLayer
    if (this.homeLayer) {
      this.homeLayer.active = false;
      this.cleanupHome();
    }
  }
  
  handleEvent(event: GameEvent) {
    if (event.type === 'START_BATTLE') {
      // Cambiar a BattleState
      this.manager.changeState(new BattleState(), event.params);
    }
  }
  
  initHomeUI() {
    // Cargar datos del jugador, eventos de botones
  }
  
  cleanupHome() {
    // Liberar recursos del inicio
  }
}

// BattleState
class BattleState implements IGameState {
  name = 'Battle';
  private battleLayer: Node | null = null;
  
  enter(params?: any) {
    const scene = director.getScene();
    this.battleLayer = scene?.getChildByName('BattleLayer') ?? null;
    if (this.battleLayer) {
      this.battleLayer.active = true;
      this.startBattle(params);
    }
  }
  
  exit() {
    if (this.battleLayer) {
      this.battleLayer.active = false;
      this.cleanupBattle();
    }
  }
  
  startBattle(params: any) {
    // Entrar en submáquina de batalla
    this.battleStateManager.init(new BeginBattleState(params));
  }
  
  cleanupBattle() {
    // Limpiar recursos de batalla
  }
}

Mostrar u ocultar Layer es la expresión visual del cambio de estado. enter muestra, exit oculta.

Máquina de estados y paso de datos

Al cambiar de estado puedes pasar parámetros. Si en inicio eligieron nivel, BattleState debe saber cuál:

// HomeState inicia el cambio
handleEvent(event: GameEvent) {
  if (event.type === 'START_BATTLE') {
    // Pasar parámetros de nivel
    this.manager.changeState(new BattleState(), {
      levelId: event.levelId,
      difficulty: event.difficulty
    });
  }
}

// BattleState recibe parámetros
enter(params?: any) {
  const levelId = params?.levelId ?? 'default';
  const difficulty = params?.difficulty ?? 'normal';
  this.loadLevel(levelId, difficulty);
}

Para persistencia usa localStorage. Si sales a mitad de batalla, al volver recuperas el progreso:

// Guardar progreso
exit() {
  localStorage.setItem('battle_progress', JSON.stringify({
    levelId: this.levelId,
    round: this.currentRound,
    score: this.score
  }));
}

// Recuperar progreso
enter() {
  const saved = localStorage.getItem('battle_progress');
  if (saved) {
    const progress = JSON.parse(saved);
    this.resumeFromProgress(progress);
  }
}

Consideraciones para minijuegos de WeChat

Un artículo de CSDN indica que conviene poner la máquina de estados en un archivo global para facilitar cambios.

Límite de paquete: el paquete principal de minijuegos WeChat es 4 MB. El código de la máquina de estados debe ser compacto; no abuses de clases.

Carga por subpaquetes: máquina central en paquete principal (Boot, Home, Battle); estados extendidos en subpaquetes (niveles especiales, pantallas de evento).

Archivo de estado global: en el archivo de entrada game.js inicializas la máquina; otros módulos acceden por variable global.

// game.js
import { GameStateManager } from './states/GameStateManager';
import { BootState } from './states/BootState';

// Gestor global de estados
window.gameStateManager = new GameStateManager();
window.gameStateManager.init(new BootState());

Al llamar APIs de WeChat, obtén el estado por variable global:

// Callback de login de WeChat
wx.login({
  success: (res) => {
    const state = window.gameStateManager.currentState;
    if (state.name === 'HomeState') {
      state.handleEvent({ type: 'LOGIN_SUCCESS', code: res.code });
    }
  }
});

Ventaja de esta arquitectura: las APIs de WeChat no necesitan importar la máquina de estados; acceden por variable global.


Este artículo encaja con el anterior «Estructura de proyecto para minijuegos en Cocos Creator». La estructura del proyecto es el contenedor; la máquina de estados es la lógica de comportamiento.

Resumen

En resumen, la máquina de estados descompone el flujo del juego en nodos claros.

La arquitectura en tres capas — flujo del juego, flujo de batalla, detalles de combate — sirve desde minijuegos hasta proyectos grandes. La ventaja: cambiar una capa no rompe las demás; también facilita el trabajo en equipo.

La experiencia con errores típicos pesa más que la teoría. Deriva de estados, conflictos de concurrencia, seguridad en liquidación, bloqueos por tiempo de espera — los he vivido y depurarlos duele. Tras añadir bloqueo de estados, detección por latido, validación en servidor y temporizadores, los problemas desaparecieron. Código bien hecho, menos bugs.

Si quieres probar, puedes descargar el ejemplo de código completo (enlace a GitHub). Lee primero «Estructura de proyecto para minijuegos en Cocos Creator» para entender cómo dividir Layer, y luego este para ver cómo corre la máquina de estados. Los dos juntos dejan la arquitectura clara.

En el siguiente artículo hablaré de pruebas de máquina de estados asistidas por IA — que la IA genere casos de prueba cubriendo rutas de transición. Con pruebas automatizadas, menos probabilidad de caer en las mismas trampas.

Diseñar una arquitectura de máquina de estados en tres capas para minijuegos

Flujo completo de gestión de estados desde el inicio hasta la batalla y la liquidación

⏱️ Estimated time: 60 min

  1. 1

    Step 1: Definir estados de la capa de flujo del juego

    Lista los estados principales del juego: BootState, HomeState, BattleState, SettlementState. Cada estado corresponde a un Layer o escena; define las reglas de transición entre estados.
  2. 2

    Step 2: Descomponer subestados de la capa de flujo de batalla

    Dentro de BattleState crea una submáquina de estados: BeginBattleState (inicialización con bloqueo), PlayerTurnState (turno del jugador), EnemyTurnState (turno de IA opcional), EndBattleState (decisión de liquidación).
  3. 3

    Step 3: Implementar la interfaz de estados y el gestor

    Crea la interfaz IGameState (name, enter, exit, update, handleEvent) e implementa GameStateManager para cambiar de estado (changeState) y operaciones de pila (pushState, popState).
  4. 4

    Step 4: Añadir mecanismos de protección ante errores típicos

    Bloquea en estados críticos (reparto de cartas, liquidación) para evitar conflictos por operaciones concurrentes; configura temporizadores de tiempo de espera para evitar bloqueos; implementa detección por latido para la deriva de estados; el servidor calcula de forma independiente para evitar trampas en la liquidación.
  5. 5

    Step 5: Conectar con Layer en Cocos Creator

    En enter muestra el Layer correspondiente e inicializa datos; en exit oculta el Layer y libera recursos. La máquina de estados impulsa el cambio de UI; la lógica de código queda desacoplada de la presentación visual.

FAQ

¿Es obligatorio usar una máquina de estados en un minijuego?
No necesariamente, pero cuando hay muchos estados (inicio, batalla, pausa, liquidación, etc.) los if-else se desordenan. La máquina de estados te ayuda a concentrar la lógica y a modificar con más tranquilidad.
¿Una máquina de estados de tres capas es sobreingeniería?
Un minijuego quizá no necesite tres capas, pero entender esta arquitectura te prepara para escenarios complejos. Juegos simples con dos capas; juegos complejos que se extienden a tres.
¿Qué diferencia hay entre máquina de estados y cambio de escena?
El cambio de escena es un concepto de gestión de recursos en Cocos; la máquina de estados es un concepto lógico. Una escena puede corresponder a un estado, y un estado también puede limitarse a mostrar u ocultar la UI.
¿Cómo resolver la deriva de estados en partidas multijugador?
Broadcast del servidor al cambiar de estado más detección por latido en el cliente. Un latido cada 3 segundos; si el servidor detecta inconsistencia, fuerza la sincronización.
¿Cómo evitar trampas en los datos de liquidación?
El servidor calcula de forma independiente y no confía en el resultado que sube el cliente. El cliente solo muestra; toda la lógica de decisión está en el servidor.

14 min de lectura · Publicado el: 19 may 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog