Туторіал · GameDev
⏱️ ~50 хвилин 🎓 Середній рівень 🛠️ JavaScript · ШІ ігор · Патерни проєктування

Дерева поведінки: ШІ для NPC

Побудуйте рушій дерев поведінки з нуля — той самий патерн ухвалення рішень, що керує NPC у Halo, The Last of Us і безлічі інді-ігор. Ви реалізуєте композитні вузли, декоратори, спільну дошку знань (blackboard) і зберете все це в NPC-охоронця, який патрулює локацію, помічає й переслідує зловмисника, атакує в межах дистанції та відступає при низькому здоров'ї.

Передумови

Чому не скінченний автомат?

Скінченний автомат (FSM) добре працює для невеликої кількості станів — Idle, Patrol, Chase, Attack — з явними переходами між кожною парою. Проблема в тому, що кількість переходів росте комбінаторно: додайте стан Flee та CallForHelp, і раптом доведеться продумати десяток нових ребер, багато з яких мали б спільно використовувати одну й ту саму перевірку "чи я в небезпеці?".

Дерево поведінки (behavior tree, BT) замінює заплутаний граф переходів деревом невеликих, повторно використовних вузлів, які обчислюються ("тікаються") згори вниз, зліва направо, щокадру. Складна поведінка виникає з композиції простих вузлів, а не з ручного прокладання переходів, і гілки можна повторно використовувати для багатьох різних NPC, просто переприв'язавши їх.

Скінченний автомат

  • Стани + явні ребра переходів
  • Кількість переходів росте як O(n²)
  • Важко повторно використовувати підповедінки
  • Чудово для невеликого, добре зрозумілого ШІ

Дерево поведінки

  • Дерево композитних вузлів, ребра прокладати не треба
  • Пріоритет — це просто порядок вузлів зліва направо
  • Піддерева тривіально повторно використовуються
  • Індустріальний стандарт для складного ШІ NPC

Контракт вузла — SUCCESS / FAILURE / RUNNING

Кожен вузол дерева, будь то листова дія чи композит, реалізує один метод: tick(blackboard). Він повертає один із трьох статусів. Цей крихітний контракт — увесь рушій; будь-який інший тип вузла будується виключно комбінуванням викликів tick() у своїх дочірніх вузлах.

const Status = Object.freeze({
  SUCCESS: 'SUCCESS', // вузол досяг мети цього тіку
  FAILURE: 'FAILURE', // вузол не зміг досягти мети
  RUNNING: 'RUNNING', // ще в процесі, викликати tick() знову наступного кадру
});

class Node {
  // Перевизначається в підкласах. Має повертати значення Status.
  tick(blackboard) {
    throw new Error('tick() not implemented');
  }
}

RUNNING — це те, що робить дерева поведінки придатними для ігор реального часу, а не лише покрокового ШІ: лист "рухатись до цілі" може повертати RUNNING протягом багатьох кадрів, поки NPC йде, а дерево просто тікає його знову щокадру, доки він не повідомить SUCCESS (дійшов) або FAILURE (шлях заблоковано).

Композитні вузли — Sequence і Selector

Композити мають дочірні вузли й вирішують, які з них тікати та як комбінувати результати. Два робочі коні покривають майже все, що вам знадобиться:

class Composite extends Node {
  constructor(children = []) {
    super();
    this.children = children;
  }
}

class Sequence extends Composite {
  tick(bb) {
    for (const child of this.children) {
      const status = child.tick(bb);
      if (status !== Status.SUCCESS) return status; // FAILURE або RUNNING
    }
    return Status.SUCCESS; // усі дочірні вузли успішні
  }
}

class Selector extends Composite {
  tick(bb) {
    for (const child of this.children) {
      const status = child.tick(bb);
      if (status !== Status.FAILURE) return status; // SUCCESS або RUNNING
    }
    return Status.FAILURE; // усі дочірні вузли провалились
  }
}

Читання дерева перетворюється на читання звичайного тексту: Selector зі списком [Attack, Chase, Patrol] означає "атакуй, якщо можеш, інакше переслідуй, якщо можеш, інакше патрулюй". Кожен із них сам є Sequence з умов і дій, наприклад Attack = Sequence[InRange?, SwingWeapon].

Декоратори — Inverter, Repeater, Cooldown

Декоратор огортає рівно один дочірній вузол і модифікує його результат або частоту виклику. Саме тут живе значна частина повторно використовної "клеючої" логіки:

class Decorator extends Node {
  constructor(child) {
    super();
    this.child = child;
  }
}

// Перевертає SUCCESS <-> FAILURE. Корисно для перевірок "НЕ видно".
class Inverter extends Decorator {
  tick(bb) {
    const status = this.child.tick(bb);
    if (status === Status.SUCCESS) return Status.FAILURE;
    if (status === Status.FAILURE) return Status.SUCCESS;
    return status; // RUNNING проходить без змін
  }
}

// Забороняє повторний запуск дочірнього вузла, доки не мине `seconds`
// секунд відколи він востаннє повернув щось відмінне від RUNNING.
class Cooldown extends Decorator {
  constructor(child, seconds) {
    super(child);
    this.seconds = seconds;
    this.readyAt = 0;
  }
  tick(bb) {
    if (bb.time < this.readyAt) return Status.FAILURE;
    const status = this.child.tick(bb);
    if (status !== Status.RUNNING) this.readyAt = bb.time + this.seconds;
    return status;
  }
}

// Продовжує тікати дочірній вузол, доки він не провалиться `n` разів,
// або нескінченно, якщо n не задано. Зручно для циклів "озирнутись довкола".
class Repeater extends Decorator {
  constructor(child, times = Infinity) {
    super(child);
    this.times = times;
    this.count = 0;
  }
  tick(bb) {
    if (this.count >= this.times) return Status.SUCCESS;
    const status = this.child.tick(bb);
    if (status === Status.FAILURE) { this.count++; return Status.RUNNING; }
    return Status.RUNNING;
  }
}

Дошка знань — спільна пам'ять між вузлами

Вузли не повинні тримати прямі посилання на ігрові об'єкти; замість цього вони читають і пишуть до спільного сховища ключ-значення — дошки знань (blackboard) — переданого в кожен виклик tick(). Це утримує вузли без стану й тривіально повторно використовними для різних NPC, а також дає єдиний об'єкт для інспекції при дебазі.

class Blackboard {
  constructor(agent) {
    this.agent = agent;         // NPC, яким керує це дерево
    this.time = 0;              // оновлюється раз на кадр ігровим циклом
    this.playerPos = null;      // остання відома позиція гравця, або null
    this.lastSeenAt = -Infinity;
    this.patrolIndex = 0;
    this.homePos = agent.position.clone();
  }
}

Листові дії та умови для NPC-охоронця

Листові вузли виконують фактичну роботу — це єдині вузли, що безпосередньо торкаються ігрового стану. Умови — це листи, які ніколи не повертають RUNNING; дії ж зазвичай повертають, поки анімують чи рухають NPC протягом кількох кадрів.

class Condition extends Node {
  constructor(predicate) { super(); this.predicate = predicate; }
  tick(bb) { return this.predicate(bb) ? Status.SUCCESS : Status.FAILURE; }
}

class Action extends Node {
  constructor(fn) { super(); this.fn = fn; }
  tick(bb) { return this.fn(bb); }
}

const SEE_RANGE = 12;
const ATTACK_RANGE = 1.6;
const LOW_HEALTH = 0.25;

const canSeePlayer = new Condition((bb) => {
  const dist = bb.agent.position.distanceTo(bb.world.player.position);
  if (dist > SEE_RANGE) return false;
  const blocked = bb.world.raycastBlocked(bb.agent.position, bb.world.player.position);
  if (!blocked) { bb.playerPos = bb.world.player.position.clone(); bb.lastSeenAt = bb.time; }
  return !blocked;
});

const inAttackRange = new Condition((bb) =>
  bb.playerPos && bb.agent.position.distanceTo(bb.playerPos) <= ATTACK_RANGE
);

const isLowHealth = new Condition((bb) => bb.agent.health / bb.agent.maxHealth < LOW_HEALTH);

const attack = new Action((bb) => {
  bb.agent.swingWeapon();
  return bb.agent.attackFinished ? Status.SUCCESS : Status.RUNNING;
});

const chaseToPlayer = new Action((bb) => {
  if (!bb.playerPos) return Status.FAILURE;
  const reached = bb.agent.moveToward(bb.playerPos, bb.agent.runSpeed);
  return reached ? Status.SUCCESS : Status.RUNNING;
});

const fleeToHome = new Action((bb) => {
  const reached = bb.agent.moveToward(bb.homePos, bb.agent.runSpeed * 1.2);
  return reached ? Status.SUCCESS : Status.RUNNING;
});

const patrolNextWaypoint = new Action((bb) => {
  const target = bb.world.patrolRoute[bb.patrolIndex];
  const reached = bb.agent.moveToward(target, bb.agent.walkSpeed);
  if (reached) bb.patrolIndex = (bb.patrolIndex + 1) % bb.world.patrolRoute.length;
  return Status.RUNNING; // патруль ніколи не "завершується" — завжди RUNNING
});

Складання повного дерева охоронця

З'єднайте листи в композити, найвищий пріоритет — першим. Корінь Selector спершу пробує втечу, потім атаку, потім переслідування, і повертається до патрулювання, якщо нічого інше не підходить:

const guardTree = new Selector([
  // 1. Найвищий пріоритет: бігти додому й лікуватись при серйозній травмі
  new Sequence([ isLowHealth, fleeToHome ]),

  // 2. Якщо гравець достатньо близько — атакувати
  new Sequence([ canSeePlayer, inAttackRange, new Cooldown(attack, 0.8) ]),

  // 3. Якщо гравця помічено, але він поза дистанцією — переслідувати
  new Sequence([ canSeePlayer, chaseToPlayer ]),

  // 4. Пам'ятати гравця 4с після втрати з поля зору, переслідувати останню відому точку
  new Sequence([
    new Condition((bb) => bb.time - bb.lastSeenAt < 4),
    chaseToPlayer,
  ]),

  // 5. За замовчуванням: нескінченно йти маршрутом патрулювання
  new Repeater(patrolNextWaypoint),
]);

Оскільки корінь — Selector, порядок пріоритету і є дизайном ШІ: поміняйте два рядки місцями, і характер охоронця повністю зміниться — поставте chase вище за flee, і отримаєте безрозсудного охоронця, що б'ється до смерті замість відступу.

Тікання дерева в ігровому циклі

Один тік на кадр на кожного NPC — це вся інтеграція. Тікання дешеве — це лише кілька викликів функцій уздовж активної гілки — тож сотні NPC можуть спільно використовувати одне визначення guardTree, кожен зі своїм власним екземпляром Blackboard:

const guards = world.npcs.map((agent) => ({
  agent,
  bb: new Blackboard(agent),
}));

function update(dt) {
  world.time += dt;
  for (const { agent, bb } of guards) {
    bb.time = world.time;
    bb.world = world;
    guardTree.tick(bb); // один виклик керує цілим рішенням цього кадру
  }
}

Зауважте, що вузли зі станом, як-от Cooldown і Repeater, зберігають таймери на this, а не на дошці знань — тож спільний екземпляр дерева для кількох NPC перетікав би станом між ними. У production або будуйте свіже дерево на кожного агента (дешево, оскільки дерева — просто звичайні об'єкти), або перенесіть увесь стан на дошку знань і зробіть вузли повністю без стану. У прикладі вище передбачається одне дерево на один NPC.

Дебаг — візуалізація активної гілки

Найбільший приріст продуктивності при побудові дерев поведінки — дебаг-накладка, що показує останній статус кожного вузла. Огорніть tick() один раз, щоб записувати його, а потім обійдіть дерево, щоб намалювати кольорову схему:

function instrument(node) {
  const original = node.tick.bind(node);
  node.tick = (bb) => {
    const status = original(bb);
    node.lastStatus = status; // зелений=SUCCESS, червоний=FAILURE, жовтий=RUNNING
    return status;
  };
  if (node.children) node.children.forEach(instrument);
  if (node.child) instrument(node.child);
  return node;
}

instrument(guardTree);

// У вашому дебаг-HUD:
function drawNode(node, depth = 0) {
  const color = { SUCCESS: '#22c55e', FAILURE: '#ef4444', RUNNING: '#facc15' }[node.lastStatus] || '#555';
  console.log('  '.repeat(depth) + node.constructor.name, color);
  (node.children || (node.child ? [node.child] : [])).forEach((c) => drawNode(c, depth + 1));
}

Спостереження за тим, як активна гілка загорається жовтим у режимі реального часу, робить одразу зрозумілим, чому NPC "не атакує" — зазвичай це застарілий lastSeenAt або промінь, заблокований власним колайдером агента.

Коли обрати utility AI або HTN замість цього

Дерева поведінки чудові, коли пріоритети переважно фіксовані й прописані вручну — саме як приклад охоронця вище. Вони починають напружуватись, коли потрібно багато конкуруючих цілей, оцінюваних за неперервними змінними (голод, боєприпаси, рівень загрози, згуртованість загону), що обмінюються між собою; це найкраще місце для utility AI, яка оцінює кожну можливу дію функцією й вибирає найвищий бал щотіку замість фіксованого порядку пріоритетів.

Для NPC, яким потрібно планувати багатокрокову тактику (обхід з флангу, потім кидок гранати, потім штурм), планувальник Ієрархічної мережі завдань (HTN) — який шукає серед послідовностей завдань, щоб задовольнити мету — підходить краще за обидва варіанти. Багато випущених ігор поєднують усі три: BT для реактивного бою, оцінку utility для вибору гілки BT чи цілі з пріоритетом, і планування HTN на рівні загону над обома.

Емпіричне правило: почніть із дерева поведінки. Це найдешевше в побудові, найлегше в дебазі й покриває переважну більшість архетипів NPC — охоронців, тварин, турелей, компаньйонів. Звертайтесь до utility AI чи HTN тільки коли впретесь у конкретну стіну, яку BT не може виразити чисто.

Продовжити навчання

🛠

Експериментуйте в Playground

Напишіть власний композитний або декоративний вузол і протестуйте його прямо в браузері.

Відкрити Playground →