[Minecraft Plugin]簡単なPlayer vs Enemyのミニゲームを作成してみよう

![2021-02-27_20.23.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/854414/cca3369f-2c9e-c4e0-9edb-db17e417f897.png)

#開発環境
- IDE: Intellij IDEA Ultimate
- java version: 1.8.0_271
- Minecraft: 1.16.4
- Spigot Plugin

#どのようなPluginなのか
プレイヤー同士協力して敵Mobを退けながらゴールを目指すシンプルなゲームを実装します
Youtube: https://youtu.be/lEvXYIoAG48
github: https://github.com/FratikaK/L4D_gamepl

#Enemyの湧かせ方について
これはとても悩みました。Locationで指定した位置に湧かせるのが自然でいいかもしれませんが、
Mapの広さ故、かなり莫大な量のLocationを書かなければいけません。

そこでSpigotが提供しているListenerとMinecraftとスポナーのシステムを利用してみました

```java
    
    //SpawnerSpawnEventを使用する
    @EventHandler
    public void zombieSpawn(SpawnerSpawnEvent event) {
        //スポナーからスポーンさせる処理はまずキャンセルする
        event.setCancelled(true);

        if (L4D_gamepl.isGame()) {
            //プレイヤー数 * 任意の数字分湧かせる
            int mobNum = L4D_gamepl.getSurvivorList().size() * 8;
            
            //スポナーの位置を特定してそこにまとめて湧かす
            Location spawnerLocation = event.getSpawner().getLocation().clone();
            spawnerLocation.add(0.5, 1, 0.5);

            //スポナーイベントが発生するたびにスポナーの設定を初期化する
            CreatureSpawner spawner = event.getSpawner();
            spawner.setSpawnCount(1);
            spawner.setSpawnRange(20);
            spawner.setRequiredPlayerRange(40);
            spawner.setMaxNearbyEntities(10);
            spawner.setDelay(0);
            spawner.setMinSpawnDelay(200);
            spawner.setMaxSpawnDelay(200);

            //スポナーの位置に湧かせる
            for (int i = 0; i < mobNum; i++) {
                event.getSpawner().getWorld().spawnEntity(spawnerLocation, event.getSpawner().getSpawnedType());
            }

            //ランダム関数で特殊mobをevent1回につき1匹スポーンさせる
            Random random = new Random();
            event.getSpawner().getWorld().spawnEntity(spawnerLocation, spawnSpecialMob(random.nextInt(8)));

        }
    }

```

これでスポナーを置いておけば勝手にEnemyがスポーンします
スポナーの設定もここで行っているので、わざわざコマンドで設定する必要がありません

#武器のシステム
このPluginのゲームに使用する武器はCrackShotのPluginを利用しています
CrackShot URL: https://dev.bukkit.org/projects/crackshot

武器を入手するには、Enemyを倒すことで得られるお金を必要とします
お金を得る処理はこのように書いています

```java

    @EventHandler
    public void playerKillMobs(EntityDeathEvent event) {

        //プレイヤーが死亡の場合はreturn
        if (event.getEntity().getType() == EntityType.PLAYER) {
            return;
        }

        Player player = event.getEntity().getKiller();

        //所持金を加算
        player.setStatistic(Statistic.ANIMALS_BRED, player.getStatistic(Statistic.ANIMALS_BRED) + 5);
   }
```

このコードから、Statistic(プレイヤーの統計)を所持金として置き換えています
データベース等を利用する気はなかったので、サーバー側で保存される統計情報を利用しました

次は武器を購入する処理です
村人をインタラクトすることで取引画面が表示されます
![2021-02-27_18.49.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/854414/4b732184-9c12-002a-26ff-26921e3ffe72.png)

```java

    private void showMerchantInventory(Player player) {

        String traderText = "Trade Items";

        if (!L4D_gamepl.isGame()) {
            traderText = "Trial Weapons";
        }
        //Inventoryを作成
        Inventory inventory = Bukkit.createInventory(null, 54, traderText);

        ItemStack firework = getMetaItem(Material.FIREWORK_STAR, "グレネード", "$20");
        ItemStack clay = getMetaItem(Material.CLAY_BALL, "コンカッション", "$70");
        ItemStack apple = getMetaItem(Material.APPLE, "回復のリンゴ", "$50");
        ItemStack beef = getMetaItem(Material.COOKED_BEEF, "ステーキ", "$120");
        ItemStack minecart = getMetaItem(Material.FURNACE_MINECART, "Landmine", "$30");
        //以下略...

        inventory.setItem(0, firework);
        inventory.setItem(1, clay);
        inventory.setItem(2, apple);
        inventory.setItem(3, beef);
        inventory.setItem(4, minecart);
        //以下略
        
        //引数のプレイヤーに作成したInventoryを表示させる
        player.openInventory(inventory);
    }

    //アイテム名と説明文をつけたItemStackを返す
    private ItemStack getMetaItem(Material material, String displayName, String lore) {
        ItemStack itemStack = new ItemStack(material);
        ItemMeta itemMeta = itemStack.getItemMeta();

        itemMeta.setDisplayName(ChatColor.AQUA + displayName);
        List<String> lores = new ArrayList<>();
        lores.add(ChatColor.GOLD + lore);
        itemMeta.setLore(lores);
        itemMeta.setCustomModelData(1);

        itemStack.setItemMeta(itemMeta);

        return itemStack;
    }
```

そして所持金が足りていれば購入できます！

```java
    private void dealings(Player player, Material material, int money, String itemName) {

        //所持金が足りなければreturn
        if (player.getStatistic(Statistic.ANIMALS_BRED) < money) {
            player.sendMessage(ChatColor.RED + "所持金が足りません！");
            return;
        }
        //渡す武器を作成する
        ItemStack itemStack = new ItemStack(material);
        ItemMeta itemMeta = itemStack.getItemMeta();
        itemMeta.setDisplayName(ChatColor.YELLOW + itemName);
        itemStack.setItemMeta(itemMeta);

        //所持金を引いて、武器を渡す
        player.setStatistic(Statistic.ANIMALS_BRED, player.getStatistic(Statistic.ANIMALS_BRED) - money);
        player.getInventory().addItem(itemStack);
        player.playSound(player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1, 0);

        player.sendMessage(ChatColor.AQUA + itemName + "を購入しました");
    }
```

#復活システム
プレイヤーは死亡した後、スペクテイターモードで観戦者モードになっています
しかし、ずっと観戦者でいるのはつまらないので生存プレイヤーがチェックポイントにたどり着けば、
復帰することができます

```java
    //引数のプレイヤーを復帰させる
    private void resurrectionPlayer(Player player) {

        //生存プレイヤーならreturn
        if (L4D_gamepl.getSurvivorList().contains(player.getUniqueId())) {
            return;
        }

        //死亡プレイヤーリストにあれば復帰処理
        if (L4D_gamepl.getDeathPlayerList().contains(player.getUniqueId())) {

            //リスト整理
            L4D_gamepl.getSurvivorList().add(player.getUniqueId());
            L4D_gamepl.getDeathPlayerList().remove(player.getUniqueId());

            player.setPlayerListName("[" + ChatColor.AQUA + "生存者" + ChatColor.WHITE + "]" + player.getDisplayName());

            //初期状態に戻す
            player.setGameMode(GameMode.SURVIVAL);
            player.setFoodLevel(6);
            //初期アイテムを渡す処理
            pl.giveGameItem(player.getInventory(), player);
        }
    }
```

#Perkシステム
Perkとはいわゆる職業、特殊能力のことを指しています
![2021-02-27_19.23.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/854414/d390706c-d598-8108-b211-7c3e6baec404.png)

Perkが設定されているかどうかはMetadataで処理しています

```java
    public void setPeekDeck() {

        //PEEK_KEYを持っているか
        if (!player.hasMetadata(PEEK_KEY)) {
            plugin.getLogger().info("[PeekDecks]プレイヤーにメタデータが付与されていません");
            return;
        }

        //メタデータはリスト型として返ってくるので、for文で取得する
        List<MetadataValue> peeks = player.getMetadata(PEEK_KEY);

        MetadataValue value = null;

        for (MetadataValue v : peeks) {
            if (v.getOwningPlugin().getName() == plugin.getName()) {
                value = v;
                break;
            }
        }

        //メタデータが見つからなかった場合はreturn
        if (value == null) {
            return;
        }

        //すでにあるポーション効果を削除
        removePotion();

        //メタデータの持っている値に応じてポーション効果を付与する
        switch (value.asString()) {
            case tank:
                player.addPotionEffect(new PotionEffect
                        (PotionEffectType.DAMAGE_RESISTANCE, 1000000, 1, true));
                break;

            case scout:
                player.addPotionEffect(new PotionEffect
                        (PotionEffectType.SPEED, 1000000, 0, true));
                break;

            case regene:
                player.addPotionEffect(new PotionEffect
                        (PotionEffectType.REGENERATION, 1000000, 0, true));
                break;

            case destroyer:
                //CSUtilityはCrackShotのAPIからです
                new CSUtility().giveWeapon(player, "GL", 1);
                break;
        }
    }
```
例として画像のグラインダーは敵を倒す度に体力を回復します

```java
        //体力が20であればreturn
        if (player.getHealth() == 20) {
            return;
        }

        //grinderのmetadataが付与されていれば1ポイント回復する
        PerkDecks perkDecks = new PerkDecks(player, pl);
        if (perkDecks.getMetadata(player, PerkDecks.getPerkKey(), pl).equals(PerkDecks.getGrinder())) {
            player.setHealth(player.getHealth() + 1);
        }
```
#発生したバグとその対処について
###プレイヤーが無敵になる、一撃で死亡することがある
発生する条件を発見できなかったので、これは苦労しました
EntityDamageByEntityEventは発生しているようです

対処として、直接プレイヤーの体力を操作する方式をとりました

```java
    @EventHandler
    public void noOverDamage(EntityDamageByEntityEvent event) {

        Entity entity = event.getEntity();

        if (event.getDamager().getType() == EntityType.PRIMED_TNT && entity.getType() == EntityType.PLAYER) {
            return;
        }

        if (entity.getType() != EntityType.PLAYER) {
            return;
        }
        Player player = (Player) entity;
        //イベントはキャンセルしておく
        event.setCancelled(true);
        //一撃死の原因かもしれないので、ダメージを受けた時にクールダウンを発生させておく
        player.setNoDamageTicks(20);
        
        //付与されているPerkがGrinderかScoutであれば受けるダメージが増える
        PerkDecks perkDecks = new PerkDecks(player, pl);
        if (perkDecks.getMetadata(player, PerkDecks.getPerkKey(), pl) == PerkDecks.getGrinder()
                || perkDecks.getMetadata(player, PerkDecks.getPerkKey(), pl) == PerkDecks.getScout()) {
            if (player.getHealth() < 6) {
                player.setHealth(0);
                return;
            }
            player.setHealth(player.getHealth() - 6);
        } else if (perkDecks.getMetadata(player, PerkDecks.getPerkKey(), pl) == PerkDecks.getTank()) {
            if (player.getHealth() < 2.5) {
                player.setHealth(0);
                return;
            }
            player.setHealth(player.getHealth() - 2.5);
        } else {
            if (player.getHealth() < 4) {
                player.setHealth(0);
                return;
            }
            player.setHealth(player.getHealth() - 4);
        }
    }
```
このようにsetHealthで体力を操作しています
これを応用すれば、特定のEnemyからのダメージを増やすこともできます

###PvPの設定をオフしているのに、グレネードなどの投げ物のダメージが入る
例えば、コンカッション。当たれば対象に移動速度低下がつく強力な武器ですが、
CrackShot側のバグなのか、プレイヤーにも付与されてしまいます

```java
    @EventHandler
    public void removeRaid(PlayerMoveEvent event) {
        if (event.getPlayer().hasPotionEffect(PotionEffectType.BAD_OMEN)) {
            event.getPlayer().removePotionEffect(PotionEffectType.BAD_OMEN);
        }

        if (event.getPlayer().hasPotionEffect(PotionEffectType.SLOW)) {
            event.getPlayer().removePotionEffect(PotionEffectType.SLOW);
        }
    }
```
PlayerMoveEventで強制的にポーション効果を消す処理を入れました
CrackShot側のバグなのか判明するまで待つしかないですかね...

###EnemyMobの増えすぎでサーバーに負荷がかかる
プレイヤー数に応じてMobを増やす関係上、
そのまま放置しているといつかクラッシュする可能性がありました

プレイヤーはもういないのにMobが湧いても意味ないです

```java

public class LagFixTask extends BukkitRunnable {

    @Override
    public void run() {

        long removed = Bukkit.getWorlds().stream()
                //ワールド内の生存しているエンティティ
                .flatMap(world -> world.getEntitiesByClasses(Projectile.class, Explosive.class, LivingEntity.class).stream())
                //20秒以上生存している、プレイヤーではないエンティティ
                .filter(livingEntity -> livingEntity.getTicksLived() > 20 * 20 && livingEntity.getType() != EntityType.VILLAGER && livingEntity.getType() != EntityType.PLAYER)
                //エンティティを削除する
                .peek(Entity::remove)
                //カウント
                .count();

        if (removed > 0) {
            Bukkit.getLogger().info("[LagFixTask] 不要な" + removed + "体のエンティティが削除されました");
        }
    }
}
```

BukkitRunableを採用して、一定時間ごとに放置されているMobを削除する処理をいれて対処しました

これはLeonGunWarさんのPluginを参考にさせていただきました
GitHub: https://github.com/AzisabaNetwork/LeonGunWar/blob/master/src/main/java/net/azisaba/lgw/core/tasks/CrackShotLagFixTask.java

#さいごに
簡単なミニゲームを作ってみたいという思いから作成してみました
今後PvPも実装してマルチサーバーとして公開してみたいと考えています

この記事がjava Plugin開発初心者の役に立てれば幸いです