メインコンテンツへスキップ

リスナーシステム

イベント/リスナーシステムは、ゲームのコアからスクリプトベースのロジックを切り離すために設計された、優先度ベースのイベント駆動型アーキテクチャです。これにより、開発者はメインエンジンのソースコードを直接変更することなく、特定のゲームイベント(戦闘、キャラクターのログイン、アイテムの作成など)に「フック」し、独自のコードを実行できます。

コアコンセプト

このシステムは、4つの主要な柱に基づいています:

  1. EventType(イベントタイプ):ゲーム内の特定のイベントを表す列挙型(定数)(例:CREATURE_KILLPLAYER_LOGINNPC_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() フラグを使用したりすると、ディスパッチャーは次のリスナーの検索を停止したり、コアにイベントをキャンセルするよう通知したりできます。

リスナーの登録

リスナーを登録するには、最も一般的な方法(アノテーション)から手動制御まで、3つの方法があります。

1. アノテーションベース(スクリプトに推奨)

AbstractScript(NPC AIスクリプトやクエストなど)を継承するクラスでは、アノテーションを使用してメソッドを登録できます。これが最もクリーンでよく使われる方法です。

@RegisterEvent(EventType.CREATURE_KILL)
@RegisterType(ListenerRegisterType.NPC)
@Id(20432) // エルピー
@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特定のゾーンにアタッチされます。

実行制御: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) の3つのブール値は以下の通りです:

  1. Abort:次のリスナーへの通知を停止します。
  2. Override:以前のすべての戻り値を上書きします。
  3. Value:コアに返される実際のブール結果(例:「このアクションは成功したか?」)。

高度な例:復活の追跡

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 を使用してください。