Перейти до основного вмісту

Система слухачів

Система подій/слухачів — це пріоритетна архітектура, керована подіями, розроблена для відокремлення ігрового ядра від логіки на основі скриптів. Вона дозволяє розробникам «підключатися» к певним ігровим подіям (таким як бій, вхід персонажа в гру або створення предмета) і виконувати власний код без прямої зміни вихідного коду основного движка.

Основні концепції

Система базується на чотирьох основних стовпах:

  1. EventType: Перерахування (константа), що представляє певну подію в ігрі (наприклад, CREATURE_KILL, PLAYER_LOGIN, NPC_TALK).
  2. BaseEvent: Носій даних. Кожному типу події відповідає реалізація BaseEvent (наприклад, подія CreatureKill), що містить всю релевантну інформацію про конкретну подію.
  3. ListenersContainer: Спеціалізований контейнер для зберігання слухачів різних подій. Багато об'єктів у L2J (гравці, NPC, замки, клани) реалізують або володіють ListenersContainer.
  4. EventDispatcher: Движок, що керує процесом сповіщення, гарантуючи виклик слухачів у правильному порядку пріоритету.

Як це працює: життєвий цикл виконання

Коли у світі відбувається дія (наприклад, Гравець атакує Монстра):

  1. Створення екземпляра події: Основний движок створює новий об'єкт події AttackableAttack, заповнюючи його даними про атакуючого, ціль, шкоду та використане вміння.
  2. Диспетчеризація: Ядро викликає EventDispatcher.getInstance().notifyEvent(event, targetContainer).
  3. Пошук слухачів: Диспетчер витягує всіх зареєстрованих слухачів для цього EventType з:
    • Локального контейнера: Конкретної залученої цілі (наприклад, атакованого Монстра).
    • Глобальних контейнерів: Централізованих контейнерів, таких як Containers.Global() або Containers.Players().
  4. Сортування за пріоритетом: Слухачі сортуються за значенням priority (вищі значення виконуються раніше).
  5. Виконання: Диспетчер перебирає слухачів і виконує їх послідовно.
  6. Повернення/Переривання: Якщо слухач повертає TerminateReturn або використовує прапор abort(), диспетчер може припинити пошук подальших слухачів або подати ядру сигнал про скасування події.

Реєстрація слухачів

Існує три способи реєстрації слухача: від найбільш поширеного (анотації) до ручного керування.

1. На основі анотацій (рекомендовано для скриптів)

У будь-якому класі, що розширює AbstractScript (наприклад, скрипти ШІ або квести), ви можете використовувати анотації для реєстрації методів. Це найчистіший і найпоширеніший метод.

@RegisterEvent(EventType.CREATURE_KILL)
@RegisterType(ListenerRegisterType.NPC)
@Id(20432) // Elpy
@Priority(100)
public void onElpyKill(CreatureKill event) {
LOG.info("Elpy був убитий гравцем {}", event.attacker().getName());
}

Доступні фільтруючі анотації:

  • @Id(int...): Фільтрація за конкретним ID NPC, предмета або замку.
  • @Ids({@Id(1), @Id(2)}): Дозволяє використовувати кілька ID.
  • @Range(from=X, to=Y): Фільтрація за діапазоном ID.
  • @NpcLevelRange(from=X, to=Y): Фільтрація за рівнем NPC.
  • @Priority(int): Керує порядком виконання (за замовчуванням 0).

2. Функціональна реєстрація (Lambda)

Зручно для швидкої реєстрації в конструкторі.

public MyScript() {
registerConsumer((PlayerLogin event) -> {
event.player().sendMessage("З поверненням!");
}, EventType.PLAYER_LOGIN, ListenerRegisterType.GLOBAL_PLAYERS);
}

3. Ручна реєстрація

Використовується рідко, але ефективна для динамічного додавання слухачів до конкретних екземплярів.

L2PcInstance player = ...;
player.addListener(new ConsumerEventListener(player, EventType.ATTACK, (Attack event) -> {
// Власна логіка для цього конкретного гравця
}, this));

Типи реєстрації (Область дії)

ListenerRegisterType визначає, де прикріплений слухач:

ТипОпис
GLOBALСлухає подію всюди у світі.
NPCПрикріплений до шаблонів NPC. Спрацьовує для всіх NPC з цим ID.
ITEMПрикріплений до шаблонів предметів.
PLAYERПрикріплений до конкретного екземпляра гравця.
GLOBAL_NPCSСлухає події для ВСІХ NPC глобально.
GLOBAL_MONSTERSСлухає події для ВСІХ монстрів глобально.
GLOBAL_PLAYERSСлухає події для ВСІХ гравців глобально.
CASTLE / FORTПрикріплений до конкретної резиденції.
ZONEПрикріплений до конкретної зони.

Керування виконанням: TerminateReturn

Система дозволяє слухачам повертати значення ядру. TerminateReturn можна використовувати для повної відміни дії або переривання ланцюжка слухачів.

Приклад: запобігання смерті

Встановивши високий пріоритет, ви можете перехопити подію до того, як вона станеться.

@RegisterEvent(EventType.CREATURE_KILL)
@RegisterType(ListenerRegisterType.GLOBAL_PLAYERS)
@Priority(Integer.MAX_VALUE) // Гарантує, що це виконається ПЕРШИМ
public TerminateReturn onPlayerDeath(CreatureKill event) {
if (event.target().isGM()) {
event.target().setCurrentHp(event.target().getMaxHp());
return new TerminateReturn(true, true, true); // Відмінити смерть!
}
return null;
}

Три логічних значення в TerminateReturn(boolean abort, boolean override, boolean value) означають:

  1. Abort: Припинити сповіщення інших слухачів.
  2. Override: Замінити будь-яке попереднє значення, що повертається.
  3. Value: Фактичний логічний результат, що повертається ядру (наприклад, «Чи була ця дія успішною?»).

Розширений приклад: Відстеження відродження (Respawn)

У L2J немає єдиної події PLAYER_RESPAWN, оскільки відродження — це складний процес, що складається з декількох етапів (смерть, вибір точки та телепортація). Проте ви можете ефективно відстежувати цей життєвий цикл за допомогою системи слухачів.

Наступний скрипт, RespawnListener.java, демонструє надійний подхід. Замість складних динамічних слухачів він використовує спільний стан (Set) для відстеження мертвих гравців і обробляє їх при наступній телепортації (що означає відродження при натисканні "В місто").

public class RespawnListener extends AbstractNpcAI {
private static final int ELPY_ID = 20432;
private final Set<Integer> _pendingRespawn = ConcurrentHashMap.newKeySet();

@RegisterEvent(CREATURE_KILL)
@RegisterType(GLOBAL_PLAYERS)
public void onPlayerDeath(CreatureKill event) {
final var target = event.target();
if (target.isPlayer()) {
_pendingRespawn.add(target.getObjectId());
}
}

@RegisterEvent(CREATURE_TELEPORTED)
@RegisterType(GLOBAL_PLAYERS)
public void onPlayerTeleport(CreatureTeleported event) {
final var creature = event.creature();
if (creature.isPlayer() && _pendingRespawn.remove(creature.getObjectId())) {
// Створити NPC у новому місці знаходження гравця.
// Параметри: npcId, x, y, z, heading, randomOffset, despawnTime
addSpawn(ELPY_ID, creature.getX(), creature.getY(), creature.getZ(), creature.getHeading(), false, 60000);
}
}

@RegisterEvent(PLAYER_LOGOUT)
@RegisterType(GLOBAL_PLAYERS)
public void onPlayerLogout(PlayerLogout event) {
_pendingRespawn.remove(event.player().getObjectId());
}
}

Цей патерн дуже ефективний, оскільки використовує постійні слухачі та забезпечує самоочищення керування станом. Він ідеально підходить для реалізації таких систем, як "Баффи воскресіння", "Захист при появі" або складних тригерів квестів після смерті.


Кращі практики та поради

  1. Використовуйте анотації: Їх легше читати, і вони автоматично керуються AbstractScript (наприклад, вони автоматично скасовують реєстрацію при перезавантаженні скрипта).
  2. Ретельно встановлюйте пріоритети:
    • Integer.MAX_VALUE: Для сервісів, які «мають виконуватися першими», таких як логування або безпека.
    • Позитивні значення: Для логіки, яка має відбутися до стандартних ігрових механік.
    • Негативні значення: Для логіки, яка має відбутися лише в тому випадку, якщо ніщо інше не скасувало подію.
  3. Перевірка на null: Завжди перевіряйте, що event.attacker() або event.target() не дорівнюють null, оскільки смерті від оточення або складні вміння можуть призводити до несподіваних станів подій.
  4. Уникайте важкої логіки: Оскільки багато подій (таких як ATTACK) спрацьовують часто, намагайтеся, щоб логіка всередині слухача виконувалася швидко. Використовуйте ThreadPoolManager, якщо вам потрібно виконати важкі обчислення або операції з базою даних.