Перейти к основному контенту

Система слушателей

Система событий/слушателей представляет собой приоритетную архитектуру, управляемую событиями, предназначенную для отделения игрового ядра от логики на основе скриптов. Она позволяет разработчикам «подключаться» к определенным игровым событиям (таким как бой, вход персонажа в игру или создание предмета) и выполнять собственный код без прямого изменения исходного кода основного движка.

Основные концепции

Система строится на четырех основных столпах:

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