Changer le thème

Machine à états pour mini-jeux : du menu à la bataille jusqu'à la conclusion

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

"80 % des bugs des jeux de cartes viennent d'une mauvaise conception d'états ; la machine à états résout efficacement le problème des jugements d'état dispersés."

- Article d'architecture de jeux de cartes sur Sohu

"La machine à états imbriquée est devenue le modèle dominant des jeux complexes ; Battle State peut contenir quatre sous-états : BeginBattle, HeroTurn, EnemyTurn, EndBattle."

Votre mini-jeu vient de sortir et les joueurs signalent que l’écran de conclusion se bloque souvent — la partie est finie, mais l’interface affiche encore la bataille. Vous ouvrez le code : des if (isPlaying && !isPaused && !isGameOver && hasPlayerLeft) partout. À ce moment-là, une seule pensée : qui a écrit ce code ? Ah oui, c’est moi.

J’ai déjà marché dans ce piège sur un mini-jeu de cartes. Les jugements d’état étaient des variables booléennes éparpillées ; modifier une fonctionnalité impliquait de fouiller une dizaine de fichiers. Le pire en multijoueur : le serveur passait à la conclusion pendant qu’un client jouait encore l’animation de bataille — dérive d’état des deux côtés. Après refactorisation avec une machine à états, le code est devenu bien plus lisible.

Cet article explique comment concevoir une machine à états pour mini-jeux. Du menu à la bataille jusqu’à la conclusion, je détaille une architecture à trois niveaux : flux de jeu, flux de bataille et détails de combat. Plus les retours d’expérience : verrous d’état, validation serveur, mécanismes de timeout — ce qui compte vraiment en production.

Pourquoi les mini-jeux ont besoin d’une machine à états ?

En bref, un jeu, c’est une série d’états qui s’enchaînent.

Menu, bataille, conclusion, pause, spectateur — chaque écran correspond à un état. Le joueur clique sur « Démarrer », le menu disparaît, l’interface de bataille apparaît. La partie se termine, l’écran de conclusion s’affiche. Simple en théorie, chaotique en code.

Les trois péchés de la cascade if-else

Voici un extrait que vous avez probablement déjà vu :

// jugements d'état dispersés
if (isPlaying && !isPaused && !isGameOver) {
  // le joueur peut agir
}

if (isGameOver && !hasShownResult) {
  // afficher l'écran de conclusion
}

if (isPlaying && currentPlayer === 'player1') {
  // tour du joueur 1
}

Trois problèmes majeurs :

Illisible : pour savoir où en est le jeu, il faut parcourir tout le fichier à la recherche de booléens. isPlaying, isPaused, isGameOver — des fragments éparpillés, un puzzle à reconstituer vous-même.

Difficile à modifier : ajouter un état « pause » ? Il faut toucher une dizaine de if. Oublier un seul endroit, et le bug apparaît. Une fois, j’ai ajouté le mode spectateur : deux jours de modifications, et à la mise en ligne une animation ne se mettait toujours pas en pause.

Difficile à déboguer : si l’ordre des transitions se mélange, impossible de savoir où ça casse. isGameOver passe à true, mais hasShownResult reste false — quel test manque ?

Un article Sohu mentionne que 80 % des bugs des jeux de cartes viennent d’une mauvaise conception d’états. Le chiffre impressionne, mais ceux qui l’ont vécu savent que c’est réel.

Comment la machine à états résout ces problèmes ?

Elle regroupe tous ces jugements dispersés en un seul endroit.

Chaque état définit son comportement : quoi faire à l’entrée, pendant l’exécution, à la sortie. Les règles de transition sont explicites : du menu on ne peut aller qu’à la bataille, pas directement à la conclusion.

Comparaison :

// approche machine à états
class HomeState {
  enter() { showHomeUI(); }
  exit() { hideHomeUI(); }
  handleEvent(event) {
    if (event === 'START_BATTLE') {
      manager.changeState(new BattleState());
    }
  }
}

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

L’état est encapsulé. Modifier HomeState n’impacte pas BattleState. Pour un nouvel état, il suffit d’une nouvelle classe State et d’une règle de transition.

Better Programming indique que la machine à états imbriquée est devenue le modèle dominant des jeux complexes. Les mini-jeux ne font pas exception — des états clairs, un code clair.

Architecture à trois niveaux en détail

La machine à états d’un mini-jeu doit être stratifiée. Sans séparation, la logique de bataille finit noyée dans la couche flux de jeu et le code grossit sans fin.

Je la découpe en trois niveaux : flux de jeu, flux de bataille et détails de combat.

Premier niveau : couche flux de jeu

C’est la colonne vertébrale du jeu, du lancement à la conclusion :

BootState : au démarrage, chargement des ressources et initialisation du moteur. La scène Boot de Cocos Creator sert exactement à cela — Boot d’abord, puis Home.

HomeState : menu principal. Le joueur choisit un niveau, un personnage, consulte le classement. Logique relativement simple : afficher l’UI, gérer les clics.

BattleState : scène de bataille. Ce n’est pas une feuille terminale, mais un conteneur — une sous-machine à états vit à l’intérieur.

SettlementState : écran de conclusion. Affiche victoire ou défaite, statistiques, nettoie les ressources de bataille.

Le flux : Boot → Home → Battle → Settlement → retour à Home (ou sortie).

Schéma :

Démarrage → BootState
          ↓ (chargement terminé)
         HomeState
          ↓ (clic Démarrer)
         BattleState ← sous-machine de bataille ici
          ↓ (bataille terminée)
      SettlementState
          ↓ (clic Retour)
         HomeState

Deuxième niveau : couche flux de bataille

BattleState contient sa propre sous-machine. Better Programming mentionne que Battle State peut inclure BeginBattle, HeroTurn, EnemyTurn et EndBattle.

BeginBattleState : initialisation de la bataille. Paramètres de niveau, données des personnages, synchronisation de tous les joueurs. En multijoueur, cette phase se verrouille — interdiction de rejoindre ou quitter en cours.

PlayerTurnState : tour du joueur. Jouer une carte, attaquer, défendre — toutes ces actions sont gérées ici. À la fin du tour, passage au tour suivant ou à la conclusion.

EnemyTurnState (optionnel) : actions de l’IA. Ignorable en solo ; en multijoueur, c’est le tour de l’adversaire.

EndBattleState : logique de conclusion. Décision victoire/défaite, calcul des récompenses, déclenchement de l’animation de fin.

Flux :

Entrée BattleState → BeginBattleState (verrou, sync)
                  ↓ (prêt)
              PlayerTurnState
                  ↓ (fin de tour)
              EnemyTurnState (optionnel)
                  ↓ (bataille terminée)
              EndBattleState
                  ↓ (conclusion terminée)
         Sortie BattleState → SettlementState

Troisième niveau : couche détails de combat

Certains états se subdivisent encore. Par exemple PlayerTurnState peut contenir :

Machine à états d’animation : animation d’attaque à l’attaque, animation de dégâts aux dégâts. Unity et Cocos Creator intègrent des machines à états d’animation — on n’approfondit pas ici.

Machine à états de tour : jouer une carte → attaquer → défendre → fin de tour. Chaque action est un sous-état.

Le jeu Beast Card Clash encapsule setup, scoring et results screen en classes d’état indépendantes. Avantage : modifier une phase n’affecte pas les autres.

L’intérêt de trois niveaux : responsabilités distinctes. Modifier le menu ne casse pas la bataille. Modifier un détail de tour n’impacte pas la conclusion. La collaboration en équipe s’en trouve facilitée — une personne sur le flux de bataille, une autre sur les détails.

Conception de l’interface centrale

Architecture à trois niveaux posée, passons au code.

Un article Zhihu sur Unity mentionne que l’interface unifiée d’un nœud de machine à états repose sur OnEnter, OnUpdate, OnExit, OnHandleEvent. Je m’en suis inspiré pour une version TypeScript.

Interface IGameState

Chaque état implémente cette interface :

interface IGameState {
  name: string;                    // nom d'état pour le débogage
  
  enter(params?: any): void;       // initialisation à l'entrée
  update(dt: number): void;        // mise à jour par frame (optionnel)
  exit(): void;                    // nettoyage à la sortie
  handleEvent(event: GameEvent): void;  // traiter les événements et déclencher les transitions
}

Rôle des cinq méthodes :

name : pour le débogage. Les logs affichent « HomeState » ou « BattleState », plus parlant qu’une pile de booléens.

enter : appelé à l’entrée. HomeState affiche l’UI du menu et charge les données joueur. BattleState initialise les paramètres de bataille et entre dans la sous-machine.

update : mise à jour par frame. Utile en bataille — compte à rebours, détection des actions. Peu utilisé au menu.

exit : nettoyage à la sortie. Libération des ressources, masquage de l’UI, désabonnement des écouteurs.

handleEvent : transitions pilotées par événements. Un clic « Démarrer » émet START_BATTLE ; HomeState le traite et bascule vers BattleState.

GameStateManager

La machine à états a besoin d’un gestionnaire pour les transitions. Un article Stack Exchange décrit la classe CGameEngine : Init, Cleanup, ChangeState, PushState, PopState.

Version simplifiée :

class GameStateManager {
  private currentState: IGameState | null = null;
  private stateStack: IGameState[] = [];  // états superposés (pause au-dessus de la bataille)
  
  // initialisation
  init(firstState: IGameState) {
    this.currentState = firstState;
    this.currentState.enter();
  }
  
  // changement d'état
  changeState(newState: IGameState, params?: any) {
    if (this.currentState) {
      this.currentState.exit();
    }
    this.currentState = newState;
    this.currentState.enter(params);
  }
  
  // empiler un état superposé (pause, pop-up)
  pushState(state: IGameState) {
    if (this.currentState) {
      // l'état courant reste en pause, sans exit
      this.stateStack.push(this.currentState);
    }
    this.currentState = state;
    state.enter();
  }
  
  // dépiler l'état superposé
  popState() {
    if (this.currentState) {
      this.currentState.exit();
    }
    this.currentState = this.stateStack.pop();
    // pas de re-enter : l'état précédent était seulement en pause
  }
  
  // mise à jour par frame
  update(dt: number) {
    if (this.currentState) {
      this.currentState.update(dt);
    }
  }
  
  // traitement des événements
  handleEvent(event: GameEvent) {
    if (this.currentState) {
      this.currentState.handleEvent(event);
    }
  }
}

changeState : remplace l’état courant. L’ancien sort, le nouveau entre. Menu → bataille.

pushState / popState : états superposés. En bataille, la touche pause pousse PauseState au-dessus de BattleState. Annuler la pause fait pop PauseState et BattleState reprend.

Les états superposés comptent beaucoup. Pause, pop-up, boîte de confirmation — ils recouvrent l’état courant sans le détruire.

Pièges en production et solutions

La théorie posée, les pièges vécus. Un article Sohu résume l’expérience des jeux de cartes ; j’en développe quelques-uns.

Dérive d’état : serveur et client désynchronisés

Le piège le plus fréquent en multijoueur.

Scénario : le serveur est en conclusion, mais un client joue encore l’animation de bataille — latence réseau, message de transition non reçu. L’interface affiche « en bataille » alors que la partie est finie.

Cause : message de changement d’état perdu ou trop tardif.

Solution :

  1. Synchronisation d’état : à chaque transition serveur, diffusion à tous les clients. Réception immédiate = synchronisation immédiate.

  2. Heartbeat : le client envoie périodiquement son état courant au serveur. En cas d’incohérence, le serveur force une resynchronisation.

// heartbeat client
class BattleState {
  enter() {
    this.startHeartbeat();
  }
  
  startHeartbeat() {
    setInterval(() => {
      socket.send({
        type: 'HEARTBEAT',
        state: this.manager.currentState.name,
        roomId: this.roomId
      });
    }, 3000);  // toutes les 3 secondes
  }
}

Conflits concurrents : actions simultanées

À la table, deux joueurs jouent une carte en même temps — l’ordre des transitions se mélange.

Cause : pas de verrou d’état, pas de file d’attente pour les opérations concurrentes.

Solution : verrou d’état + mécanisme « opérateur courant ».

class DealingState implements IGameState {
  private lock: boolean = true;
  private currentOperator: string = '';
  
  enter() {
    this.lock = true;  // verrou en phase de distribution
    // interdire join/quit des joueurs
    // synchroniser les données initiales de tous
    
    setTimeout(() => this.unlock(), 3000);  // déverrouillage après 3 s
  }
  
  unlock() {
    this.lock = false;
    this.currentOperator = this.getFirstPlayerId();
    this.manager.changeState(new PlayerTurnState());
  }
  
  handleEvent(event: GameEvent) {
    if (this.lock) {
      // état verrouillé, refuser l'opération
      return;
    }
    
    // seul l'opérateur courant peut déclencher un événement
    if (event.playerId === this.currentOperator) {
      // traiter l'opération
    }
  }
}

Le verrou garantit qu’aux moments critiques (distribution, conclusion), un seul flux s’exécute, sans interruption par les actions des joueurs.

Sécurité de la conclusion : scores falsifiés par un client modifié

Le client envoie score et résultat. Un client cracké modifie le score — comment le serveur le détecte ?

Cause : le serveur fait confiance aux données client.

Solution : calcul indépendant côté serveur, sans faire confiance au client.

// logique de conclusion côté serveur
class EndBattleState {
  enter() {
    // ne pas accepter le score envoyé par le client
    // calcul indépendant côté serveur à partir du journal de bataille
    const result = this.calculateResultFromLog(battleLog);
    
    // envoyer le résultat à tous les clients
    this.broadcastResult(result);
  }
  
  calculateResultFromLog(log: BattleLog) {
    // calcul indépendant à partir du journal (ordre des cartes, données d'attaque)
    // le journal client peut être falsifié ; valider les données clés côté serveur
  }
}

Principe central : le serveur ne fait pas confiance au client. Le client affiche ; le serveur décide.

Blocage par timeout : état sans condition de sortie

Un état reste bloqué indéfiniment, sans jamais passer au suivant.

Cause : pas de timeout, événement attendu jamais déclenché.

Solution : timeout sur chaque état.

class PlayerTurnState implements IGameState {
  private timeoutTimer: number;
  
  enter() {
    this.timeoutTimer = setTimeout(() => {
      // saut automatique en cas de timeout
      this.manager.handleEvent({
        type: 'TIMEOUT_SKIP',
        playerId: this.currentPlayer
      });
    }, 30000);  // timeout 30 s
  }
  
  exit() {
    clearTimeout(this.timeoutTimer);  // nettoyer le timeout à la sortie
  }
}

Le timeout est essentiel. Joueur déconnecté, réseau bloqué — l’état ne peut pas attendre indéfiniment ; il faut un filet de sécurité.

Quatre pièges couverts. Cette expérience prime sur la théorie — j’ai passé deux jours à traquer une dérive d’état. Le heartbeat a réglé le problème.

Cas pratique Cocos Creator

Théorie, interfaces et pièges en place — voyons la mise en œuvre dans Cocos Creator.

L’article précédent « Structure de projet pour mini-jeux Cocos Creator » détaillait Boot, scènes et page de conclusion. Celui-ci prolonge la même logique : comment lier machine à états et Layers.

Machine à états en architecture scène unique

Cocos Creator recommande pour les mini-jeux : une scène, plusieurs Layers affichés ou masqués. La machine à états s’y accorde parfaitement.

BootLayerHomeLayerBattleLayerSettlementLayer

Chaque Layer correspond à un état :

import { director, Node } from 'cc';

// HomeState
class HomeState implements IGameState {
  name = 'Home';
  private homeLayer: Node | null = null;
  
  enter() {
    // afficher HomeLayer
    const scene = director.getScene();
    this.homeLayer = scene?.getChildByName('HomeLayer') ?? null;
    if (this.homeLayer) {
      this.homeLayer.active = true;
      this.initHomeUI();
    }
  }
  
  exit() {
    // masquer HomeLayer
    if (this.homeLayer) {
      this.homeLayer.active = false;
      this.cleanupHome();
    }
  }
  
  handleEvent(event: GameEvent) {
    if (event.type === 'START_BATTLE') {
      // basculer vers BattleState
      this.manager.changeState(new BattleState(), event.params);
    }
  }
  
  initHomeUI() {
    // charger les données joueur, configurer les clics boutons
  }
  
  cleanupHome() {
    // libérer les ressources du menu
  }
}

// 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) {
    // entrer dans la sous-machine de bataille
    this.battleStateManager.init(new BeginBattleState(params));
  }
  
  cleanupBattle() {
    // nettoyer les ressources de bataille
  }
}

Afficher ou masquer un Layer, c’est la traduction visuelle d’un changement d’état. enter affiche, exit masque.

Passage de données entre états

Transmettre des paramètres lors d’une transition. Le joueur choisit un niveau au menu ; BattleState doit le connaître :

// HomeState déclenche la transition
handleEvent(event: GameEvent) {
  if (event.type === 'START_BATTLE') {
    // transmettre les paramètres de niveau
    this.manager.changeState(new BattleState(), {
      levelId: event.levelId,
      difficulty: event.difficulty
    });
  }
}

// BattleState reçoit les paramètres
enter(params?: any) {
  const levelId = params?.levelId ?? 'default';
  const difficulty = params?.difficulty ?? 'normal';
  this.loadLevel(levelId, difficulty);
}

Persistance via localStorage. Quitter en cours de bataille, reprendre à la prochaine session :

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

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

Points d’attention pour les mini-jeux WeChat

Un article CSDN mentionne : placer la machine à états dans un fichier global facilite les modifications.

Limite de taille du paquet : le paquet principal WeChat est limité à 4 Mo. Le code de la machine à états doit rester concis, sans trop de classes.

Chargement par sous-paquets : machine à états cœur dans le paquet principal (Boot, Home, Battle) ; états étendus en sous-paquets (niveaux spéciaux, écrans d’événement).

Fichier d’état global : initialiser la machine à états dans le point d’entrée game.js ; les autres modules y accèdent via une variable globale.

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

// gestionnaire d'état global
window.gameStateManager = new GameStateManager();
window.gameStateManager.init(new BootState());

Lors des appels API WeChat, récupérer l’état via la variable globale :

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

Avantage : les API WeChat n’importent pas la machine à états ; la variable globale suffit.


Lisez cet article avec le précédent « Structure de projet pour mini-jeux Cocos Creator » pour une vision complète. La structure de projet est le conteneur ; la machine à états en est la logique comportementale.

Conclusion

En résumé, la machine à états découpe le flux de jeu en nœuds clairs.

L’architecture à trois niveaux — flux de jeu, flux de bataille, détails de combat — s’adapte des mini-jeux aux grands projets. Modifier une couche n’affecte pas les autres ; la collaboration en équipe s’en trouve simplifiée.

L’expérience des pièges prime sur la théorie. Dérive d’état, conflits concurrents, sécurité de la conclusion, blocage par timeout — j’ai vécu ces quatre problèmes, l’investigation était pénible. Verrous d’état, heartbeat, validation serveur et timeouts ont tout réglé. Un code bien structuré, c’est moins de bugs.

Pour vous lancer, téléchargez l’exemple de code complet (lien GitHub). Commencez par « Structure de projet pour mini-jeux Cocos Creator » pour comprendre le découpage des Layers, puis revenez ici pour voir comment la machine à états s’exécute. Les deux articles ensemble, l’architecture devient claire.

Prochain sujet : tests de machine à états assistés par IA — générer des cas de test couvrant tous les chemins de transition. Automatiser les tests, c’est réduire encore la probabilité de pièges.

Concevoir une architecture à trois niveaux de machine à états pour mini-jeux

Flux complet de gestion d'états du menu à la bataille jusqu'à la conclusion

⏱️ Estimated time: 60 min

  1. 1

    Step 1: Définir les états de la couche flux de jeu

    Lister les états principaux : BootState, HomeState, BattleState, SettlementState. Chaque état correspond à un Layer ou une scène ; définir les règles de transition entre eux.
  2. 2

    Step 2: Découper les sous-états de la couche flux de bataille

    À l'intérieur de BattleState, créer une sous-machine : BeginBattleState (initialisation verrouillée), PlayerTurnState (tour du joueur), EnemyTurnState (tour IA optionnel), EndBattleState (décision de conclusion).
  3. 3

    Step 3: Implémenter l'interface d'état et le gestionnaire

    Créer l'interface IGameState (name, enter, exit, update, handleEvent) et GameStateManager pour les changements d'état (changeState) et les opérations de pile (pushState, popState).
  4. 4

    Step 4: Ajouter des mécanismes de protection contre les pièges

    Verrouiller les états critiques (distribution, conclusion) pour éviter les conflits concurrents, définir des timeouts contre les blocages, implémenter le heartbeat contre la dérive d'état, calcul serveur indépendant contre la triche à la conclusion.
  5. 5

    Step 5: Connecter aux Layers Cocos Creator

    Dans enter, afficher le Layer correspondant et initialiser les données ; dans exit, masquer le Layer et libérer les ressources. La machine à états pilote le changement d'UI, découplée de la logique métier.

FAQ

Faut-il absolument une machine à états pour un mini-jeu ?
Pas obligatoire, mais dès que les états se multiplient (menu, bataille, pause, conclusion), les if-else deviennent ingérables. La machine à états regroupe la logique et rend les modifications plus sûres.
Trois niveaux, n'est-ce pas du surdesign ?
Un mini-jeu simple peut se contenter de deux niveaux, mais comprendre cette architecture aide à gérer les scénarios complexes. Deux niveaux pour les jeux simples, trois pour les jeux plus riches.
Quelle différence entre machine à états et changement de scène ?
Le changement de scène est un concept de gestion des ressources Cocos ; la machine à états est un concept logique. Une scène peut correspondre à un état, et un état peut aussi se limiter à afficher ou masquer une UI.
Comment résoudre la dérive d'état en multijoueur ?
Diffusion côté serveur des changements d'état + détection par heartbeat côté client. Un heartbeat toutes les 3 secondes ; le serveur force la synchronisation en cas d'incohérence.
Comment empêcher la triche sur les données de conclusion ?
Calcul indépendant côté serveur, sans faire confiance aux résultats envoyés par le client. Le client affiche seulement ; toute la logique de décision reste côté serveur.

13 min de lecture · Publié le: 19 mai 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog