← На главную
Руководство

FSM в сценах HTML5-игр

Поле Block Puzzle, связанное со схемой конечного автомата

В этой статье я показываю, как управляю сценой через FSM в @gamedevland/engine на примере запуска, размещения фигуры, проверки ходов, завершения и перезапуска Block Puzzle.

Что такое FSM?

FSM расшифровывается как Finite State Machine — конечный автомат.

FSM хранит текущее состояние системы и описывает, что должно происходить при поступлении нового события.

В Block Puzzle событие PlacementRequested переводит сцену из playing в resolvingPlacement.

Элементы FSM

01 Состояния Режимы, в которых может находиться сцена
02 События Факты и запросы, на которые реагирует сцена
03 Переходы Допустимые пути между состояниями
04 Действия Команды, выполняемые при переходе или входе в состояние

«Конечный» означает, что набор состояний определён заранее. Конфигурация FSM становится полной картой допустимых переходов.

Зачем игровой сцене FSM

Без FSM состояние сцены обычно оказывается разбросано по компонентам и флагам: можно ли перетаскивать фигуру, обрабатывается ли размещение, завершилась ли игра и доступен ли перезапуск.

Проблема не в самих флагах, а в разрозненных правилах переходов.

Разрозненное управление
Флаги в разных компонентах
isDragging isResolving inputLocked gameEnded
Порядок приходится восстанавливать по деталям реализации.
Явный поток выполнения
Одна конфигурация FSM
playing→resolving→checking
Состояния, события, переходы и команды видны вместе.

FSM управляет потоком выполнения. Правила игры, изменения вёрстки и подсчёт очков остаются в других слоях.

Подключение FSM к сцене

Класс сцены Block Puzzle занимает одну строку:

GameScene.ts ↗

import { FsmDrivenScene } from '@gamedevland/engine/scenes';

export class GameScene extends FsmDrivenScene {}

Я оставляю классы сцен пустыми и распределяю логику по ответственности:

Управление сценойFSMсостояния и переходы
Шаг сценария использованияКомандаодно действие оркестрации
Состояние во время работыСервис сценысостояние сессии и координация
Правила игрыДоменная модельразмещение, поле, очки
Поведение узлаКомпонентпостоянное визуальное поведение
Визуальный шагДействиеодин эффект или анимация

В моём SDK FsmDrivenScene связывает жизненный цикл сцены с её FSM. Когда вёрстка, DI сцены и её сервисы готовы, он отправляет SceneReady. Пока сцена активна, он также отправляет SceneTick.

Конфигурация FSM подключается в описании сцены:

scene.config.ts ↗

export class GameSceneDefinition {
  static readonly config: SceneConfig = {
    key: 'game',
    useClass: GameScene,
    layout: 'json/layouts/scenes/game/layout.json',
    fsm: GameFsm.config,
  };
}

Кто изменяет состояние?

FSM изменяет собственное состояние. Компонент или сервис не указывает ему, в какое состояние перейти. Они отправляют событие, описывающее намерение или результат.

КомпонентНамерение игрокаPlacementRequested
Сервис сценыРезультат сценария использованияPlacementCompleted / Rejected
SDKЖизненный цикл сценыSceneReady / SceneTick
Шина событий / мост жизненного цикла
Текущее состояние + событие SceneFSM найти переход → выполнить actions → установить target → выполнить entry

События обрабатываются в таком порядке:

  1. Компонент или сервис отправляет событие; события жизненного цикла SDK отправляет напрямую.
  2. Мост событий передаёт игровые события активному SceneFSM.
  3. FSM находит переход и последовательно выполняет его actions.
  4. Затем устанавливает target и выполняет действия entry нового состояния.

Если для события нет перехода в текущем состоянии, FSM ничего с ним не делает. Например, PlacementRequested начинает размещение только в состоянии playing.

Почему компонент не должен переключать состояние сцены

SceneFSM не предоставляет метода switchState(). Компоненты сообщают о намерении; маршрут выбирается конфигурацией FSM.

Прямое управление состоянием
Компонент switchState('resolvingPlacement')
Компонент знает поток сцены и выбирает следующее состояние.
Переход по событию
Компонент PlacementRequested SceneFSM
Компонент сообщает о намерении; FSM решает, допустим ли переход и куда перейти.

Так управление сценой остаётся вне визуальных компонентов:

  • переходы находятся в одной конфигурации и могут зависеть от состояния;
  • действия перехода, действия входа и асинхронные команды сохраняют порядок выполнения;
  • изменение потока сцены не требует переписывания компонентов.

Событие не обязано менять состояние. Если у перехода есть actions, но нет target, FSM выполняет команды и остаётся в текущем состоянии. Так Block Puzzle обрабатывает RestartRequested.

Не каждое состояние относится к FSM сцены

У компонента может быть собственное локальное состояние. Например, BlockPuzzleDragComponent хранит текущую фазу перетаскивания:

BlockPuzzleDragComponent.ts ↗

type DragPhase = 'dragging' | 'snapping' | 'returning';

interface DragSession extends DragSelection {
  readonly shape: ShapeDefinition;
  preview: PlacementPreview;
  phase: DragPhase;
}
Локальное состояние Важно одному узлу или компоненту Оставьте его внутри компонента.
Состояние сцены Нужна координация нескольких систем Разместите его в FSM сцены и меняйте через события.

Эти фазы влияют только на компонент перетаскивания, поэтому им не место в FSM сцены.

Карта состояний сцены

Block Puzzle использует шесть состояний. Эта диаграмма показывает весь поток сцены, реализованный кодом в следующих разделах:

Начальное состояние bootstrapping ожидание готовности сцены
SceneReady запуск сессии + музыка
Подготовка starting создание рабочей сессии
SessionStarted
Ввод игрока playing приём запросов на размещение
PlacementRequested PlaceBlockCommand
Сценарий использования resolvingPlacement применение хода
PlacementCompleted
Действие при входе checkingMoves поиск следующего хода
GameOver
Результат ended ожидание перезапуска
PlacementRejectedresolvingPlacement → playing
MovesAvailablecheckingMoves → playing
RestartRequestedplaying / resolving / checking / ended

Запуск игровой сессии

Когда сцена готова, FsmDrivenScene отправляет SceneReady. FSM выполняет две команды и задаёт целью starting:

fsm.config.ts ↗

bootstrapping: {
  on: {
    [FsmDrivenSceneLifecycleEvents.SceneReady]: {
      target: 'starting',
      actions: [
        'StartBlockPuzzleSessionCommand',
        'StartBlockPuzzleMusicCommand',
      ],
    },
  },
},
starting: {
  on: {
    [BlockPuzzleEvents.SessionStarted]: {
      target: 'playing',
    },
  },
},

Команды в actions выполняются через CommandBus в строгом порядке. Если команда возвращает Promise, FSM ждёт её завершения перед запуском следующей команды.

Когда обе команды завершены, FSM устанавливает состояние starting. Событие SessionStarted, отправленное при создании сессии, уже ожидает в очереди и вызывает следующий переход в playing.

Сама команда остаётся небольшой. Она получает сервис сцены из DI и вызывает один сценарий использования:

StartBlockPuzzleSessionCommand.ts ↗

export class StartBlockPuzzleSessionCommand extends BaseCommand {
  override run(): void {
    this.getSceneService(BlockPuzzleServices.Gameplay).startSession();
  }
}

BlockPuzzleGameplayService создаёт рабочую сессию, отправляет начальные данные визуальным компонентам и сообщает о завершении запуска:

BlockPuzzleGameplayService.ts ↗

startSession(): void {
  const deck = new BlockPuzzleShapeDeck(this.shapesConfig.shapes);
  const slots = new BlockPuzzleSlots();
  slots.replace(deck.draw(BlockPuzzleSlots.Count));
  const progress = this.progress.load();

  this.session = {
    board: new BlockPuzzleBoard(this.boardConfig.width, this.boardConfig.height),
    deck,
    slots,
    score: 0,
    bestScore: progress.bestScore,
    ended: false,
  };
  this.emitBoardChanged();
  this.emitBlocksChanged();
  this.emitScoreChanged(0);
  this.sceneContext.engine.events.emit({
    type: BlockPuzzleEvents.SessionStarted,
    data: {},
  });
}

Размещение блока

1. Компонент отправляет запрос

BlockPuzzleDragComponent отвечает за ввод, предпросмотр и визуальное перемещение. После анимации привязки к клетке он не меняет поле напрямую, а отправляет событие:

BlockPuzzleDragComponent.ts ↗

this.clearPreview();
this.node.events.emit({
  type: BlockPuzzleEvents.PlacementRequested,
  data: {
    slotId: drag.slotId,
    anchor,
  },
});

2. FSM выбирает сценарий

FSM обрабатывает запрос в playing:

fsm.config.ts ↗

playing: {
  on: {
    [BlockPuzzleEvents.PlacementRequested]: {
      target: 'resolvingPlacement',
      actions: ['PlaceBlockCommand'],
    },
    [BlockPuzzleEvents.RestartRequested]: {
      actions: ['RestartBlockPuzzleCommand'],
    },
  },
},

PlacementRequested обрабатывается только в playing, поэтому отдельный флаг isPlacementLocked не нужен. FSM выполняет PlaceBlockCommand, переходит в resolvingPlacement, затем обрабатывает событие результата из очереди.

3. Команда проверяет входные данные

PlaceBlockCommand наследуется от BaseTypedCommand:

PlaceBlockCommand.ts ↗

type PlaceBlockCommandPayload = InferDecoded<
  typeof BlockPuzzleEventSchemas.PlacementRequest
>;

export class PlaceBlockCommand extends BaseTypedCommand<PlaceBlockCommandPayload> {
  protected override readonly inputDecoder =
    BlockPuzzleEventSchemas.PlacementRequest;

  protected override execute(payload: PlaceBlockCommandPayload): void {
    this.getSceneService(BlockPuzzleServices.Gameplay).place(payload);
  }
}

Схема полезной нагрузки находится рядом с остальными контрактами игровых событий:

event.schemas.ts ↗

static readonly PlacementRequest = SchemaDecoder.object({
  slotId: SchemaDecoder.number({
    integer: true,
    min: 0,
    max: BlockPuzzleSlots.Count - 1,
  }),
  anchor: BlockPuzzleEventSchemas.Cell,
});

SDK декодирует полезную нагрузку события до запуска execute. InferDecoded выводит тип TypeScript из той же схемы, поэтому проверки во время исполнения и статическая типизация не могут разойтись.

4. Сервис сцены обновляет сессию

BlockPuzzleGameplayService владеет текущей сессией:

BlockPuzzleGameplayService.ts ↗

interface BlockPuzzleSession {
  readonly board: BlockPuzzleBoard;
  readonly deck: BlockPuzzleShapeDeck;
  readonly slots: BlockPuzzleSlots;
  score: number;
  bestScore: number;
  ended: boolean;
}

Сервис координирует полный ход: получает фигуру из слота, вызывает доменные методы поля, очищает заполненные линии, считает очки и отправляет результат. Сами правила остаются в специализированных доменных классах.

Проверка допустимости размещения относится к BlockPuzzleBoard:

BlockPuzzleBoard.ts ↗

canPlace(shape: ShapeDefinition, anchor: CellCoordinate): boolean {
  return shape.cells.every((cell) => {
    const target = this.resolveTarget(anchor, cell);
    return this.isInside(target) && this.readCell(target) === null;
  });
}

Подсчёт очков относится к BlockPuzzleScoreRules:

BlockPuzzleScoreRules.ts ↗

calculate(placedCellCount: number, clearedLineCount: number): number {
  const placementPoints = placedCellCount * this.config.pointsPerPlacedCell;
  const linePoints = clearedLineCount * this.config.pointsPerClearedLine;
  const multiLineBonus = clearedLineCount >= 2 ? this.config.multiLineBonus : 0;
  return placementPoints + linePoints + multiLineBonus;
}

FSM управляет порядком выполнения, команда делегирует сценарий использования, а доменная модель владеет правилами.

5. Результат определяет следующее состояние

Сервис отправляет PlacementCompleted или PlacementRejected, а FSM описывает обе ветви:

fsm.config.ts ↗

resolvingPlacement: {
  on: {
    [BlockPuzzleEvents.PlacementCompleted]: {
      target: 'checkingMoves',
    },
    [BlockPuzzleEvents.PlacementRejected]: {
      target: 'playing',
    },
    [BlockPuzzleEvents.RestartRequested]: {
      actions: ['RestartBlockPuzzleCommand'],
    },
  },
},

Действия при входе и ветвление

После каждого успешного хода нужно проверить, доступно ли ещё размещение. Это должно происходить при каждом входе FSM в checkingMoves, поэтому проверка объявлена как действие entry:

fsm.config.ts ↗

checkingMoves: {
  entry: ['CheckBlockPuzzleMovesCommand'],
  on: {
    [BlockPuzzleEvents.MovesAvailable]: {
      target: 'playing',
    },
    [BlockPuzzleEvents.GameOver]: {
      target: 'ended',
      actions: ['HandleBlockPuzzleGameOverCommand'],
    },
    [BlockPuzzleEvents.RestartRequested]: {
      actions: ['RestartBlockPuzzleCommand'],
    },
  },
},

Команда содержит один шаг:

CheckBlockPuzzleMovesCommand.ts ↗

export class CheckBlockPuzzleMovesCommand extends BaseCommand {
  override run(): void {
    this.getSceneService(BlockPuzzleServices.Gameplay).emitMoveAvailability();
  }
}

Сервис обращается к доменной модели и отправляет результат:

BlockPuzzleGameplayService.ts ↗

emitMoveAvailability(): void {
  this.sceneContext.engine.events.emit({
    type: this.hasAvailableMove()
      ? BlockPuzzleEvents.MovesAvailable
      : BlockPuzzleEvents.GameOver,
    data: {},
  });
}

Внутри FSM нет if (hasAvailableMove()). Он получает готовый результат — MovesAvailable или GameOver — и выбирает соответствующий переход.

Регистрация команд и сервисов сцены

FSM ссылается на команды по имени, поэтому каждую команду нужно зарегистрировать в глобальном DI:

di.providers.ts ↗

protected override registerCommands(group: CommandsGroup): void {
  group
    .add('CheckBlockPuzzleMovesCommand', CheckBlockPuzzleMovesCommand)
    .add('FailBootPreloadCommand', FailBootPreloadCommand)
    .add('HandleBlockPuzzleGameOverCommand', HandleBlockPuzzleGameOverCommand)
    .add('OpenGameSceneCommand', OpenGameSceneCommand)
    .add('PreloadBootResourcesCommand', PreloadBootResourcesCommand)
    .add('PlaceBlockCommand', PlaceBlockCommand)
    .add('RestartBlockPuzzleCommand', RestartBlockPuzzleCommand)
    .add('StartBlockPuzzleSessionCommand', StartBlockPuzzleSessionCommand)
    .add('StartBlockPuzzleMusicCommand', StartBlockPuzzleMusicCommand);
}

Сервис игрового процесса регистрируется в области DI игровой сцены:

scene.di.ts ↗

protected override registerServices(services: SceneServiceTokenRegistry): void {
  services
    .add(BlockPuzzleServices.Gameplay)
    .add(BlockPuzzleServices.Audio)
    .add(DebugDiTokens.GameDevtoolsApiService);
}

Сервис в области сцены создаётся при входе в сцену и завершает работу вместе с ней. BlockPuzzleGameplayService.onExit() очищает текущую сессию:

BlockPuzzleGameplayService.ts ↗

protected override onExit(): void {
  this.session = undefined;
}

Команды получают сервисы сцены через getSceneService(...). Они не создают их вручную и не используют глобальный singleton для состояния конкретной сцены.

Что даёт эта структура

Такие границы делают разработку с AI предсказуемой. Я могу попросить агента добавить событие, переход и команду, сохранив правила в домене, а визуальное поведение — в компонентах. Агенту не нужно изобретать архитектуру.

Для меня FSM — исполняемая карта поведения сцены. Порядок команд остаётся предсказуемым, входные данные типизированы, а жизненным циклом управляет SDK.