В этой статье я показываю, как управляю сценой через FSM в @gamedevland/engine на примере запуска, размещения фигуры, проверки ходов, завершения и перезапуска Block Puzzle.
Что такое FSM?
FSM расшифровывается как Finite State Machine — конечный автомат.
FSM хранит текущее состояние системы и описывает, что должно происходить при поступлении нового события.
В Block Puzzle событие PlacementRequested переводит сцену из playing в resolvingPlacement.
Элементы FSM
«Конечный» означает, что набор состояний определён заранее. Конфигурация FSM становится полной картой допустимых переходов.
Зачем игровой сцене FSM
Без FSM состояние сцены обычно оказывается разбросано по компонентам и флагам: можно ли перетаскивать фигуру, обрабатывается ли размещение, завершилась ли игра и доступен ли перезапуск.
Проблема не в самих флагах, а в разрозненных правилах переходов.
FSM управляет потоком выполнения. Правила игры, изменения вёрстки и подсчёт очков остаются в других слоях.
Подключение FSM к сцене
Класс сцены Block Puzzle занимает одну строку:
import { FsmDrivenScene } from '@gamedevland/engine/scenes';
export class GameScene extends FsmDrivenScene {}
Я оставляю классы сцен пустыми и распределяю логику по ответственности:
В моём SDK FsmDrivenScene связывает жизненный цикл сцены с её FSM. Когда вёрстка, DI сцены и её сервисы готовы, он отправляет SceneReady. Пока сцена активна, он также отправляет SceneTick.
Конфигурация FSM подключается в описании сцены:
export class GameSceneDefinition {
static readonly config: SceneConfig = {
key: 'game',
useClass: GameScene,
layout: 'json/layouts/scenes/game/layout.json',
fsm: GameFsm.config,
};
}
Кто изменяет состояние?
FSM изменяет собственное состояние. Компонент или сервис не указывает ему, в какое состояние перейти. Они отправляют событие, описывающее намерение или результат.
События обрабатываются в таком порядке:
- Компонент или сервис отправляет событие; события жизненного цикла SDK отправляет напрямую.
- Мост событий передаёт игровые события активному
SceneFSM. - FSM находит переход и последовательно выполняет его
actions. - Затем устанавливает
targetи выполняет действияentryнового состояния.
Если для события нет перехода в текущем состоянии, FSM ничего с ним не делает. Например, PlacementRequested начинает размещение только в состоянии playing.
Почему компонент не должен переключать состояние сцены
SceneFSM не предоставляет метода switchState(). Компоненты сообщают о намерении; маршрут выбирается конфигурацией FSM.
switchState('resolvingPlacement')
PlacementRequested
→
SceneFSM
Так управление сценой остаётся вне визуальных компонентов:
- переходы находятся в одной конфигурации и могут зависеть от состояния;
- действия перехода, действия входа и асинхронные команды сохраняют порядок выполнения;
- изменение потока сцены не требует переписывания компонентов.
Событие не обязано менять состояние. Если у перехода есть actions, но нет target, FSM выполняет команды и остаётся в текущем состоянии. Так Block Puzzle обрабатывает RestartRequested.
Не каждое состояние относится к FSM сцены
У компонента может быть собственное локальное состояние. Например, BlockPuzzleDragComponent хранит текущую фазу перетаскивания:
type DragPhase = 'dragging' | 'snapping' | 'returning';
interface DragSession extends DragSelection {
readonly shape: ShapeDefinition;
preview: PlacementPreview;
phase: DragPhase;
}
Эти фазы влияют только на компонент перетаскивания, поэтому им не место в FSM сцены.
Карта состояний сцены
Block Puzzle использует шесть состояний. Эта диаграмма показывает весь поток сцены, реализованный кодом в следующих разделах:
Следующие три раздела проходят по карте сверху вниз. Четвёртый показывает подключение элементов:
Запуск игровой сессии
Когда сцена готова, FsmDrivenScene отправляет SceneReady. FSM выполняет две команды и задаёт целью starting:
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 отвечает за ввод, предпросмотр и визуальное перемещение. После анимации привязки к клетке он не меняет поле напрямую, а отправляет событие:
this.clearPreview();
this.node.events.emit({
type: BlockPuzzleEvents.PlacementRequested,
data: {
slotId: drag.slotId,
anchor,
},
});
2. FSM выбирает сценарий
FSM обрабатывает запрос в playing:
playing: {
on: {
[BlockPuzzleEvents.PlacementRequested]: {
target: 'resolvingPlacement',
actions: ['PlaceBlockCommand'],
},
[BlockPuzzleEvents.RestartRequested]: {
actions: ['RestartBlockPuzzleCommand'],
},
},
},
PlacementRequested обрабатывается только в playing, поэтому отдельный флаг isPlacementLocked не нужен. FSM выполняет PlaceBlockCommand, переходит в resolvingPlacement, затем обрабатывает событие результата из очереди.
3. Команда проверяет входные данные
PlaceBlockCommand наследуется от BaseTypedCommand:
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);
}
}
Схема полезной нагрузки находится рядом с остальными контрактами игровых событий:
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:
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:
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 описывает обе ветви:
resolvingPlacement: {
on: {
[BlockPuzzleEvents.PlacementCompleted]: {
target: 'checkingMoves',
},
[BlockPuzzleEvents.PlacementRejected]: {
target: 'playing',
},
[BlockPuzzleEvents.RestartRequested]: {
actions: ['RestartBlockPuzzleCommand'],
},
},
},
Действия при входе и ветвление
После каждого успешного хода нужно проверить, доступно ли ещё размещение. Это должно происходить при каждом входе FSM в checkingMoves, поэтому проверка объявлена как действие entry:
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:
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 игровой сцены:
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.