StateMachine

Иерархический конечный автомат над обычным объектом-контекстом — единственное место, где игра хранит «что я сейчас делаю»: мозг врага, оружие (ready → fire → cooldown → reload), игровой режим (warmup → round → over), дверь. Весь автомат — ОДНА декларативная таблица; автомат ничего не знает об аспектах, узлах или сети, так что владелец тикает его там, где он живёт (updateFixed для всего, что должно совпадать с другим автоматом, update для презентации). Весь паттерн — боты, чувства, отряды — в рецепте Game AI.

Обзор

TypeScript
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 с

Таблица

TypeScript
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; имя, возвращённое функцией, разрешается (и бросает) в момент возврата.

Note

таблица общая для всех экземпляров — длительность на экземпляр это guard по t (to: { ready: (g, t) => t >= g.cooldown }), а не after.

Семантика одного step(dt)

  1. Часы каждого активного состояния продвигаются.
  2. Переходы проверяются от внешнего к внутреннему: to корня, затем guard'ы to каждого активного состояния в порядке объявления и его after. Сработавший переход перепроверяется из нового состояния (проходное состояние выходит дальше в том же шаге), не больше 8 прыжков.
  3. update выполняется от внешнего → к внутреннему (update корня первым). Update, вернувший имя, переходит туда и завершает проход; состояния, в которые он вошёл, обновляются на следующем шаге.

Переход выходит из состояний от внутреннего к внешнему до общего предка и входит в новую ветку от внешнего к внутреннему, спускаясь в детей initial. exit составного состояния — единственное место, где можно отменить всё, что выставил любой из его детей.

Note

guard никогда не входит заново в уже активное состояние — to: { dead: (b) => b.hp <= 0 } срабатывает один раз, а не каждый шаг, пока остаётся true. Повторно входят только go() и события (go('patrol') из patrol выполняет exit, затем enter).

Рантайм

TypeScript
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 перевычисляет то, что ему нужно, или не направляй это событие туда.

Смотрите также