Інтерфейс IDamagable
Передумови
Скачайте даний проєкт та працюйте у ньому.
Нова інформація
Два нових для цієї теми елементи, які використовуються у цьому проєкті: null-coalescing assignment (??=) та struct.
??= (null-coalescing assignment)
a ??= b; - скорочений запис для if (a == null) { a = b; } (те саме, що a = a ?? b;). Якщо a вже НЕ null - вираз праворуч навіть не обчислюється, і значення a лишається як є.
private HUDMenu cached;
cached ??= GetTree().GetFirstNodeInGroup("HUDMenu") as HUDMenu;
// еквівалентно:
if (cached == null)
{
cached = GetTree().GetFirstNodeInGroup("HUDMenu") as HUDMenu;
}Важливо: ??= перевіряє лише null, а не "чи нод ще живий". Якщо кешований нод звільнили (QueueFree(), перезавантаження рівня) - змінна не стає null, і ??= залишає старе, вже поломане посилання. Тому для нодів, які можуть пережити перезавантаження сцени (наприклад, кеш у GameManager), треба ще й перевіряти IsInstanceValid(cached).
struct
struct, на відміну від class, - це value type (тип значення): коли ви передаєте struct у метод чи присвоюєте іншій змінній, копіюється весь вміст, а не посилання на один спільний об'єкт у пам'яті. Дві копії struct повністю незалежні одна від одної.
DamageInfo - це readonly struct, бо це просто короткочасний "пакет" даних про один удар: створили, передали у TakeDamage(), використали - і все. EnemyCrab чи Player, навпаки, - це class (Node), бо вони живуть довго і мають власне місце в дереві сцени.
Нагадування - інтерфейси, які ви вже використовували
Interface - це контракт: список методів/властивостей/подій без реалізації. Клас, що "реалізує" інтерфейс, мусить надати власний код для кожного з них. На відміну від базового класу (наслідування), інтерфейсів у одного класу може бути декілька одразу, і вони не потребують спільного предка.
ISavable
Ви вже використовували інтерфейс ISavable (тема про збереження статусу гри): Player та Lever реалізують його, а SaveManager через RedGodot.GetAllISavableNodes() збирає їх усіх з дерева сцени - не знаючи заздалегідь, скільки таких об'єктів і якого вони типу.
Код:
using System.Collections.Generic;
/// <summary>
/// Інтерфейс-контракт для всіх ігрових об'єктів, які підтримують збереження та завантаження стану.
/// Класи, що реалізують цей інтерфейс, зобов'язані надати логіку серіалізації своїх внутрішніх змінних
/// у єдиний словник даних, який пізніше перетворюється на JSON.
/// </summary>
public interface ISavable
{
/// <summary>
/// Метод зберігає поточний стан об'єкта, додаючи значення у переданий Dictionary<string, object>.
/// Повертає true у разі успішного збереження, false - при виникненні помилок або відсутності даних.
/// </summary>
bool Save(Dictionary<string, object> data);
/// <summary>
/// Метод завантажує попередньо збережений стан об'єкта, читаючи дані з Dictionary<string, object>.
/// Повертає true, якщо дані успішно відновлені, ініціалізуючи поля класу відповідними значеннями.
/// </summary>
bool Load(Dictionary<string, object> data);
}ILeverable та IButtonable
Lever та ToggleButton теж працюють через інтерфейси - ILeverable (один метод OnLeverStateChanged(int state)) та IButtonable (один метод OnButtonPressed()). Поле ObjectUnderControl має тип звичайного Node, а перевірка ObjectUnderControl is ILeverable leverable під час _Ready() визначає, чи вміє призначений об'єкт реагувати на важіль - незалежно від того, який це конкретно клас.
Проблема - шкода обробляється по-різному для кожного типу/ноду/елементу гри
Пригадайте, як зараз (до цієї теми) шкода (damage/HP) були реалізовані у проєкті.
Шкода гравцю реалізована через GameManager.DamagePlayer(int damage, Player player) - функцію, яка приймає клас Player та напряму зменшує HP, сама показує DeathMenu, оновлює HUD.
У EnemyCrab взагалі немає формальної "шкоди" - краб помирає від будь-якого стрибка на голову без здоров'я, а "відскакування" гравця захардкоджене прямо у StartDying(Player player) через vel.Y = -800f.
Якщо захочемо додати третій тип об'єкта, що може отримувати шкоду (наприклад, ящик який можна зламати чи іншого ворога) - доведеться писати ще один окремий, несумісний з іншими спосіб. HUD, GameManager, інша ігрова логіка не мають універсального способу дізнатись "цьому об'єкту щойно завдали шкоди" чи "цей об'єкт помер", яким можна скористатись незалежно від типу.
Вирішення - інтерфейс IDamagable: "я можу отримувати шкоду і повідомляти про це", який Player та EnemyCrab зможуть реалізувати кожен по-своєму, лишаючись при цьому не пов'язаними одне з одним класами (обидва успадковують CharacterBody2D).
Нова файлова структура проєкту
Нові файли важливі у контексті цієї теми. Жовтим - нові файли та файли, що змінились.
- .
- Code
- Libraries
- RedGodot.cs
- RedMath.cs
- Objects
- DamageInfo.cs
- IDamagable.cs
- Scenes
- Enemies
- Crab
- EnemyCrab.cs
- EnemyCrab.tscn
- InteractionArea
- InteractionArea.cs
- InteractionArea.tscn
- Levels
- Level1.tscn
- WinArea2D.cs
- Lever
- Lever.cs
- Lever.tscn
- Managers
- GameManager
- GameManager.cs
- InteractionManager
- InteractionManager.cs
- InteractionManager.tscn
- SaveManager
- ISavable.cs
- SaveManager.cs
- Menus
- DeathMenu
- DeathMenu.cs
- DeathMenu.tscn
- HUDMenu
- HUDMenu.cs
- HUDMenu.tscn
- LevelMenu
- LevelMenu.cs
- LevelMenu.tscn
- MainMenu
- MainMenu.cs
- MainMenu.tscn
- PauseMenu
- PauseMenu.cs
- PauseMenu.tscn
- VictoryMenu
- VictoryMenu.cs
- VictoryMenu.tscn
- MovingPlatform
- MovingPlatform.cs
- MovingPlatform.tscn
- Player
- Player.cs
- Player.tscn
- Portal
- Portal.cs
- Portal.tscn
- ToggleButton
- ToggleButton.cs
- ToggleButton.tscn
Інтерфейс - IDamagable
Контракт для будь-якого класу/ноду, який, що може отримувати шкоду (damage) та має HP.
IDamagable стандартизує лише те, що спільне для будь-якого "здатного отримувати шкоду" об'єкта: чи він уже мертвий (IsDead), подію про отримання шкоди (Damaged), подію про смерть (Died), та Функція, через яку шкода завдається (TakeDamage(DamageInfo info)).
Кожен клас/об'єкт сам вирішує, як саме зберігати здоров'я (Player - через HP, EnemyCrab - через Health) та як саме реагувати на смерть (анімація, DeathMenu, зникнення з рівня тощо). Інтерфейс цього не диктує.
Код:
using System;
/// <summary>
/// Контракт для будь-якого об'єкта, який може отримувати шкоду та повідомляти про свій бойовий
/// стан. Кожна реалізація сама відповідає за власне поле здоров'я (у Player - HP, у EnemyCrab -
/// Health) - цей інтерфейс лише стандартизує, як шкода надходить і як про неї дізнаються
/// спостерігачі (HUD, GameManager, інші об'єкти), без потреби знати конкретний тип того, хто
/// отримав удар.
/// </summary>
public interface IDamagable
{
/// <summary>true, щойно цей об'єкт помер. Ніколи не повертається назад у false.</summary>
bool IsDead { get; }
/// <summary>Викликається щоразу після TakeDamage - вже після того, як шкода застосована.</summary>
event Action<DamageInfo> Damaged;
/// <summary>Викликається рівно один раз - коли здоров'я цього об'єкта вперше досягає нуля або менше.</summary>
event Action Died;
/// <summary>Застосовує один випадок нанесення шкоди. Реалізація сама вирішує свою реакцію (постраждати/померти).</summary>
void TakeDamage(DamageInfo info);
}Структура - DamageInfo
Один "пакет" даних про певну подію нанесення шкоди (удару/stomp-у/ТД).
Замість того, щоб TakeDamage приймав окремо кількість шкоди, інформацію про те хто зробив атаку та кнок-бек (відскок) (або його відсутність) - усе це об'єднано в один readonly struct DamageInfo: Amount (скільки), Source (хто завдав) та опціональне SourceBounceVelocity.
SourceBounceVelocity застосовується саме до Source (атакуючого), а не до цілі - це той самий ефект "гравець стрибнув ворогу на голову і підстрибнув від нього", який раніше був захардкоджений прямо у EnemyCrab через vel.Y = -800f. Метод ApplyBounceToSource() централізує цю логіку в одному місці замість того, щоб дублювати її в кожному класі, що завдає шкоди.
Код:
using Godot;
/// <summary>Описує один випадок нанесення шкоди об'єкту IDamagable.</summary>
public readonly struct DamageInfo
{
/// <summary>Наскільки зменшити здоров'я/HP цілі.</summary>
public int Amount { get; }
/// <summary>Нод, відповідальний за цю шкоду (атакуючий). Може бути null.</summary>
public Node Source { get; }
/// <summary>
/// Опційне перевизначення швидкості, яке застосовується до Source (АТАКУЮЧОГО), а не до цілі,
/// що отримує шкоду. Моделює вже наявну механіку "гравець стрибає ворогу на голову і
/// підстрибує від нього" - єдину подібну до "відкидання" поведінку в цьому проєкті. Саму
/// ціль, що отримала удар, наразі ніщо не відкидає.
/// </summary>
public Vector2? SourceBounceVelocity { get; }
public DamageInfo(int amount, Node source, Vector2? sourceBounceVelocity = null)
{
Amount = amount;
Source = source;
SourceBounceVelocity = sourceBounceVelocity;
}
/// <summary>Застосовує SourceBounceVelocity до Source, якщо воно задане.</summary>
public void ApplyBounceToSource()
{
if (SourceBounceVelocity is Vector2 v && Source is CharacterBody2D body)
{
body.Velocity = v;
}
}
}EnemyCrab тепер реалізує IDamagable
Той самий краб з аналогічною поведінкою - але damage тепер наноситься через єдиний, універсальний механізм.
Що змінилось у EnemyCrab:
Клас тепер оголошує
: CharacterBody2D, IDamagable.Додано поле
[Export] public int Health = 1;та властивістьIsDead { get; private set; }.Додано події
DamagedтаDied.Замість окремого
StartDying(Player player), що напряму лізло уplayer.Velocity, тепер єTakeDamage(DamageInfo info): зменшуєHealth, викликаєinfo.ApplyBounceToSource(), викликаєDamaged, і при потребі виставляєIsDead = trueта викликаєDied- в одному й тому самому місці, синхронно (як і вPlayer), точно за контрактомIDamagable.IsDead.StartDying()викликається тут же (тепер без параметрів - лише анімація смерті, без мануального відскоку), а вимкнення колізій,SetPhysicsProcess(false)все ще викликається після анімації, уCleanup().OnBodyEnteredпри стрибку гравця на голову тепер викликає власнийTakeDamage(new DamageInfo(player.StompDamage, player, new Vector2(player.Velocity.X, player.StompBounceVelocity)))замість прямого маніпулювання швидкістю.HurtPlayerзамістьGameManager.DamagePlayer(Damage, player)тепер просто викликаєplayer.TakeDamage(new DamageInfo(Damage, this))- той самий метод, який спрацював би для будь-якогоIDamagable, а не лише дляPlayer.
Код:
using System;
using Godot;
public partial class EnemyCrab : CharacterBody2D, IDamagable
{
public float Gravity = 980f;
[Export] public float Speed = 60f;
[Export] public int Damage = 1;
[Export] public int Health = 1;
private string WalkAnimName = "Walk";
private string AttackAnimName = "Attack";
private string DeathAnimName = "Death";
private int Direction = 1;
private Area2D Area;
private AnimatedSprite2D AnimSprite;
private bool Dying = false;
private bool PauseMovement = false;
public bool IsDead { get; private set; }
public event Action<DamageInfo> Damaged;
public event Action Died;
public override void _Ready()
{
Area = GetNode<Area2D>("Area2D");
Area.BodyEntered += OnBodyEntered;
AnimSprite = GetNode<AnimatedSprite2D>("AnimatedSprite2D");
AnimSprite.AnimationFinished += OnAnimationFinished;
}
public override void _PhysicsProcess(double delta)
{
if (IsDead || Dying) return;
Vector2 velocity = Velocity;
if (!IsOnFloor())
{
velocity.Y += Gravity * (float)delta;
}
if (PauseMovement)
{
velocity.X = 0;
}
else
{
velocity.X = Direction * Speed;
}
Velocity = velocity;
MoveAndSlide();
if (IsOnWall())
{
Direction *= -1;
AnimSprite.FlipH = !AnimSprite.FlipH;
}
}
private void OnBodyEntered(Node2D body)
{
if (IsDead || Dying) return;
if (body is not Player player) return;
bool playerAbove = (GlobalPosition.Y - player.GlobalPosition.Y) > 10;
if (playerAbove)
{
// Раніше тут напряму виставлялась швидкість гравця (vel.Y = -800f) - тепер це те саме
// "відскакування атакуючого", яке DamageInfo/TakeDamage вміють робити для будь-кого,
// хто реалізує IDamagable, а не лише для цього конкретного випадку.
TakeDamage(new DamageInfo(
player.StompDamage,
player,
new Vector2(player.Velocity.X, player.StompBounceVelocity)));
}
else
{
HurtPlayer(player);
}
}
/// <summary>Реалізація IDamagable.TakeDamage - той самий метод, який гравець викликає на нас, коли стрибає нам на голову.</summary>
public void TakeDamage(DamageInfo info)
{
Health -= info.Amount;
info.ApplyBounceToSource();
Damaged?.Invoke(info);
if (Health <= 0 && !Dying)
{
IsDead = true;
Died?.Invoke();
StartDying();
}
}
private void StartDying()
{
Dying = true;
AnimSprite.Play(DeathAnimName);
}
private void OnAnimationFinished()
{
string currentAnim = AnimSprite.Animation;
if (currentAnim == DeathAnimName && Dying)
{
Cleanup();
}
if (currentAnim == AttackAnimName)
{
PauseMovement = false;
AnimSprite.Play(WalkAnimName);
}
}
private void Cleanup()
{
// IsDead уже true - виставлено синхронно з Died у TakeDamage(), а не тут.
SetPhysicsProcess(false);
Area.Monitoring = false;
foreach (var child in FindChildren("", "CollisionShape2D"))
{
if (child is CollisionShape2D shape)
{
shape.Disabled = true;
}
}
}
private void HurtPlayer(Player player)
{
GD.Print("Player hurt!");
PauseMovement = true;
AnimSprite.Play(AttackAnimName);
// Раніше тут викликався GameManager.DamagePlayer(Damage, player) - конкретний метод для
// конкретного типу Player. Тепер це просто TakeDamage() з інтерфейсу IDamagable: той самий
// виклик спрацював би для будь-якого IDamagable, а не лише для Player.
player.TakeDamage(new DamageInfo(Damage, this));
}
}Player також реалізує IDamagable
Один клас - два незалежні інтерфейси.
Player вже реалізовував ISavable. Тепер до оголошення класу додається ще й IDamagable: public partial class Player : CharacterBody2D, ISavable, IDamagable. Це і є та сама ключова властивість інтерфейсів, про яку йшлося на початку - один клас може реалізовувати скільки завгодно інтерфейсів одночасно, і вони жодним чином не заважають один одному.
Захардкоджені раніше значення (сила шкоди від stomp-у, сила відскоку -800f) стали експортованими полями StompDamage та StompBounceVelocity прямо на Player - тепер їх можна налаштувати з інтерфейсу, а не шукати в коді.
TakeDamage(DamageInfo info) тут виглядає майже так само, як у EnemyCrab, лише зменшує HP замість Health - кожен клас сам вирішує, як зберігати своє здоров'я, інтерфейс цього не диктує. info.ApplyBounceToSource() викликається тут, так само як і в EnemyCrab.
Код:
using Godot;
using System;
using System.Collections.Generic;
// Клас PlayerCharacterBody2d успадковує CharacterBody2D, що дозволяє йому мати фізичні властивості, такі як маса, прискорення, колізії.
public partial class Player : CharacterBody2D, ISavable, IDamagable
{
// Константи для фізичних параметрів гравця
private const float Acceleration = 300.0f; // Прискорення при русі
private const float JumpAcceleration = 700f; // Сила стрибка
private const float MaxSpeed = 350; // Максимальна швидкість руху
private const float AnimationMaxFPS = 20; // Максимальна швидкість анимації для ходьби
private const float PushForce = 1000; // Сила відштовхування при колізії з іншими об'єктами
// Вектор гравітації (вниз)
private Vector2 Gravity = new Vector2(0, 50f);
// Посилання на AnimatedSprite2D для відображення анімації
public AnimatedSprite2D AnimatedSprite2D { private set; get; }
[Export]
public int HP { get; set; } = 3;
// Раніше -800f було захардкоджено прямо у EnemyCrab.StartDying(). Тепер це налаштовується
// тут, на самому Player-і, і передається як SourceBounceVelocity у DamageInfo, коли Player
// когось топче.
[Export] public int StompDamage = 1;
[Export] public float StompBounceVelocity = -800f;
public bool IsDead { get; private set; }
public event Action<DamageInfo> Damaged;
public event Action Died;
// Метод викликається один раз під час ініціалізації об'єкта
public override void _Ready()
{
// Отримуємо посилання на AnimatedSprite2D з сцени
AnimatedSprite2D = GetNode<AnimatedSprite2D>("AnimatedSprite2D");
// Реєструємось у GameManager один раз, щоб він міг підписатись на Damaged/Died.
GetNode<GameManager>("/root/GameManager").RegisterPlayer(this);
}
// Основний метод фізичного процесу, викликається кожен кадр
public override void _PhysicsProcess(double delta)
{
// Отримуємо напрямок руху з методу GetDirection()
Vector2 direction = GetDirection();
Vector2 velocity = Velocity;
// Додаємо прискорення в напрямку руху
velocity += direction * Acceleration * (float)delta;
// Додаємо гравітацію
velocity += Gravity;
// Обмежуємо швидкість по X, щоб не перевищувала MaxSpeed
velocity.X = Mathf.Clamp(velocity.X, -MaxSpeed, MaxSpeed);
// Якщо гравець рухається по X
if (velocity.X != 0)
{
// Якщо рух відбувається вправо
if (velocity.X > 0)
{
AnimatedSprite2D.FlipH = true; // Перевертаємо спрайт вправо
}
// Якщо рух відбувається вліво
else if (velocity.X < 0)
{
AnimatedSprite2D.FlipH = false; // Перевертаємо спрайт вліво
}
// Встановлюємо анімацію ходьби
AnimatedSprite2D.Animation = "walk";
// Змінюємо швидкість анімації залежно від швидкості руху
AnimatedSprite2D.SpriteFrames.SetAnimationSpeed("walk", AnimationMaxFPS * (Math.Abs(velocity.X) / MaxSpeed));
}
else
{
// Якщо гравець не рухається по X, встановлюємо анімацію "idle"
AnimatedSprite2D.Animation = "idle";
}
// Запускаємо анімацію
AnimatedSprite2D.Play();
// Реалізуємо інерцію при зміні напрямку руху
if (direction.X == 1 && velocity.X < 0 || direction.X == -1 && velocity.X > 0)
{
velocity.X *= 0.90f; // Зменшуємо швидкість по X
}
else if (direction.X == 0)
{
velocity.X *= 0.90f; // Зменшуємо швидкість по X при відсутності напрямку
}
// Якщо натиснута клавіша пробіл і гравець на землі, робимо стрибок
if (Input.IsPhysicalKeyPressed(Key.Space) && IsOnFloor())
{
velocity.Y -= JumpAcceleration; // Змінюємо швидкість по Y вгору
}
// Встановлюємо нову швидкість гравця
Velocity = velocity;
// Виконуємо фізичний рух об'єкта
if (MoveAndSlide())
{
// Отримуємо кількість колізій
int collisionCount = GetSlideCollisionCount();
// Перебираємо всі колізії
for (int i = 0; i < collisionCount; i++)
{
KinematicCollision2D collision = GetSlideCollision(i);
// Якщо колізія відбувається з RigidBody2D
if (collision.GetCollider() is RigidBody2D collider)
{
// Застосовуємо силу відштовхування в напрямку нормалі колізії
collider.ApplyForce(collision.GetNormal() * -PushForce);
}
}
}
}
// Метод для визначення напрямку руху гравця
public Vector2 GetDirection()
{
Vector2 direction = new Vector2(); // Створюємо вектор напрямку
// Якщо натиснута клавіша W — рух вгору
if (Input.IsPhysicalKeyPressed(Key.W))
{
direction.Y += -1;
}
// Якщо натиснута клавіша S — рух вниз
if (Input.IsPhysicalKeyPressed(Key.S))
{
direction.Y += 1;
}
// Якщо натиснута клавіша A — рух вліво
if (Input.IsPhysicalKeyPressed(Key.A))
{
direction.X += -1;
}
// Якщо натиснута клавіша D — рух вправо
if (Input.IsPhysicalKeyPressed(Key.D))
{
direction.X += 1;
}
return direction; // Повертаємо напрямок руху
}
/// <summary>Реалізація IDamagable.TakeDamage.</summary>
public void TakeDamage(DamageInfo info)
{
HP -= info.Amount;
info.ApplyBounceToSource();
Damaged?.Invoke(info);
if (HP <= 0 && !IsDead)
{
IsDead = true;
Died?.Invoke();
}
}
public bool Save(Dictionary<string, object> data)
{
Red.RedGodot.SaveData(this, data, "Position", Position);
Red.RedGodot.SaveData(this, data, "Velocity", Velocity);
return true;
}
public bool Load(Dictionary<string, object> data)
{
Position = Red.RedGodot.LoadData<Vector2>(this, data, "Position");
Velocity = Red.RedGodot.LoadData<Vector2>(this, data, "Velocity");
return true;
}
}GameManager реагує через події замість конкретного методу
GameManager більше не має спеціального DamagePlayer(int, Player) - він просто підписується на Damaged/Died.
Метод RegisterPlayer(Player p) підписується на player.Damaged та player.Died. OnPlayerDamaged оновлює HUD, а OnPlayerDied показує DeathMenu.
OnPlayerDamaged кешує HUDMenu замість того, щоб шукати його по дереву на кожен виклик, як робив старий GameManager.DamagePlayer. Але просте HUDMenu ??= ... тут не підійде: GameManager - autoload, і переживає перезавантаження рівня (наприклад, кнопку "Рестарт" на DeathMenu чи повторний вибір рівня з LevelMenu), а HUDMenu попередньої сцени - ні: після ChangeSceneToPacked/ChangeSceneToFile стара HUDMenu звільняється, але кешоване поле в GameManager просто лишається вказувати на вже видалений (freed) нод - і це не робить його null, тому ??= нічого не оновить. Звернення до звільненого ноду кидає помилку, тому перед повторним використанням кешу треба перевіряти IsInstanceValid(HUDMenu), а не лише HUDMenu == null.
GameManager все ще підписується екземпляр Player (бо HUD показує саме здоров'я 'одного' гравця) - але сам механізм підписки (Damaged/Died) однаково підійшов би для будь-якого IDamagable, якби HUD-у колись знадобилось стежити ще й, наприклад, за здоров'ям союзника.
Код:
using Godot;
public partial class GameManager : Node
{
private PackedScene PauseMenuScene { get; set; }
private PackedScene DeathMenuScene { get; set; }
private Node PauseMenuInstance { get; set; }
private Node DeathMenuInstance { get; set; }
private HUDMenu HUDMenu { get; set; } = null;
private Player player;
public override void _Ready()
{
PauseMenuScene = GD.Load<PackedScene>("res://Scenes/Menus/PauseMenu/PauseMenu.tscn");
DeathMenuScene = GD.Load<PackedScene>("res://Scenes/Menus/DeathMenu/DeathMenu.tscn");
// КРИТИЧНО: Встановлюємо ProcessMode в Always, щоб цей Node продовжував
// обробляти ввід та працювати, навіть коли GetTree().Paused = true
ProcessMode = ProcessModeEnum.Always;
}
public override void _UnhandledInput(InputEvent @event)
{
// Перевіряємо, чи було натиснуто дію "ui_cancel" (за замовчуванням це клавіша ESC)
if (@event.IsActionPressed("ui_cancel"))
{
TogglePause();
// Позначаємо подію як оброблену, щоб вона не передавалася далі іншим об'єктам
GetViewport().SetInputAsHandled();
}
}
private void TogglePause()
{
// Визначаємо новий стан паузи (інвертуємо поточний стан GetTree().Paused)
bool willBePaused = !GetTree().Paused;
// Встановлюємо стан паузи для всього дерева сцени
GetTree().Paused = willBePaused;
if (willBePaused)
{
// Якщо гра стає на паузу: створюємо екземпляр меню паузи та додаємо його в корінь сцени
PauseMenuInstance = PauseMenuScene.Instantiate<Node>();
GetTree().Root.AddChild(PauseMenuInstance);
}
else
{
// Якщо гра виходить з паузи: перевіряємо існування меню та видаляємо його з пам'яті
if (PauseMenuInstance != null && IsInstanceValid(PauseMenuInstance))
{
PauseMenuInstance.QueueFree();
PauseMenuInstance = null;
}
}
}
// Публічний метод для примусового виходу з режиму паузи (наприклад, при натисканні кнопки "Resume")
public void Unpause()
{
if (GetTree().Paused)
TogglePause();
}
public void ShowDeathMenu()
{
GetTree().Paused = true;
DeathMenuInstance = DeathMenuScene.Instantiate<Node>();
GetTree().Root.AddChild(DeathMenuInstance);
}
public void RegisterPlayer(Player p)
{
player = p;
player.Damaged += OnPlayerDamaged;
player.Died += OnPlayerDied;
}
private void OnPlayerDamaged(DamageInfo info)
{
// GameManager - autoload, тому переживає перезавантаження рівня/сцени, а HUDMenu - ні:
// після ChangeSceneToPacked/ChangeSceneToFile стара HUDMenu звільняється, і кешоване тут
// посилання лишається вказувати на вже видалений (freed) нод. ??= саме через це не годиться -
// він побачить, що поле вже не null, і залишить стале посилання як є. IsInstanceValid()
// перевіряє саме це - чи ще живий той нод, на який ми посилаємось - і лише тоді шукає HUDMenu
// заново.
if (HUDMenu == null || !IsInstanceValid(HUDMenu))
{
HUDMenu = GetTree().GetFirstNodeInGroup("HUDMenu") as HUDMenu;
}
HUDMenu?.UpdateHealth(player.HP);
}
private void OnPlayerDied() => ShowDeathMenu();
}Важливо - RegisterPlayer сам себе не викличе
RegisterPlayer(Player p) лише готовий приймати виклик - хтось має викликати його рівно один раз, інакше GameManager ніколи не підпишеться на player.Damaged/player.Died, і HUD з DeathMenu просто мовчки перестануть реагувати на шкоду гравцю. Це runtime помилка, а не compile time помилка - проєкт зкомпілюється та запуститься 'нормально'.
build info
- compiler
- 2.0.5.1 · 4054EF9BF6C4510A
- built
- 2026-11-08 14:31:25 UTC
WebUniverse