0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PaperMC (Kotlin 1.21.1) における額縁ロック・O(1)メモリキャッシュとデータ永続化の実装設計

0
Posted at

はじめに

Minecraft のマルチプレイサーバーにおいて、拠点や展示エリアを装飾する「額縁(ItemFrame / GlowItemFrame)」は、他プレイヤーやモンスター、あるいは過失による破壊・アイテムの盗難に非常に晒されやすいオブジェクトです。

本記事では、PaperMC 1.21.1(Java 21 / Kotlin 1.9.22)環境向けに開発した額縁保護プラグイン Gakubuchi-Locker のアーキテクチャについて解説します。

大量の額縁が設置されるマルチサーバー環境においてもサーバーパフォーマンスを一切低下させない SQLite 永続化 × O(1) メモリキャッシュ構造、投擲物や爆発を多角的に防ぐイベントキャンセル設計、およびパーティクルを活用した UX(額縁ファインダー)実装 について詳述します。


1. システムアーキテクチャ概要

Gakubuchi-Locker は、軽量性と応答性を両立するために、ディスクへの永続化層(SQLite)と高速判定のためのインメモリ層(Set / Map Cache)を完全に分離した2層構造を採用しています。

[ イベント発生 (右クリック/打撃/投擲/爆発) ]
                │
                ▼
[ メモリキャッシュ判定 (Set<String> / O(1)) ]
      ├── 未ロック ──> 通常処理を続行
      └── ロック中 ──> [ オーナー / OP 権限チェック ]
                            ├── 権限あり ──> 操作許可
                            └── 権限なし ──> event.isCancelled = true

2. O(1) メモリキャッシュ × SQLite 永続化設計

Minecraft サーバーのイベントハンドラ(EntityDamageByEntityEventPlayerInteractEntityEvent など)は、1秒間に何百回も呼び出される可能性があります。判定のたびに SQL クエリ(SELECT)を発行すると、データベース I/O がボトルネックとなりサーバーの TPS(Ticks Per Second)が低下します。

この問題を解決するため、本プラグインではサーバー起動時に SQLite のデータテーブルから全ロック対象の entity_uuid をメモリ上の HashSet<UUID> へ展開する手法をとっています。

データベーススキーマ (gakubuchi.db)

CREATE TABLE IF NOT EXISTS locked_frames (
    entity_uuid TEXT      PRIMARY KEY,
    world       TEXT      NOT NULL,
    x           INTEGER   NOT NULL,
    y           INTEGER   NOT NULL,
    z           INTEGER   NOT NULL,
    owner_uuid  TEXT      NOT NULL,
    locked_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Kotlin によるキャッシュロードと O(1) 判定の実装例

object FrameLockManager {
    private val lockedFrameUuids = mutableSetOf<UUID>()
    private val frameOwnerMap = mutableMapOf<UUID, UUID>()

    fun loadAllFromDatabase(database: Database) {
        lockedFrameUuids.clear()
        frameOwnerMap.clear()
        
        database.queryAllLockedFrames { entityUuid, ownerUuid ->
            lockedFrameUuids.add(entityUuid)
            frameOwnerMap[entityUuid] = ownerUuid
        }
    }

    fun isLocked(entityUuid: UUID): Boolean {
        // HashSet による O(1) 判定
        return lockedFrameUuids.contains(entityUuid)
    }

    fun getOwner(entityUuid: UUID): UUID? {
        return frameOwnerMap[entityUuid]
    }
}

3. 多角的な保護を網羅するイベントキャンセル設計

額縁の破壊・操作経路は多岐にわたります。単なるプレイヤーのクリックだけでなく、以下のような様々なイベントを網羅的にハンドリングする必要があります。

  1. 左クリック打撃 / 右クリック操作: PlayerInteractEntityEvent / EntityDamageByEntityEvent
  2. 投擲物(矢・雪玉・エンダーパール等): EntityDamageByEntityEvent (DamageSource 判定)
  3. 爆発(TNT・クリーパー・ウィザー): HangingBreakEvent (RemoveCause.EXPLOSION)
  4. ピストンによる押し出し: HangingBreakEvent (RemoveCause.PHYSICS)

投擲物・打撃保護のハンドラ実装例

@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
fun onEntityDamage(event: EntityDamageByEntityEvent) {
    val entity = event.entity
    if (entity !is ItemFrame) return

    val frameUuid = entity.uniqueId
    if (!FrameLockManager.isLocked(frameUuid)) return

    // 攻撃者がプレイヤーの場合
    val damager = event.damager
    if (damager is Player) {
        val ownerUuid = FrameLockManager.getOwner(frameUuid)
        if (damager.uniqueId == ownerUuid || damager.isOp) {
            // オーナーまたは OP による破壊時: メモリとDBから解除
            FrameLockManager.unlockFrame(frameUuid)
            return
        }
    }

    // 投擲物(Projectile)や権限のないプレイヤーからの攻撃はすべてブロック
    event.isCancelled = true
}

4. UX の向上: 透明額縁ファインダー (Particle Finder)

額縁を透明化(/gtoumei)した場合、設置されたアイテムだけが浮いているように見え、どの額縁がロックされているか物理的に識別できなくなる課題が発生します。

これに対し、指定範囲内のロック済み透明額縁を検出して、実行したプレイヤーだけにパーティクル(Particle.DUST)を10秒間表示するファンクションを実装しました。

fun showTransparentFrameParticles(player: Player, radius: Double) {
    val playerLoc = player.location
    val world = player.world
    
    // 範囲内の額縁エンティティを取得
    val nearbyFrames = world.getNearbyEntities(playerLoc, radius, radius, radius)
        .filterIsInstance<ItemFrame>()
        .filter { it.isInvisible && FrameLockManager.isLocked(it.uniqueId) }

    val playerUuid = player.uniqueId
    val isOp = player.isOp

    // タイマー(BukkerRunnable)で10秒間パーティクルを生成
    object : BukkitRunnable() {
        var elapsedTicks = 0

        override fun run() {
            if (elapsedTicks >= 200) { // 10秒
                cancel()
                return
            }

            for (frame in nearbyFrames) {
                val ownerUuid = FrameLockManager.getOwner(frame.uniqueId)
                if (isOp || ownerUuid == playerUuid) {
                    // 赤色ダストパーティクルを実行プレイヤーにのみ送信
                    val loc = frame.location.add(0.5, 0.5, 0.5)
                    player.spawnParticle(
                        Particle.DUST,
                        loc,
                        5,
                        Particle.DustOptions(Color.RED, 1.0f)
                    )
                }
            }
            elapsedTicks += 10
        }
    }.runTaskTimer(plugin, 0L, 10L)
}

5. まとめ

  1. パフォーマンスの最適化: 最頻出判定となるロックチェックを SQLite の直接検索ではなくメモリ上の HashSet による (O(1)) 判定とすることで、サーバー負荷を極限まで削減しました。
  2. 網羅的な保護ロジック: プレイヤー操作のみならず、投擲物・爆発・ピストンなどあらゆる物理環境変化を捉えたイベントハンドリングを徹底しました。
  3. UX への配慮: 透明額縁の視認性問題に対して、パケット/パーティクル処理を用いた個別の可視化機能を提供することで利便性を高めました。

PaperMC プラグイン開発におけるデータ永続化とメモリキャッシュの設計パターンとして、ぜひ活用してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?