Дружній NPC та основи Pathfinding
Передумови
Скачайте даний проєкт та працюйте у ньому.
Що ми будуємо
Ціль теми: дружній NPC, який переслідує гравця (або любий інший нод).
Створимо дружнього NPC (краб), який іде за гравцем по рівню: підходить, зупиняється на певній відстані та знову рухається, щойно гравець відійде достатньо далеко.
У платформері це складніше, ніж здається. NPC має вміти не лише ходити вліво-вправо, а й підійматися на вищі платформи, перестрибувати прірви та спускатися з країв. Тому потрібен pathfinding (пошук шляху) - алгоритм, який знаходить маршрут між двома точками рівня.
Готова pathfinding-інфраструктура складається з двох частин: граф рівня (його з тайлів будують PlatformPathfinder та LevelMapBase) та агент (PathfindingAgent2D), який по цьому графу ходить. Уся "складна" частина вже є в проєкті-основі. Ваша частина це шар поведінки: PathfindingGameFriendly2D та CrabFriendly. Ту саму інфраструктуру можна повторно використати для будь-якого іншого персонажа, що має діставатися цілі.
Теорія - граф та алгоритм A-star
Пошук шляху зводиться до пошуку маршруту в графі.
Граф - це набір точок та з'єднань між ними. Точка - місце, де може опинитися персонаж; з'єднання означає "з цієї точки можна дістатися тієї".
Знайти шлях - знайти послідовність з'єднаних точок від старту до цілі. Для цього використовують алгоритм A-star ("A-зірка") - він знаходить найкоротший такий маршрут.
У Godot для цього є готовий клас AStar2D: додаєте точки (AddPoint(id, position)), з'єднуєте їх (ConnectPoints(id1, id2)) і питаєте шлях (GetIdPath(from, to)).
var astar = new AStar2D();
astar.AddPoint(1, new Vector2(0, 0));
astar.AddPoint(2, new Vector2(100, 0));
astar.AddPoint(3, new Vector2(100, 100));
astar.AddPoint(4, new Vector2(200, 0));
astar.AddPoint(5, new Vector2(500, 500)); // ізольована точка: до неї нічого не веде
astar.ConnectPoints(1, 2);
astar.ConnectPoints(2, 3);
astar.ConnectPoints(2, 4);
long[] ids = astar.GetIdPath(1, 4); // [1, 2, 4]
long[] none = astar.GetIdPath(1, 5); // [] - порожній масив: шляху немає
long closest = astar.GetClosestPoint(new Vector2(190, 10)); // 4 - точка, найближча до цієї позиції
// З'єднання можна зробити односпрямованим: йти можна лише з 1 в 2, назад - ні
var oneWay = new AStar2D();
oneWay.AddPoint(1, Vector2.Zero);
oneWay.AddPoint(2, new Vector2(0, 50));
oneWay.ConnectPoints(1, 2, bidirectional: false);Pathfinding у платформері - як побудувати граф з тайлів
У платформері персонаж не може просто "піти" в будь-яку сусідню клітинку - зате може стрибнути, впасти або зійти з краю. Тому граф будують з урахуванням цих дій.
PlatformPathfinder сканує тайли рівня і ставить точки графа над "цікавими" місцями: краями платформ, стінами, місцями приземлення після падіння (прапорці PointInfo: IsLeftEdge/IsRightEdge, IsLeftWall/IsRightWall, IsFallTile).
Потім точки з'єднують - кожен вид з'єднання відповідає одній дії персонажа:
- horizontal - ходьба по рівній поверхні;
- platformJump - стрибок через проміжок між платформами;
- diagonalJump - стрибок з краю на край нижчої платформи;
- fall - падіння з краю; на невелику висоту двоспрямоване (можна залізти назад), на велику - лише вниз;
- step - невеликий крок (сходинка, виступ).
Це можна частково налаштувати через експортовані змінні Level1Map (у тайлах): JumpDistance, JumpHeight, StepDistance, JumpHeadroom (вільне місце над головою).
Граф видно прямо на рівні: у Level1Map якщо змінна ShowDebugGraph увімкнена (true).
Нова файлова структура проєкту
Показано лише ті папки, де є зміни; решта проєкту (меню, Player, GameManager тощо) лишається як у темі 19. Жовтим позначено файли цієї теми: частина з них уже є у проєкті-основі (pathfinding-інфраструктура та реорганізована карта), а частину - позначену "(створити)" - ви створите самі. Файл Level1.tscn також змінено.
- .
- Assets
- Spritesheets
- GraphPoint
- Code
- Libraries
- RedGodot.cs
- RedMath.cs
- PlatformPathfinder.cs
- Objects
- DamageInfo.cs
- IDamagable.cs
- IPlatformPathfinder.cs
- LevelMapBase.cs
- PathfindingAgent2D.cs
- PathfindingGameEntity2D.cs
- PathfindingGameFriendly2D.cs (створити)
- PointInfo.cs
- Scenes
- Enemies
- Crab
- EnemyCrab.cs
- EnemyCrab.tscn
- GraphPoint
- GraphPoint.tscn
- Levels
- Level1.tscn
- WinArea2D.cs
- Maps
- Level1Map
- Level1Map.cs
- Level1Map.tscn
- Paralax
- Paralax.tscn
- NPCs
- CrabFriendly
- CrabFriendly.cs (створити)
- CrabFriendly.tscn (створити)
Що змінилося у Level1 - карта в окремій сцені
Щоб pathfinding працював на будь-якому рівні, карту (тайли) винесено з Level1.tscn в окрему сцену з власним скриптом.
У темі 19 усі тайли лежали прямо в Level1. Тепер вони містяться в сцені Level1Map (корінь - Node2D зі скриптом Level1Map.cs), а фон із паралаксом - у сцені Paralax. Level1 просто містить їхні екземпляри в ноді Map.
Шар тайлів із колізіями, який раніше називався Foreground, тепер називається ForegroundNav: саме цей TileMapLayer (шлях до нього - NavigationLayerPath) PlatformPathfinder читає, щоб побудувати граф. Сам шар і його фізика залишилися, змінилися назва та місце в дереві.
Важливо!: pathfinding (PlatformPathfinder/LevelMapBase) вміє працювати тільки з одним фізичним шаром (ForegroundNav), усі інші шари є чисто декоративні.
Дерево сцени Level1Map:
- Level1Map (Node2D, скрипт Level1Map.cs)
- Background-Rocks-Background (TileMapLayer)
- Background-Rocks (TileMapLayer)
- Background (TileMapLayer)
- ForegroundNav (TileMapLayer)
- Foreground-Rocks (TileMapLayer)
- Foreground-WinPoints (TileMapLayer)
Сцена GraphPoint:
- GraphPoint (Sprite2D)
GraphPoint - допоміжна сцена, яка використовується лише для візуалізації графа: LevelMapBase створює її екземпляр у кожній точці графа, коли увімкнено ShowDebugGraph.
Інтерфейс - IPlatformPathfinder
Контракт для будь-якого нода, що будує pathfinding-граф рівня та відповідає на запити про шлях.
Агенти залежать лише від IPlatformPathfinder, а не від конкретного класу рівня - за тим самим принципом, що й IDamagable у минулій темі: код працює з контрактом (інтерфейсом), а не з конкретним класом. Тому той самий код агента без змін працюватиме на будь-якому рівні, де є нод, що реалізує цей інтерфейс.
Інтерфейс містить лише два методи: GetPlatform2DPath() (шукає шлях між двома глобальними позиціями; якщо шляху немає - повертає порожній стек) та GlobalToTile() (перетворює глобальну позицію на координати тайла).
Константа GroupName - назва групи, до якої кожна реалізація додає себе. Агенти шукають pathfinder рівня через групу (GetFirstNodeInGroup()), не знаючи ні імені нода, ні його типу.
Код:
using Godot;
using System.Collections.Generic;
/// <summary>
/// Контракт для будь-якого нода, який будує та надає pathfinding-граф рівня 2D-платформера
/// (наприклад, Level1Map). Агенти (PathfindingAgent2D) залежать лише від цього інтерфейсу, а не від
/// конкретного класу рівня, тому той самий код агента без змін працює на будь-якому рівні, де є нод,
/// що реалізує IPlatformPathfinder і належить до групи <see cref="GroupName"/>.
/// </summary>
public interface IPlatformPathfinder
{
/// <summary>
/// Назва Godot-групи, до якої кожна реалізація додає себе у _EnterTree(), щоб агенти могли знайти
/// pathfinder рівня через GetTree().GetFirstNodeInGroup(...) не знаючи імені нода чи його типу.
/// </summary>
public const string GroupName = "platform_pathfinder";
/// <summary>
/// Шукає шлях між двома позиціями у глобальних координатах. Повертає порожній стек, якщо шлях
/// не знайдено. Перша точка шляху лежить на вершині стека (її дістають через Pop()).
/// </summary>
Stack<PointInfo> GetPlatform2DPath(Vector2 startGlobalPos, Vector2 endGlobalPos);
/// <summary>Перетворює глобальну позицію на координати тайла цього pathfinder-а.</summary>
Vector2I GlobalToTile(Vector2 globalPosition);
}Клас - PointInfo
Одна точка pathfinding-графа (або початок/кінець знайденого шляху).
PointInfo - клас-контейнер даних без власної логіки. Він зберігає позицію точки у глобальних координатах, її PointID в AStar2D та прапорці оточення (IsLeftEdge, IsRightEdge, IsLeftWall, IsRightWall, IsFallTile), за якими агент вирішує, як саме діяти в цій точці (просто йти, стрибнути, зійти з краю).
Метод GetFlagString() повертає короткий рядок із літер активних прапорців (наприклад, "LrwF") - він використовується лише для debug-логів.
Код:
using Godot;
/// <summary>
/// Точка pathfinding-графа платформера, а також початок/кінець знайденого шляху. Position зберігається
/// у ГЛОБАЛЬНИХ координатах, тому її можна напряму використовувати як ціль руху незалежно від того,
/// як TileMapLayer розташований відносно карти рівня.
/// </summary>
/// <remarks>
/// Це лише контейнер даних без логіки (окрім допоміжного GetFlagString()). Булеві прапорці описують
/// оточення точки: край платформи, стіна поруч, місце приземлення після падіння.
/// </remarks>
public class PointInfo
{
/// <summary>Точка приземлення: місце на землі, куди потрапляє агент, якщо зійде з краю платформи і впаде.</summary>
public bool IsFallTile;
/// <summary>Лівий край платформи: під точкою є тайл, а зліва від нього тайла немає (обрив).</summary>
public bool IsLeftEdge;
/// <summary>Правий край платформи: під точкою є тайл, а справа від нього тайла немає (обрив).</summary>
public bool IsRightEdge;
/// <summary>Зліва від точки, на тій самій висоті, стоїть тайл (стіна блокує рух вліво).</summary>
public bool IsLeftWall;
/// <summary>Справа від точки, на тій самій висоті, стоїть тайл (стіна блокує рух вправо).</summary>
public bool IsRightWall;
/// <summary>
/// true - це не точка графа, а позиційна точка: початок або кінець шляху, створені з реальної
/// позиції агента чи цілі (з PointID = -10000).
/// </summary>
public bool IsPositionPoint;
/// <summary>
/// Ідентифікатор точки в AStar2D-графі. Для позиційних точок (IsPositionPoint) дорівнює -10000,
/// бо вони до графа не додаються.
/// </summary>
public long PointID;
/// <summary>Координати точки у глобальному (world) просторі Godot.</summary>
public Vector2 Position;
/// <summary>Порожній конструктор - дозволяє створити об'єкт через ініціалізатор об'єкта (object initializer).</summary>
public PointInfo() { }
/// <summary>
/// Основний конструктор: створює точку з ідентифікатором та позицією. Усі прапорці залишаються
/// false і виставляються окремо під час побудови графа.
/// </summary>
public PointInfo(long pointId, Vector2 position)
{
PointID = pointId;
Position = position;
}
/// <summary>
/// Повертає короткий текстовий опис активних прапорців для debug-логів.
/// </summary>
/// <returns>
/// Рядок із літер: "L" - лівий край, "R" - правий край, "lw" - ліва стіна, "rw" - права стіна,
/// "F" - точка приземлення, "P" - позиційна точка. Приклад: "LrwF" - лівий край + права стіна +
/// точка приземлення. Якщо жоден прапорець не виставлений - порожній рядок.
/// </returns>
public string GetFlagString()
{
var s = new System.Text.StringBuilder(); // StringBuilder дозволяє ефективно збирати рядок із частин
if (IsLeftEdge) s.Append('L');
if (IsRightEdge) s.Append('R');
if (IsLeftWall) s.Append("lw"); // Двосимвольний, щоб не плутати з 'L'
if (IsRightWall) s.Append("rw"); // Двосимвольний, щоб не плутати з 'R'
if (IsFallTile) s.Append('F');
if (IsPositionPoint) s.Append('P');
return s.ToString();
}
}Клас - PlatformPathfinder
Будує граф рівня та шукає по ньому шляхи. Код цього класу великий (понад 700 рядків) і його читати не обов'язково - нижче описано, що він робить.
PlatformPathfinder - звичайний C#-клас (НЕ Node): сам він нічого не малює і не створює нодів сцени, тому його можна використовувати незалежно від дерева сцени. Він створює та зберігає екземпляр AStar2D.
Build() проходить по всіх використаних тайлах навігаційного шару (ForegroundNav), створює точки (краї, стіни, точки приземлення), та потім з'єдную їх.
GetPlatform2DPath(start, end) шукає шлях між двома ГЛОБАЛЬНИМИ позиціями: знаходить найближчі до початкової та кінцевої позицій точки графа (GetClosestPoint()), просить у AStar2D шлях між ними.
Для перевірки, чи є вільний простір між точками (для стрибків і кроків), клас користується тими самими тайлами: якщо між точками стоїть тайл або (для кроків і стрибків) над точкою недостатньо вільного місця (JumpHeadroom), таке з'єднання просто не створюється.
Класи - LevelMapBase та Level1Map
Нод, який створює pathfinder і робить його доступним для агентів.
LevelMapBase - базовий клас карти рівня, який реалізує IPlatformPathfinder. Він створює PlatformPathfinder на основі TileMapLayer, шлях до якого задається у NavigationLayerPath (за замовчуванням - дочірній нод "ForegroundNav"), і просто передає йому запити про шлях.
Експортовані змінні (JumpDistance, JumpHeight, StepDistance, JumpHeadroom, ShowDebugGraph) налаштовуються прямо в інспекторі окремо для кожного рівня.
Нод додає себе до групи у _EnterTree(), а не в _Ready(): Godot викликає _EnterTree() викликається до _Ready(), тоді коли нод додається до tree.
Якщо ShowDebugGraph увімкнено, клас малює лінії з'єднань у _Draw() та створює візуальні точки зі сцени GraphPoint.
Level1Map - клас карти першого рівня. Уся поведінка успадкована від LevelMapBase, тому власного коду в ньому немає; за потреби налаштування, специфічне для рівня, можна додати в OnPathfindingReady().
Код LevelMapBase:
using Godot;
using System.Collections.Generic;
/// <summary>
/// Базовий клас карти рівня. Створює PlatformPathfinder на основі TileMapLayer, надає його іншим
/// нодам через <see cref="IPlatformPathfinder"/> і (за бажанням) малює debug-граф. Конкретні рівні
/// (Level1Map, Level2Map, ...) успадковують цей клас і зазвичай не потребують власного коду -
/// щонайбільше налаштування, специфічного для рівня, у методі <see cref="OnPathfindingReady"/>.
/// </summary>
public partial class LevelMapBase : Node2D, IPlatformPathfinder
{
[Export]
public bool ShowDebugGraph = true; // Чи малювати точки та лінії графа
[Export]
public int JumpDistance = 5; // Макс. горизонтальна дальність стрибка (у ТАЙЛАХ) - для перестрибування прірви
[Export]
public int JumpHeight = 4; // Макс. висота стрибка між точками (у тайлах, не в пікселях)
[Export]
public int StepDistance = 2; // Макс. горизонтальна відстань (у тайлах) для ЗВИЧАЙНОГО кроку (сходинки, виступи)
[Export]
public int JumpHeadroom = 2; // Мін. кількість вільних тайлів над точками відриву та приземлення стрибка
[Export]
public NodePath NavigationLayerPath = "ForegroundNav"; // Дочірній TileMapLayer, за яким будується граф
private static readonly PackedScene graphPointScene =
ResourceLoader.Load<PackedScene>("res://Scenes/GraphPoint/GraphPoint.tscn"); // Сцена візуальної debug-точки
private PlatformPathfinder _pathfinder;
public override void _EnterTree()
{
// Реєструємось саме тут, а не в _Ready: Godot викликає _EnterTree для всього піддерева
// ще до першого _Ready, тож агенти гарантовано знайдуть групу незалежно від порядку нодів
// у сцені.
AddToGroup(IPlatformPathfinder.GroupName);
}
public override void _Ready()
{
var navigationLayer = GetNodeOrNull<TileMapLayer>(NavigationLayerPath);
if (navigationLayer == null)
{
GD.PushError($"{Name}: no TileMapLayer found at '{NavigationLayerPath}' - pathfinding graph cannot be built.");
return;
}
_pathfinder = new PlatformPathfinder(navigationLayer, JumpDistance, JumpHeight, StepDistance, JumpHeadroom, ShowDebugGraph);
_pathfinder.Build();
if (ShowDebugGraph)
{
SpawnDebugVisuals();
QueueRedraw(); // Запускає один виклик _Draw, який намалює закешовані debug-лінії
}
OnPathfindingReady();
}
/// <summary>
/// Перевизначте у похідному класі рівня, щоб виконати налаштування, специфічне для рівня, одразу
/// після побудови pathfinding-графа. Викликається в кінці _Ready.
/// </summary>
protected virtual void OnPathfindingReady()
{
}
public override void _Draw()
{
if (!ShowDebugGraph || _pathfinder == null) { return; }
// Лише відтворюємо закешовані відрізки - сам граф уже побудований.
foreach (var (a, b, color) in _pathfinder.DebugLines)
{
DrawLine(ToLocal(a), ToLocal(b), color);
}
}
private void SpawnDebugVisuals()
{
foreach (var spec in _pathfinder.VisualPointSpecs)
{
// Створюємо нову візуальну точку. Коренем сцени GraphPoint має бути Node2D
// (або його нащадок, наприклад Sprite2D), бо позицію задаємо через GlobalPosition.
var visualPoint = graphPointScene.Instantiate<Node2D>();
if (spec.Color != null)
{
visualPoint.Modulate = (Color)spec.Color;
}
if (spec.Scale != 1.0f && spec.Scale > 0.1f)
{
visualPoint.Scale = new Vector2(spec.Scale, spec.Scale);
}
AddChild(visualPoint);
visualPoint.ZIndex = 100;
visualPoint.GlobalPosition = _pathfinder.GlobalFromTile(spec.Tile);
}
}
public Stack<PointInfo> GetPlatform2DPath(Vector2 startGlobalPos, Vector2 endGlobalPos)
=> _pathfinder?.GetPlatform2DPath(startGlobalPos, endGlobalPos) ?? new Stack<PointInfo>();
public Vector2I GlobalToTile(Vector2 globalPosition)
=> _pathfinder?.GlobalToTile(globalPosition) ?? Vector2I.Zero;
}Код Level1Map:
using Godot;
/// <summary>
/// Карта першого рівня. Уся поведінка pathfinding (побудова графа, малювання debug-графа,
/// реалізація IPlatformPathfinder) успадковується від LevelMapBase. Налаштування, специфічне для рівня,
/// можна додати, перевизначивши OnPathfindingReady().
/// </summary>
public partial class Level1Map : LevelMapBase
{
}Клас - PathfindingAgent2D (агент, що ходить по графу)
Спільний "двигун" руху для будь-якого CharacterBody2D, який має дістатися цілі на рівні. Код класу теж великий (понад 700 рядків), тому нижче - опис його роботи.
PathfindingAgent2D - абстрактний клас. Щоб зробити нового персонажа, який ходить до цілі, достатньо успадкувати його, реалізувати метод GetDestination() (він повертає Vector2? - глобальну точку, до якої треба йти, або null, якщо йти нікуди) та запустити таймер перерахунку через StartPathfindTimer().
Як агент працює:
У
_Ready()знаходить pathfinder рівня через групуIPlatformPathfinder.GroupNameта підписується на сигналTimeoutдочірньогоTimer(шлях до нього -PathfindTimerPath, за замовчуванням"PathFindTimer").Щоразу, коли таймер спрацьовує (у нашого краба - кожні 0.3 с) і агент стоїть на землі, викликається
RecalculatePath(): агент звертається доGetDestination(), щоб дізнатися, куди йти, і просить у pathfinder-а новий шляхStack<PointInfo>.В
_PhysicsProcess()агент бере з шляху наступну точку (CurrentTarget) і біжить до неї горизонтально зі швидкістюSpeed. Точка вважається досягнутою, коли агент опиняється в межахHorizontalArrivalTolerance(8 пікселів) від неї за віссю X.Дійшовши до точки, агент бере наступну і вирішує, чи потрібен стрибок: порівнюються прапорці та висота попередньої й наступної точок. Сила стрибка обчислюється за формулою руху тіла під дією гравітації так, щоб дуга приземлила агента саме в цільову точку (значення обмежено діапазоном між
TinyJumpVelocityіJumpVelocity).Там, де достатньо просто пройти, агент не стрибає: невеликі виступи (до
WalkableStepHeight) він переступає. За замовчуванням ця висота дорівнює довжиніSeparationRayShape2D.Якщо агент застряг (майже не рухається в потрібний бік), він підстрибує та просить новий шлях; якщо ці спроби марні - робить паузу й пробує знову пізніше.
Клас - PathfindingGameEntity2D (анімації та життєвий цикл)
Проміжний клас, що додає до агента анімації та прості стани життєвого циклу.
PathfindingGameEntity2D успадковує PathfindingAgent2D: керує анімаціями (idle / walk / spawn / die, їхні імена - експортовані змінні), має стани появи, смерті та видалення (IsSpawning, IsDying, IsDead) і на час цих станів призупиняє рух за шляхом.
Тут реалізовано абстрактний GetDestination() - ціллю за замовчуванням є позиція нода TargetRef. Цей нод визначається так: якщо задано експортований TargetOverride - беремо його, інакше - перший нод із групи TargetGroupName (за замовчуванням - "Player"). TargetRef обчислюється щоразу заново, тому призначення TargetOverride під час гри діє одразу.
Код:
using Godot;
/// <summary>
/// Базовий клас ігрових сутностей, які шукають шлях і мають анімації. Додає до
/// PathfindingAgent2D керування анімаціями (idle/walk/spawn/die), стани життєвого циклу
/// (поява, смерть, видалення) та перевизначення _PhysicsProcess, яке вимикає pathfinding,
/// поки сутність не в стані "ходьби". Ціль за замовчуванням - нод із групи TargetGroupName
/// (або TargetOverride, якщо його задано).
/// </summary>
public abstract partial class PathfindingGameEntity2D : PathfindingAgent2D
{
[ExportGroup("Animation")]
[Export] public string IdleAnimName = "idle";
[Export] public string WalkAnimName = "walk";
[Export] public string SpawnAnimName = "undie";
[Export] public string DieAnimName = "die";
[ExportGroup("Targeting")]
// Явна ціль для конкретного екземпляра (наприклад, NPC, що йде за іншим NPC, а не за гравцем).
// Якщо задана - завжди має пріоритет над пошуком за групою. null означає
// "шукати за групою", а не "цілі немає".
[Export] public Node2D TargetOverride;
[Export] public string TargetGroupName = "Player";
protected AnimatedSprite2D AnimSprite { get; private set; }
protected bool IsSpawning { get; set; }
protected bool IsDying { get; private set; }
protected bool IsDead { get; private set; }
/// <summary>
/// Нод, до якого сутність зараз прямує. Обчислюється щоразу заново, а не кешується, тому
/// призначення <see cref="TargetOverride"/> під час гри (або зміна складу групи) діє одразу.
/// Відсутність цілі - допустимий стан: код, що читає цю властивість, трактує null як
/// "зараз нічого робити".
///
/// Для TargetOverride окремо перевіряється <see cref="IsInstanceValid"/>: якщо нод-ціль
/// видалили, C#-посилання на нього залишається не-null, але будь-який доступ кидає
/// ObjectDisposedException. Тому видалена ціль просто замінюється пошуком за групою.
/// Із груп Godot видалений нод прибирає сам, там додаткова перевірка не потрібна.
/// </summary>
protected Node2D TargetRef =>
TargetOverride != null && IsInstanceValid(TargetOverride)
? TargetOverride
: GetTree().GetFirstNodeInGroup(TargetGroupName) as Node2D;
/// <summary>True, поки сутність у стані, який призупиняє звичайний рух за шляхом.</summary>
protected virtual bool MovementSuspended => IsSpawning || IsDying || IsDead;
protected override void OnAgentReady()
{
AnimSprite = GetNodeOrNull<AnimatedSprite2D>("AnimatedSprite2D");
if (AnimSprite == null)
{
GD.PushError(
$"{Name}: Required child node 'AnimatedSprite2D' (type: AnimatedSprite2D) not found. " +
"Add it as a child of this node. Entity is disabled.");
DisableEntity();
return;
}
AnimSprite.AnimationFinished += OnAnimationFinished;
StartSpawning();
}
/// <summary>Пункт призначення за замовчуванням - поточна позиція <see cref="TargetRef"/>.</summary>
protected override Vector2? GetDestination() => TargetRef?.GlobalPosition;
public override void _PhysicsProcess(double delta)
{
if (IsDead) { return; }
if (MovementSuspended)
{
// Pathfinding пропускаємо, але гравітацію застосовуємо, щоб сутність стояла на землі.
Vector2 velocity = Velocity;
if (!IsOnFloor())
{
velocity.Y += Gravity * (float)delta;
}
velocity.X = 0f;
Velocity = velocity;
MoveAndSlide();
return;
}
base._PhysicsProcess(delta);
UpdateMovementAnimation();
}
/// <summary>Перемикає анімацію між idle та walk залежно від поточного руху.</summary>
protected virtual void UpdateMovementAnimation()
{
bool moving = CurrentTarget != null || Mathf.Abs(Velocity.X) > 1f;
if (Mathf.Abs(Velocity.X) > 1f)
{
AnimSprite.FlipH = Velocity.X < 0f;
}
if (moving)
{
if (AnimSprite.Animation != WalkAnimName)
{
AnimSprite.Play(WalkAnimName);
}
}
else if (AnimSprite.Animation != IdleAnimName)
{
AnimSprite.Play(IdleAnimName);
}
}
/// <summary>Запускає анімацію появи.</summary>
protected virtual void StartSpawning()
{
IsSpawning = true;
AnimSprite.Play(SpawnAnimName);
}
/// <summary>Запускає анімацію смерті.</summary>
protected virtual void StartDying()
{
IsDying = true;
StopPathfindTimer();
AnimSprite.Play(DieAnimName);
}
/// <summary>Обробляє завершення анімацій, якими керує цей клас (поява, смерть).</summary>
protected virtual void OnAnimationFinished()
{
string currentAnim = AnimSprite.Animation;
if (currentAnim == DieAnimName && IsDying)
{
Cleanup();
return;
}
if (currentAnim == SpawnAnimName && IsSpawning)
{
IsSpawning = false;
StartPathfindTimer();
AnimSprite.Play(IdleAnimName);
return;
}
}
/// <summary>Фінальне прибирання перед видаленням сутності з дерева.</summary>
protected virtual void Cleanup()
{
IsDead = true;
StopPathfindTimer();
SetPhysicsProcess(false);
QueueFree();
}
/// <summary>
/// Вимикає сутність після помилки налаштування: вона стає неактивною й невидимою,
/// але лишається у дереві сцени, тож її можна оглянути в редакторі.
/// </summary>
protected void DisableEntity()
{
SetProcess(false);
SetPhysicsProcess(false);
Visible = false;
}
}Клас - PathfindingGameFriendly2D
Дружній NPC, що підходить до цілі, зупиняється на комфортній відстані та знову рушає, коли ціль відходить.
PathfindingGameFriendly2D - основа для будь-якого дружнього NPC. Його поведінку визначають дві експортовані змінні (у пікселях): StopDistance - якщо відстань до цілі не більша за це значення, NPC зупиняється; ResumeDistance - лише коли ціль віддалиться далі за це значення, NPC знову рушає. Відстань вимірюється по прямій між NPC та ціллю (враховуючи й різницю висот).
ResumeDistance має бути більшим за StopDistance. Такий проміжок між порогами називають гістерезисом: без нього ціль, що повільно рухається або коливається біля межі StopDistance, змушувала б NPC раз у раз перемикатися між "стояти" та "йти". Відстань перевіряється лише під час перерахунку шляху: за таймером (у нашого краба - кожні 0.3 с, коли він стоїть на землі) та одразу після завершення шляху. Тому така нестабільність виглядає як "смикання" з частотою кількох разів на секунду.
Код:
using Godot;
/// <summary>
/// Базовий клас для дружніх сутностей (NPC), які просто йдуть за ціллю
/// (<see cref="PathfindingGameEntity2D.TargetRef"/>): підходять, зупиняються на комфортній відстані
/// й знову рушають, коли ціль відійде далеко. Бойової поведінки тут немає - лише рух.
/// </summary>
public abstract partial class PathfindingGameFriendly2D : PathfindingGameEntity2D
{
[ExportGroup("Follow")]
[Export] public float StopDistance = 40f; // На цій відстані та ближче NPC зупиняється
// NPC знову рушає, лише коли ціль віддалиться більше ніж на ResumeDistance (> StopDistance).
// Без цього проміжку ціль, що повільно рухається або коливається біля межі StopDistance,
// змушувала б NPC раз у раз перемикатися між станами "зупинитись" і "іти".
[Export] public float ResumeDistance = 60f;
private bool isWaiting;
/// <summary>
/// Поки NPC чекає, стоїть на землі й у нього не лишилось шляху (CurrentTarget == null), фізику
/// не обробляємо взагалі - рахувати нічого. Умова IsOnFloor() не дає "заморозити" NPC посеред
/// стрибка чи падіння, а CurrentTarget == null - додатковий запобіжник. Прапорець isWaiting
/// виставляється під час першого ж перерахунку шляху, де ціль опинилась у межах StopDistance.
/// </summary>
public override void _PhysicsProcess(double delta)
{
if (isWaiting && CurrentTarget == null && IsOnFloor())
{
return;
}
base._PhysicsProcess(delta);
}
/// <summary>
/// Повертає позицію цілі, а коли NPC уже достатньо близько й вирішив зупинитись - null,
/// щоб система руху просто завершила шлях, а не "дотискала" останні пікселі до цілі.
/// </summary>
protected override Vector2? GetDestination()
{
Vector2? targetPosition = base.GetDestination();
if (targetPosition == null)
{
isWaiting = false;
return null;
}
float distance = GlobalPosition.DistanceTo(targetPosition.Value);
bool wasWaiting = isWaiting;
if (isWaiting)
{
if (distance > ResumeDistance) { isWaiting = false; }
}
else if (distance <= StopDistance)
{
isWaiting = true;
// Одразу обриваємо поточний шлях. Інакше NPC дійшов би до кінця вже побудованого
// шляху (найчастіше він складається з єдиної точки прямо в позиції цілі) і
// зупинився б упритул до неї, хоч би яким великим було StopDistance.
ClearCurrentPath();
}
if (VerboseLogging && isWaiting != wasWaiting)
{
GD.Print($"{Name}: {(isWaiting ? "stopping" : "resuming")} (distance {distance:F1}, StopDistance {StopDistance}, ResumeDistance {ResumeDistance}).");
}
return isWaiting ? null : targetPosition;
}
}Клас та сцена - CrabFriendly
Конкретний дружній NPC - краб.
CrabFriendly завершує ланцюжок: PathfindingAgent2D → PathfindingGameEntity2D → PathfindingGameFriendly2D → CrabFriendly. Усю поведінку (пошук шляху, рух, анімації, зупинка й відновлення руху) він успадковує, тому власного коду майже немає.
Єдине перевизначення - StartSpawning(). Спрайт краба не має окремої анімації появи (лише Walk, Attack, Death). Базова реалізація викликала б Play(SpawnAnimName) з стандартним іменем "undie", а такої анімації в краба немаєю.
Код:
using Godot;
/// <summary>
/// Дружній краб, який іде за гравцем (усю поведінку успадковано від PathfindingGameFriendly2D).
/// </summary>
public partial class CrabFriendly : PathfindingGameFriendly2D
{
// У спрайта цього краба немає анімації появи (є лише Walk/Attack/Death), а базова реалізація
// викликала б Play(SpawnAnimName) з типовим іменем "undie". Такої анімації немає: Godot лише
// виводить помилку, сигнал AnimationFinished не надходить, і краб застряг би в стані IsSpawning
// назавжди. Тому одразу переходимо до звичайної роботи.
protected override void StartSpawning()
{
IsSpawning = false;
StartPathfindTimer();
AnimSprite.Play(IdleAnimName);
}
}Сцена CrabFriendly:
Сцену зручно зробити копією EnemyCrab.tscn: ті самі спрайти, анімації та форми колізій - але зі своїм скриптом і з невеликими змінами.
Дерево:
- CrabFriendly (CharacterBody2D, скрипт CrabFriendly.cs)
- AnimatedSprite2D
- CollisionShape2DBase (CollisionShape2D)
- CollisionShape2DSeparation (CollisionShape2D)
- PathFindTimer (Timer)
Пояснення:
- Корінь - CharacterBody2D зі скриптом CrabFriendly.cs. У копії EnemyCrab.tscn замініть скрипт на CrabFriendly.cs та видаліть нод Area2D разом із дочірнім CollisionShape2D: він був потрібен ворожому крабу, щоб реагувати на стрибок гравця на голову та завдавати гравцю шкоди, а дружньому - ні.
- AnimatedSprite2D має містити анімації, імена яких збігаються з IdleAnimName / WalkAnimName / DieAnimName у скрипті. У краба немає окремої анімації спокою, тому на кореневому ноді виставте IdleAnimName = "Walk", WalkAnimName = "Walk", DieAnimName = "Death". Анімація "Death" не має бути зацикленою - інакше AnimationFinished не спрацює.
- CollisionShape2DBase - звичайна форма колізії краба, а CollisionShape2DSeparation із SeparationRayShape2D - промінь, спрямований униз, до землі під крабом.
- PathFindTimer - новий нод Timer з Wait Time = 0.3 с. Його ім'я має збігатися з PathfindTimerPath ("
PathFindTimer") - кожне спрацювання таймера запускає перерахунок шляху. Запускається таймер із коду (StartPathfindTimer()), тому Autostart вмикати не треба.
Додаємо NPC на рівень
У Level1 з'являється новий нод для NPC та сам дружній краб.
У сцені Level1.tscn створіть під нодом Objects новий Node з назвою NPCs і додайте в нього екземпляр сцени CrabFriendly. Для перевірки задайте екземпляру краба такі значення:
- StopDistance = 100 та ResumeDistance = 150 - краб зупиняється за 100 пікселів від гравця і знову рушає, коли той віддалиться більше ніж на 150 пікселів.
Дерево (зміни в Level1):
- Level1 (Node2D)
- Map (Node)
- Level1Map (екземпляр сцени)
- Paralax (екземпляр сцени)
- Objects (Node)
- Enemies (Node)
- EnemyCrab, EnemyCrab2 ... EnemyCrab5 (екземпляри сцени)
- NPCs (Node)
- CrabFriendly (екземпляр сцени)
- WinTrigger (Node)
- Player (Node)
- HUD (Node)
Ціль краба - перший нод із групи "Player": у сцені Player цю групу вже налаштовано, тому жодних додаткових налаштувань не потрібно. Відійдіть далі за ResumeDistance - краб рушить за вами, підійде на відстань StopDistance та знову зупиниться (якщо до вашого нового місця є шлях у графі). Щоб бачити, коли краб зупиняється й рушає, увімкніть VerboseLogging на ньому та шукайте в консолі рядки "stopping" та "resuming".
Важливо - обмеження pathfinding
Кілька особливостей pathfinding-у.
NPC не дійде туди, куди не веде жодне з'єднання
Граф знає лише про те, що вдалося побудувати з тайлів. Якщо гравець стоїть на платформі, до якої немає жодного з'єднання (наприклад, вона надто висока для стрибка краба - це видно на debug-графі), GetPlatform2DPath() поверне порожній стек, і NPC не знатиме, як туди дійти. Це не помилка коду, а межа графа: змініть JumpDistance / JumpHeight / StepDistance або форму рівня.
Граф не оновлюється автоматично
LevelMapBase будує граф один раз у _Ready(). Рухомі платформи та тайли, змінені під час гри, у граф не потрапляють. Агент бачить лише статичну геометрію навігаційного шару.
Перерахунок шляху не миттєвий
Шлях перераховується за таймером (у краба - кожні 0.3 с, коли він стоїть на землі), а не кожного кадру: це економить ресурси. Додатково перерахунок відбувається одразу після завершення шляху. Тому, коли гравець різко змінює напрямок, NPC реагує з невеликою затримкою.
build info
- compiler
- 2.0.5.1 · 4054EF9BF6C4510A
- built
- 2026-11-08 14:31:25 UTC
WebUniverse