StateMachine
Иерархический конечный автомат над обычным объектом-контекстом — единственное место, где игра
хранит «что я сейчас делаю»: мозг врага, оружие (ready → fire → cooldown → reload), игровой режим
(warmup → round → over), дверь. Весь автомат — ОДНА декларативная таблица; автомат ничего не знает
об аспектах, узлах или сети, так что владелец тикает его там, где он живёт (updateFixed для всего,
что должно совпадать с другим автоматом, update для презентации). Весь паттерн — боты, чувства,
отряды — в рецепте Game AI.
Обзор
type Bot = { hp: number; target: boolean; visible: boolean; dist: number; lostFor: number; fire: boolean }
const Guard = defineStates<Bot>({
initial: 'patrol',
on: { hit: 'flinch' }, // событие, из ЛЮБОГО состояния
to: { dead: (b) => b.hp <= 0 }, // проверяется в каждом состоянии, первым
states: {
patrol: { to: { combat: (b) => b.target } },
combat: {
initial: 'engage',
to: { search: (b) => b.lostFor > 4 }, // покидает любого активного ребёнка
states: {
engage: { to: { shoot: (b) => b.visible && b.dist < 12 } },
shoot: { enter: (b) => { b.fire = true }, exit: (b) => { b.fire = false }, to: { engage: (b) => !b.visible } },
},
},
search: { after: [6, 'patrol'] }, // переход по таймеру
flinch: { after: [0.2, '$back'] }, // назад в состояние, из которого пришли
dead: { enter: () => console.log('down') },
},
})
const bot: Bot = { hp: 100, target: false, visible: false, dist: 99, lostFor: 0, fire: false }
const fsm = new StateMachine(Guard, bot) // входит в 'patrol' сразу
fsm.on('change', (to, from) => console.log(`[fsm] ${from}>${to}`))
bot.target = true
fsm.step(1 / 60) // раз в тик: например, из update(dt) аспекта
fsm.state // 'combat.engage'
fsm.is('combat') // true, пока активен любой ребёнок combat
fsm.send('hit') // → 'flinch'; обратно в 'combat.engage' через 0.2 сТаблица
defineStates<C>(def: StatesDef<C>): StatesDef<C> // identity — называет тип контекста один раз
interface StatesDef<C> {
initial: string
states: Record<string, StateDef<C>>
to?: Record<string, (ctx: C, t: number) => boolean> // guard'ы из любого состояния, проверяются первыми
on?: Record<string, string | ((ctx: C, ...args) => string | void)> // запасной обработчик событий из любого состояния
update?: (ctx: C, dt: number, t: number) => string | void // выполняется до собственных update активных состояний
}
interface StateDef<C> {
initial?: string // с `states`: ребёнок, в который входим по умолчанию (по умолчанию первый)
states?: Record<string, StateDef<C>> // делает состояние составным; «состоянием» всегда является только лист
enter?: (ctx: C, from: string) => void
exit?: (ctx: C, to: string) => void
update?: (ctx: C, dt: number, t: number) => string | void // верни имя, чтобы перейти
to?: Record<string, (ctx: C, t: number) => boolean> // guard'ы: ключ = цель, побеждает первый true
after?: [seconds: number, target: string] // покинуть через `seconds` в этом состоянии
on?: Record<string, string | ((ctx: C, ...args) => string | void)>
}t — секунды, проведённые в этом состоянии (t корня = всего). Имена разрешаются сначала
относительно — соседи объявляющего состояния (составное состояние может называть и собственных
детей), затем соседи каждого предка — затем как путь с точками от корня ('combat.engage').
'$back' = лист, активный до последнего перехода; он может быть целью after / on / update,
но не ключом to. Неизвестное имя в to, after или строковом on бросает исключение при
построении автомата, так что опечатка — ошибка на старте, а не тихий no-op; имя, возвращённое
функцией, разрешается (и бросает) в момент возврата.
таблица общая для всех экземпляров — длительность на экземпляр это guard по t
(to: { ready: (g, t) => t >= g.cooldown }), а не after.
Семантика одного step(dt)
- Часы каждого активного состояния продвигаются.
- Переходы проверяются от внешнего к внутреннему:
toкорня, затем guard'ыtoкаждого активного состояния в порядке объявления и егоafter. Сработавший переход перепроверяется из нового состояния (проходное состояние выходит дальше в том же шаге), не больше 8 прыжков. updateвыполняется от внешнего → к внутреннему (updateкорня первым). Update, вернувший имя, переходит туда и завершает проход; состояния, в которые он вошёл, обновляются на следующем шаге.
Переход выходит из состояний от внутреннего к внешнему до общего предка и входит в новую ветку от
внешнего к внутреннему, спускаясь в детей initial. exit составного состояния — единственное
место, где можно отменить всё, что выставил любой из его детей.
guard никогда не входит заново в уже активное состояние — to: { dead: (b) => b.hp <= 0 }
срабатывает один раз, а не каждый шаг, пока остаётся true. Повторно входят только go() и события
(go('patrol') из patrol выполняет exit, затем enter).
Рантайм
new StateMachine(def, ctx) // входит в начальную ветку сразу: `enter(ctx, '')`, без события `change`
fsm.step(dt: number): void
fsm.send(event: string, ...args): boolean // сначала самое внутреннее активное состояние, `on` корня последним; true = обработано
fsm.go(name: string): void // принудительный переход (спавн, сброс)
fsm.state: string // путь активного листа
fsm.branch: string[] // все активные состояния, от внешнего к внутреннему
fsm.previous: string // лист до последнего перехода ('' на старте)
fsm.time: number // секунды в активном листе
fsm.is(name): boolean // активно как лист ИЛИ как предок (имя или путь)
fsm.timeIn(name): number // секунды в этом активном состоянии (0, если неактивно)
fsm.on('change', (to, from) => void): this
fsm.off('change', fn): this
fsm.ctx: CОбработчик в on, который ничего не возвращает, всё равно считается обработавшим — событие на нём
останавливается; верни имя, чтобы перейти. Обработчик dead: { on: { hit: () => undefined } } —
способ, которым состояние проглатывает событие, на которое иначе отреагировал бы корень.
Подводные камни
- Guard'ы выполняются каждый шаг: держи их чистыми и дешёвыми. Guard с побочным эффектом
(
seekCover(), сохраняющий найденное укрытие) допустим только когда его предусловия идут первыми в цепочке&&. - Не сбрасывай одноразовые выходы внутри состояний — очищай их один раз перед
step()(мозг очищаетintent.fire; состояниеshootвыставляет его заново). '$back'в составное состояние заново выполняетenterэтого состояния — пустьenterперевычисляет то, что ему нужно, или не направляй это событие туда.
Смотрите также
- cookbook/game-ai-state-machines.md — боты, оружие и игровой режим, построенные на этом
- aspects.md, systems.md — где тикать автомат и в какой фазе