리스너 시스템
이벤트/리스너 시스템은 게임 코어와 스크립트 기반 로직을 분리하도록 설계된 우선순위 기반의 이벤트 구동 아키텍처입니다. 개발자는 코어 엔진의 소스 코드를 직접 수정하지 않고도 특정 게임 이벤트(예: 전투, 캐릭터 로그인, 아이템 생성 등)에 "후크(hook)"를 걸어 사용자 정의 코드를 실행할 수 있습니다.
핵심 개념
이 시스템은 네 가지 주요 기둥을 바탕으로 구축되었습니다.
- EventType: 게임에서 발생하는 특정 사건을 나타내는 열거형(상수)입니다 (예:
CREATURE_KILL,PLAYER_LOGIN,NPC_TALK). - BaseEvent: 데이터 캐리어입니다. 각 이벤트 유형에는 해당 사건에 대한 모든 관련 정보를 포함하는
BaseEvent구현체(예:CreatureKill이벤트)가 있습니다. - ListenersContainer: 다양한 이벤트에 대한 리스너를 보관하는 특수 저장 컨테이너입니다. L2J의 많은 객체(플레이어, NPC, 성, 혈맹 등)는
ListenersContainer를 구현하거나 소유하고 있습니다. - EventDispatcher: 알림 프로세스를 관리하여 리스너가 올바른 우선순위 순서대로 호출되도록 보장하는 엔진입니다.
작동 원리: 실행 라이프사이클
월드에서 액션이 발생할 때(예: 플레이어가 몬스터를 공격할 때):
- 이벤트 인스턴스화: 코어 엔진은 공격자, 대상, 데미지, 사용된 스킬 등을 채워 넣은 새로운
AttackableAttack이벤트 객체를 생성합니다. - 디스패칭: 코어는
EventDispatcher.getInstance().notifyEvent(event, targetContainer)를 호출합니다. - 리스너 검색: 디스패처는 다음 위치에서 해당
EventType에 등록된 모든 리스너를 가져옵니다.- 로컬 컨테이너: 관련된 특정 대상(예: 공격받은 몬스터).
- 글로벌 컨테이너:
Containers.Global()또는Containers.Players()와 같은 중앙 집중식 컨테이너.
- 우선순위 정렬: 리스너는
priority값에 따라 정렬됩니다(값이 높을수록 먼저 실행됨). - 실행: 디스패처는 리스너를 순회하며 순차적으로 실행합니다.
- 반환/중단: 리스너가
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 | 월드의 모든 곳에서 발생하는 이벤트를 수신합니다. |
NPC | NPC 템플릿에 연결됩니다. 해당 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)의 세 가지 불리언 값의 의미는 다음과 같습니다.
- Abort: 다른 리스너에게 알림을 중단합니다.
- Override: 이전의 모든 반환 값을 대체합니다.
- 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());
}
}
이 패턴은 영구 리스너를 사용하고 상태 관리를 자동으로 처리하므로 매우 효율적입니다. "부활 버프", "스폰 보호" 또는 사망 후의 복잡한 퀘스트 트리거를 구현하는 데 적합합니다.
권장 사항 및 팁
- 어노테이션 사용: 읽기 쉽고
AbstractScript에 의해 자동으로 관리됩니다 (예: 스크립트가 다시 로드될 때 자동으로 등록 해제됨). - 신중한 우선순위 설정:
Integer.MAX_VALUE: 로깅이나 보안과 같이 "반드시 먼저 실행되어야 하는" 서비스용입니다.- 양수 값: 표준 게임 메커니즘 이전에 발생해야 하는 로직용입니다.
- 음수 값: 다른 어떤 것도 이벤트를 취소하지 않은 경우에만 발생해야 하는 로직용입니다.
- Null 체크: 환경에 의한 죽음이나 복잡한 스킬은 예상치 못한 이벤트 상태를 생성할 수 있으므로 항상
event.attacker()또는event.target()이 null인지 확인하세요. - 무거운 로직 지양:
ATTACK과 같이 빈번하게 발생하는 이벤트가 많으므로 리스너 내부 로직은 빠르게 유지하세요. 무거운 계산이나 데이터베이스 작업이 필요한 경우ThreadPoolManager를 사용하세요.