Principes clés
-
Le budget frame est une loi - Chaque choix de pattern doit respecter le budget d'environ 16ms. Les allocations pendant le gameplay causent des pics de garbage collection. L'indirection a un coût en cache. Profilez toujours avant d'ajouter de l'abstraction.
-
Découpler, mais pas infiniment - Les systèmes de jeu doivent communiquer via des événements et des commandes plutôt que des références directes, mais un découplage excessif crée des cauchemars de débogage. Un niveau d'indirection suffit généralement.
-
L'état est explicite - L'état implicite (drapeaux booléens imbriqués, entiers de mode) mène à des combinaisons impossibles et des bugs subtils. Faites de chaque état valide un objet de première classe avec des transitions définies.
-
Poolez ce que vous créez - Toute entité créée et détruite plus d'une fois par seconde devrait être poolée. Le coût de l'allocation n'est pas le constructeur - c'est la pause du garbage collector 3 secondes plus tard.
-
Les commandes sont des données - Quand les actions d'entrée sont des objets plutôt que des appels de méthode directs, vous obtenez l'undo, la rejeu, la mise en réseau et l'IA « gratuitement ». Le pattern Command est le pattern avec le meilleur effet de levier dans le code de gameplay.
Concepts fondamentaux
Les machines d'états modélisent les entités qui ont des modes comportementaux distincts. Un personnage peut être Idle (inactif), Running (en train de courir), Jumping (en train de sauter) ou Attacking (en train d'attaquer) - mais jamais Jumping et Idle en même temps. Chaque état encapsule sa propre logique de mise à jour, comportement d'entrée/sortie et transitions valides. Les machines d'états hiérarchiques (HFSM) ajoutent des sous-états imbriqués pour l'IA complexe.
L'object pooling pré-alloue un ensemble fixe d'objets et les recycle plutôt que de créer et détruire des instances à l'exécution. Le pool maintient une liste « disponible » et distribue des objets pré-initialisés sur demande, les récupérant quand ils sont « tués ». Cela élimine la pression d'allocation pendant le gameplay.
Les systèmes d'événements (aussi appelés observer, pub/sub ou message bus) permettent aux systèmes de jeu de communiquer sans références directes. Quand un joueur subit des dégâts, le système de santé déclenche un événement DamageTaken. Les systèmes d'UI, audio, camera shake et analytics s'abonnent indépendamment. Ajouter une nouvelle réaction ne nécessite aucun changement au code de dégâts.
Le pattern Command encapsule une action en tant qu'objet avec execute() et optionnellement undo(). L'entrée du joueur devient un flux d'objets command. Cela permet la redistribution des touches, l'enregistrement de rejeu, l'undo/redo dans les éditeurs, et l'envoi de commandes sur le réseau pour le multijoueur.
Tâches courantes
Implémenter une machine d'états finis pour le comportement du personnage
Chaque état est une classe avec enter(), update(), exit() et une vérification de transition.
La machine tient l'état courant et le délègue.
interface State {
enter(): void;
update(dt: number): void;
exit(): void;
}
class IdleState implements State {
constructor(private character: Character) {}
enter() { this.character.playAnimation("idle"); }
update(dt: number) {
if (this.character.input.jump) {
this.character.fsm.transition(new JumpState(this.character));
}
}
exit() {}
}
class StateMachine {
private current: State;
transition(next: State) {
this.current.exit();
this.current = next;
this.current.enter();
}
update(dt: number) {
this.current.update(dt);
}
}
Évitez les noms d'états basés sur des chaînes. Utilisez des classes d'état typées pour que le compilateur détecte les transitions invalides.
Construire un object pool
Pré-allouez les objets au démarrage. acquire() retourne une instance recyclée ; release() la retourne au pool. Ne jamais allouer pendant le gameplay.
class ObjectPool<T> {
private available: T[] = [];
private active: Set<T> = new Set();
constructor(
private factory: () => T,
private reset: (obj: T) => void,
initialSize: number
) {
for (let i = 0; i < initialSize; i++) {
this.available.push(this.factory());
}
}
acquire(): T | null {
if (this.available.length === 0) return null;
const obj = this.available.pop()!;
this.active.add(obj);
return obj;
}
release(obj: T): void {
if (!this.active.has(obj)) return;
this.active.delete(obj);
this.reset(obj);
this.available.push(obj);
}
}
// Usage: bullet pool
const bulletPool = new ObjectPool(
() => new Bullet(),
(b) => { b.active = false; b.position.set(0, 0); },
200
);
Dimensionnez le pool à votre pic de charge dans le pire cas. Si
acquire()retourne null, soit augmentez le pool (avec un log d'avertissement), soit ignorez le spawn - ne jamais allouer en ligne.
Configurer un système d'événements typé
Utilisez un event bus type-safe pour que les abonnés sachent exactement quel payload attendre.
type EventMap = {
"damage-taken": { target: Entity; amount: number; source: Entity };
"enemy-killed": { enemy: Entity; killer: Entity; score: number };
"level-complete": { level: number; time: number };
};
class EventBus {
private listeners = new Map<string, Set<Function>>();
on<K extends keyof EventMap>(event: K, handler: (data: EventMap[K]) => void) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(handler);
return () => this.listeners.get(event)!.delete(handler); // unsubscribe
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
this.listeners.get(event)?.forEach(fn => fn(data));
}
}
// Usage
const bus = new EventBus();
const unsub = bus.on("damage-taken", ({ target, amount }) => {
healthBar.update(target.id, amount);
});
Toujours retourner une fonction d'abonnement. Les abonnements non libérés d'entités détruites sont le bug #1 des systèmes d'événements dans les jeux.
Implémenter le pattern Command pour l'entrée avec undo
Chaque action du joueur est un objet command. Stockez une pile d'historique pour l'undo.
interface Command {
execute(): void;
undo(): void;
}
class MoveCommand implements Command {
private previousPosition: Vector2;
constructor(private entity: Entity, private direction: Vector2) {}
execute() {
this.previousPosition = this.entity.position.clone();
this.entity.position.add(this.direction);
}
undo() {
this.entity.position.copy(this.previousPosition);
}
}
class CommandHistory {
private history: Command[] = [];
private pointer = -1;
execute(cmd: Command) {
// Discard any redo history
this.history.length = this.pointer + 1;
cmd.execute();
this.history.push(cmd);
this.pointer++;
}
undo() {
if (this.pointer < 0) return;
this.history[this.pointer].undo();
this.pointer--;
}
redo() {
if (this.pointer >= this.history.length - 1) return;
this.pointer++;
this.history[this.pointer].execute();
}
}
Pour les systèmes de rejeu, sérialisez les commandes avec des timestamps. Rejeu = alimenter le même flux de commandes à un état de jeu frais.
Utiliser une machine d'états hiérarchique pour l'IA complexe
Quand une seule FSM a trop d'états, utilisez des sous-états. Un état « Combat » peut contenir des sous-états « Attacking » (en train d'attaquer), « Flanking » (en train de contourner) et « Retreating » (en train de se retirer).
class HierarchicalState implements State {
protected subMachine: StateMachine;
enter() { this.subMachine.transition(this.getInitialSubState()); }
update(dt: number) { this.subMachine.update(dt); }
exit() { this.subMachine.currentState?.exit(); }
protected getInitialSubState(): State {
throw new Error("Override in subclass");
}
}
class CombatState extends HierarchicalState {
constructor(private ai: AIController) {
super();
this.subMachine = new StateMachine();
}
protected getInitialSubState(): State {
return new AttackingSubState(this.ai);
}
}
Limitez l'imbrication à 2 niveaux. Trois niveaux ou plus de hiérarchie indique que vous avez besoin d'un behavior tree à la place.
Implémenter le pattern Command pour l'entrée multijoueur
Envoyez des commandes sur le réseau au lieu de l'état. Les deux clients exécutent le même flux de commandes de façon déterministe.
interface NetworkCommand extends Command {
serialize(): ArrayBuffer;
readonly playerId: string;
readonly frame: number;
}
class NetworkCommandBuffer {
private buffer: Map<number, NetworkCommand[]> = new Map();
addCommand(frame: number, cmd: NetworkCommand) {
if (!this.buffer.has(frame)) this.buffer.set(frame, []);
this.buffer.get(frame)!.push(cmd);
}
getCommandsForFrame(frame: number): NetworkCommand[] {
return this.buffer.get(frame) ?? [];
}
}
Le lockstep déterministe nécessite que tous les clients traitent exactement les mêmes commandes dans le même ordre de frame. Les différences en virgule flottante entre les plateformes causent une désynchronisation - utilisez les mathématiques en point fixe pour l'état critique.
Anti-patterns / erreurs courantes
| Erreur | Pourquoi c'est faux | Que faire à la place |
|---|---|---|
| Drapeaux d'état booléens | isJumping && !isAttacking && isDashing crée des combinaisons impossibles à déboguer |
Utilisez une machine d'états explicite avec des états typés |
| Allocer dans la boucle chaude | new Bullet() à chaque frame cause des pauses GC et des chutes d'images |
Poolez tous les objets fréquemment créés |
| Event bus dieu | Chaque système s'abonne à tout sur un seul bus global | Limitez les buses par domaine (bus combat, bus UI) ou utilisez des listeners directs pour les couplages serrés |
| Commandes sans undo | Implémenter execute() mais ignorer undo() pour la « simplicité » |
Toujours implémenter undo() même s'il n'est pas utilisé maintenant - le rejeu et le débogage en ont besoin |
| Événements stringly-typés | Utiliser des chaînes brutes comme "dmg" au lieu de noms d'événements typés |
Utilisez un EventMap typé (TypeScript) ou des clés basées sur enum pour que les fautes de frappe soient des erreurs de compilation |
| Historique de commandes non limité | Stocker chaque commande pour toujours fuit la mémoire dans les sessions longues | Limitez la longueur de l'historique ou utilisez checkpoint + troncature périodiquement |
| Transitions spaghetti | Chaque état peut faire la transition vers tous les autres états | Définissez une table de transition d'avance. Si une transition n'est pas dans la table, elle est illégale |
Pièges
-
Les object pools dimensionnés pour la charge moyenne, pas la charge de pic, causent des spawns manqués - Si vous dimensionnez un pool de balles pour « en moyenne 50 balles » mais le combat de boss en tire 200 en 2 secondes,
acquire()retourne null et les balles ne se créent pas en silence. Toujours dimensionner les pools au pic dans le pire cas de votre jeu, ajoutez l'expansion de pool avec un log d'avertissement, et testez le scénario de pic explicitement. -
Les transitions de machine d'états qui allouent de nouveaux objets State causent de la pression GC - Si chaque appel de
transition()faitnew JumpState(character), vous allouez pendant le gameplay, ce qui déclenche des pauses de garbage collection. Pré-allouez toutes les instances d'état au démarrage et stockez-les dans un dictionnaire ; faites la transition en échangeant les références, pas en créant de nouveaux objets. -
Les abonnements au event bus d'entités détruites causent des crashes de référence null - Quand un objet de jeu est détruit sans se désabonner de ses event handlers, le prochain dispatch d'événement appelle un handler avec un contexte
thisnull et crashe ou produit un état obsolète. Toujours stocker et invoquer la fonction d'abonnement retournée paron()dans le chemin de destruction/nettoyage de l'entité. -
L'historique de commandes grandit sans limite dans les sessions longues - Stocker chaque commande depuis le démarrage de la session pour un système d'undo consommera de la mémoire croissante au fil des heures de gameplay. Limitez l'historique de commandes à une profondeur maximale (par exemple 100 commandes) ou utilisez checkpoint-and-truncate périodiquement. Pour les systèmes de rejeu, les commandes antérieures au checkpoint peuvent être supprimées.
-
Le lockstep déterministe se brise silencieusement sur les opérations en virgule flottante - Deux clients exécutant le même flux de commandes désynchroniseront si un calcul de physique ou de mouvement utilise les mathématiques en virgule flottante, car les résultats IEEE 754 peuvent différer selon les architectures CPU et les optimisations du compilateur. Utilisez l'arithmétique en point fixe pour tout état de jeu qui doit être déterministe entre les clients.
Références
Pour du contenu détaillé sur les patterns spécifiques, lisez le fichier pertinent dans references/ :
references/state-machines.md- FSMs hiérarchiques, automates à pile, comparaison avec les behavior trees, et conception de tables de transitionreferences/object-pooling.md- Stratégies de dimensionnement de pool, patterns de préchauffage, thread safety, et considérations de GC spécifiques au langagereferences/event-systems.md- Event queue vs dispatch immédiat, ordonnancement des priorités, filtrage d'événements, et débogage des abonnements non libérésreferences/command-pattern.md- Sérialisation pour rejeu/mise en réseau, enregistrement de macros, commandes composites, et gestion de la pile d'undo
Chargez un fichier references uniquement si la tâche courante nécessite des détails profonds sur ce sujet.