본문으로 건너뛰기

리스너 시스템

이벤트/리스너 시스템은 게임 코어와 스크립트 기반 로직을 분리하도록 설계된 우선순위 기반의 이벤트 구동 아키텍처입니다. 개발자는 코어 엔진의 소스 코드를 직접 수정하지 않고도 특정 게임 이벤트(예: 전투, 캐릭터 로그인, 아이템 생성 등)에 "후크(hook)"를 걸어 사용자 정의 코드를 실행할 수 있습니다.

핵심 개념

이 시스템은 네 가지 주요 기둥을 바탕으로 구축되었습니다.

  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(AI 스크립트나 퀘스트 등)를 확장하는 모든 클래스에서 어노테이션을 사용하여 메서드를 등록할 수 있습니다. 가장 깔끔하고 일반적인 방법입니다.

@RegisterEvent(EventType.CREATURE_KILL)
@RegisterType(ListenerRegisterType.NPC)
@Id(20432) // Elpy
@Priority(100)
public void onElpyKill(CreatureKill event) {
LOG.info("엘피가 {}에 의해 죽었습니다.", event.attacker().getName());
}

사용 가능한 필터링 어노테이션:

  • @Id(int...): 특정 NPC, 아이템 또는 성 ID별로 필터링합니다.
  • @Ids({@Id(1), @Id(2)}): 여러 ID를 허용합니다.
  • @Range(from=X, to=Y): ID 범위별로 필터링합니다.
  • @NpcLevelRange(from=X, to=Y): NPC 레벨 범위별로 필터링합니다.
  • @Priority(int): 실행 순서를 제어합니다 (기본값은 0).

2. 함수형(람다) 등록

생성자 내에서 빠르게 등록할 때 유용합니다.

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월드의 모든 곳에서 발생하는 이벤트를 수신합니다.
NPCNPC 템플릿에 연결됩니다. 해당 ID를 가진 모든 NPC에 대해 트리거됩니다.
ITEM아이템 템플릿에 연결됩니다.
PLAYER특정 플레이어 인스턴스에 연결됩니다.
GLOBAL_NPCS전역적으로 모든 NPC의 이벤트를 수신합니다.
GLOBAL_MONSTERS전역적으로 모든 몬스터의 이벤트를 수신합니다.
GLOBAL_PLAYERS전역적으로 모든 플레이어의 이벤트를 수신합니다.
CASTLE / FORT특정 거주지(성/요새)에 연결됩니다.
ZONE특정 지역(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를 사용하세요.