본문 바로가기
마인크래프트/플러그인 제작 강좌(자바)

[자바로 마크 Paper 플러그인 만들기]7. 총 만들기(1) - 커스텀 아이템 제작하는 법

by Zepelown 2024. 12. 23.

*본 강의는 1.20.2 Paper 기준으로 제작되었습니다*

 

이전 강의

(참고하면 너무 좋아요!)

[자바로 마크 Paper 플러그인 만들기]6. 주기적으로 공지사항 띄우기

 

[자바로 마크 Paper 플러그인 만들기]6. 주기적으로 공지사항 띄우기

*본 강의는 1.20.2 Paper 기준으로 제작되었습니다* 이전 강의[자바로 마크 Paper 플러그인 만들기]5. 유저 데이터 관리하기(4) - Config로 데이터 저장하기(Config 만들기, 직렬화 사용) (tistory.com) [자바

zepelown.tistory.com

 

강의에 사용한 전체 코드

Zepelown/PaperBlogPosting: Minecraft Paper API Blog Posting Repository (github.com)

 

GitHub - Zepelown/PaperBlogPosting: Minecraft Paper API Blog Posting Repository

Minecraft Paper API Blog Posting Repository. Contribute to Zepelown/PaperBlogPosting development by creating an account on GitHub.

github.com

 

 


미리 보는 결과물

 

 

 


개요

이 글은 이전 강의들을 꼭 진행할 필요는 없습니다.

(기본적인 세팅은 당연히 필요)

 

문득, 아이템 관련 강의는 써야 하는데 뭔가 재미있는 걸 만들고 싶어 생각난 게 바로 총입니다.

 

그래서 총을 만들어 보겠습니다.

 

모드였다면 퀄리티를 높게 만들 수 있는 모션, 사운드 효과 등등을 추가할 수 있겠지만

 

플러그인의 한계가 존재합니다.

 

하지만, 최대한 퀄리티 좋게 만들어봅시다.

 

여러 편으로 구성될 예정이며

 

이 글은 가장 기본적인 로직을 구성하는 1편입니다.

 

강의 진행에 있어 해당 객체, 변수, 함수가 없어 발생하는 에러는 일단 넘어가시면

마지막에 다 완성됩니다!

 

이전 강의를 안했다면?? 외부 라이브러리 Lombok 불러오는 법


기획

최대한 총과 유사하게 만들기 위해

 

이 글에선 다음 기능을 구현합니다.

 

1. 다양한 총 종류를 구성할 수 있어야 한다.

 

2. 그 총에 맞는 총알들이 존재해야 한다.

 

3. 총마다 탄창이 존재해서 다 사용 시 재장전을 진행 하고, 재장전 시 시간이 필요하다.

(당연히 총마다 탄창의 크기는 다르다)

 

먼저, 가장 기본적인 권총 "딱총"을 구현해 보겠습니다.

 

하지만, 1,2,3 조건을 다 만족하여 차후 다양한 무기를 추가할 수 있게 설계할게요.

 

 

 


패키지(폴더) 구조

그림 1. 전체 패키지 구조

 

domain.model 패키지의 객체들과 GunPlayerInteractController가 핵심입니다.

 

최대한 간략하게 구현한 것이니 쉽게 따라 하실 수 있으실 겁니다.


기능 구현 단계

하지만.. 최대한 쉽게 이해하도록 구현했으나

 

이 글을 본인의 맞게 소화를 하시려면 결국에 이론적 배경에 대한 이해가 필요합니다.

 

앞으로 각 기능마다 이론 배경을 앞에 붙이도록 하겠습니다.

util.GunBuilder

 

 

그림 2. util 패키지

 

그림 3. 아이템 예시

 

마인크래프트 아이템을 Paper Plugin에선 ItemStack라는 객체로 부릅니다.

 

마우스로 옮기는 한 묶음이라고 생각하면 됩니다.

 

따라서 ItemStack은 단순히 1개의 아이템뿐만 아니라 위 사진처럼 64개가 하나의 ItemStack 객체가 될 수 있습니다.

 

그리고 이 ItemStack 안에 MetaData라는 친구가 있습니다.

 

MetaData는 아이템의 표시 이름, 인첸트, 포션 효과 등등의 다양한 설정값을 가지고 있습니다.

 

그중에서 PersistentDataContainer 라는 것이 있습니다.

 

이는 Key와 Value 형식의 커스텀 메타 데이터를 담을 수 있는 컨테이너로 

 

개발자가 원하는 값을 ItemStack에 주입할 수 있습니다.

 

이것을 통해 마인크래프트 기본 아이템과 총 아이템을 구분 지을 수 있게 됩니다.

 

커스텀 데이터를 주입하여 ItemStack을 만들거나 총 아이템 식별을 위한 GunBuilder 객체입니다.

 

public class GunBuilder {
    public static ItemStack buildGun(Material type, int amount, String displayName, String customItemTag, String... lore) {
        ItemStack stack = new ItemStack(type, amount);
        ItemMeta meta = stack.getItemMeta();
        meta.getPersistentDataContainer().set(Gun.TAG_KEY, PersistentDataType.STRING, customItemTag);
        meta.setDisplayName(displayName);
        meta.setLore(Arrays.asList(lore));
        stack.setItemMeta(meta);
        return stack;
    }

    public static ItemStack buildBullet(Material type, int amount, String displayName, double damage, String... lore) {
        ItemStack stack = new ItemStack(type, amount);
        ItemMeta meta = stack.getItemMeta();
        meta.getPersistentDataContainer().set(Bullet.DAMAGE_TAG_KEY, PersistentDataType.DOUBLE, damage);
        meta.setDisplayName(displayName);
        meta.setLore(Arrays.asList(lore));
        stack.setItemMeta(meta);
        return stack;
    }

    public static boolean isGun(ItemStack item) {
        return item != null && item.hasItemMeta() && item.getItemMeta().getPersistentDataContainer().has(Gun.TAG_KEY, PersistentDataType.STRING);
    }
}

 

 

ItemStack에서 ItemMeta를 꺼내고 여러 값을 설정하고 stack을 반환하는 buildGun(), buildBullet()

 

PersistentDataContainer가 있는지 확인하는 isGun()이 있습니다.

 

PersistentDataContainer는 Key, Value 형태를 사용한다고 했었는데 Key로는 NamespacedKey 객체가 필요합니다.

 

buildGun 쪽 로직은 정말 설명할 게 많으니 먼저 buildBullet부터 보겠습니다.

meta.getPersistentDataContainer().set(Bullet.DAMAGE_TAG_KEY, PersistentDataType.DOUBLE, damage);

 

set() 메소드로 첫 번째 인수로는 NamespacedKey, 두 번째 인수로 데이터 타입, 그리고 타입에 맞는 값을 넣어주게 되면,

 

Bullet.DAMAGE_TAG_KEY에 맞는 값(Value)을 넣어줍니다.

 

이 값은 여기선 damage가 되는 겁니다.

 

이로써 총알에 damage가 주입됐습니다.

 

그러면 Bullet 클래스를 한번 봐볼게요.

 

domain.bullet.Bullet

그림 4. bullet 패키지

 

@Getter
@AllArgsConstructor
public class Bullet {
    public static final NamespacedKey DAMAGE_TAG_KEY = new NamespacedKey(PaperEdu.getServerInstance(), "BULLET_DAMAGE");
    public static final Material DEFAULT_MATERIAL = Material.CLOCK;
    private BulletType type;
    private ItemStack itemStack;
}

 

총알에 대한 정보를 가지고 있는 객체입니다.

 

damage 정보를 가지고 있는 NamespacedKey인 DAMAGE_TAG_KEY 상수가 있습니다.

 

바로 이 상수가 PersistentDataContainer를 사용할 수 있게 해주는 Key입니다.

 

추가로 이 객체를 보자면 기본 Material은 시계로 설정했고, Bullet의 아이템을 표현하기 위한 ItemStack 

 

마지막으로 총알의 종류를 가진 enum class인 BulletType이 있습니다.

 

domain.bullet.BulletType

public enum BulletType {
    PISTOL(3);

    private int speed;

    BulletType(int speed) {
        this.speed = speed;
    }

    public int getSpeed() {
        return speed;
    }
}

 

각 총알의 종류마다 속도를 가지고 있으며

 

이는 탄도학과 연관이 있습니다.

 

빠를수록 직선으로 나갑니다.

 

이렇게 총알 로직이 끝났습니다.

 

가장 중요한 총을 만들어봅시다.

 

domain.gun.

그림 5. gun 패키지

 

Gun은 인터페이스로 앞으로 다양한 총 객체를 구현하는데 바탕이 됩니다.

 

NormalPistol은 Gun 인터페이스를 구현하고 있습니다.

 

import org.blog.paperedu.PaperEdu;
import org.blog.paperedu.weapon.gun.common.GunSoundConstant;
import org.blog.paperedu.weapon.gun.domain.model.bullet.Bullet;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.entity.Snowball;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.Vector;

public interface Gun {
    NamespacedKey CURRENT_AMMO_TAG_KEY = new NamespacedKey(PaperEdu.getServerInstance(), "CURRENT_AMMO");
    NamespacedKey TAG_KEY = new NamespacedKey(PaperEdu.getServerInstance(), "GUN");
    Material DEFAULT_MATERIAL = Material.GOLDEN_HORSE_ARMOR;

    void fire(Player player, ItemStack gun);

    void giveGunToPlayer(Player player);

    void giveBulletToPlayer(Player player);

    void reload(Player player, ItemStack itemStack);

    void calculateReload(Player player, ItemStack gunItemStack, int bulletCount);

    default int countBullet(Player player, ItemStack itemStack) {
        int result = 0;
        for (ItemStack item : player.getInventory().getContents()) {
            if (item == null) {
                continue;
            }
            if (item.isSimilar(itemStack)) {
                result += item.getAmount();
            }
        }
        return result;
    }

    default void setCurrentAmmo(ItemStack gunItemStack, int currentAmmo) {
        ItemMeta itemMeta = gunItemStack.getItemMeta();
        itemMeta.getPersistentDataContainer().set(CURRENT_AMMO_TAG_KEY, PersistentDataType.INTEGER, currentAmmo);
        gunItemStack.setItemMeta(itemMeta);
    }

    default void initReloadRunnable(Player player, Gun gun, ItemStack gunItemStack, int reloadTime, int maxAmmo, int bulletCount) {
        if (bulletCount < maxAmmo) {
            PaperEdu.getGunController().reload(player, gun, gunItemStack, bulletCount, reloadTime);
            return;
        }
        PaperEdu.getGunController().reload(player, gun, gunItemStack, maxAmmo, reloadTime);
    }

    default void throwBulletProjectile(Player player, int bulletSpeed, double damage) {
        Snowball projectile = player.launchProjectile(Snowball.class);
        projectile.getPersistentDataContainer().set(Bullet.DAMAGE_TAG_KEY, PersistentDataType.DOUBLE, damage);
        Vector multiplied = player.getEyeLocation().getDirection().multiply(bulletSpeed);
        projectile.setVelocity(multiplied);
        player.playSound(player.getLocation(), GunSoundConstant.FIRE_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
    }
}

 

길지만 하나하나 설명하겠습니다.

    NamespacedKey CURRENT_AMMO_TAG_KEY = new NamespacedKey(PaperEdu.getServerInstance(), "CURRENT_AMMO");
    NamespacedKey TAG_KEY = new NamespacedKey(PaperEdu.getServerInstance(), "GUN");
    Material DEFAULT_MATERIAL = Material.GOLDEN_HORSE_ARMOR;

    void fire(Player player, ItemStack gun);

    void giveGunToPlayer(Player player);

    void giveBulletToPlayer(Player player);

    void reload(Player player, ItemStack itemStack);

    void calculateReload(Player player, ItemStack gunItemStack, int bulletCount);

 

총은 탄창을 끼우고 장전을 하면 해당 총알은 아이템에 귀속이 되어야 합니다.

 

따라서 사용자가 다른 아이템을 바꾸더라도 그 총엔 장전된 총알이 계속 남아 있어야 합니다.

 

CURRENT_AMMO_TAG_KEY 는 이를 구현하기 위해 NamespacedKey입니다.

 

TAG_KEY는 일반 마크 아이템과 총 아이템을 구분하기 위한 NamespacedKey입니다.

 

이후 나열된 메소드들은 Gun 인터페이스를 구현할 때, 반드시 요구하도록 구현된 메소드들입니다.

 

이름대로, 각각 총알 발사, 총과 총알을 플레이어에게 전달, 장전, 장전할 총알을 계산하는 메소드를 뜻합니다.

 

    default int countBullet(Player player, ItemStack itemStack) {
        int result = 0;
        for (ItemStack item : player.getInventory().getContents()) {
            if (item == null) {
                continue;
            }
            if (item.isSimilar(itemStack)) {
                result += item.getAmount();
            }
        }
        return result;
    }

    default void setCurrentAmmo(ItemStack gunItemStack, int currentAmmo) {
        ItemMeta itemMeta = gunItemStack.getItemMeta();
        itemMeta.getPersistentDataContainer().set(CURRENT_AMMO_TAG_KEY, PersistentDataType.INTEGER, currentAmmo);
        gunItemStack.setItemMeta(itemMeta);
    }

    default void initReloadRunnable(Player player, Gun gun, ItemStack gunItemStack, int reloadTime, int maxAmmo, int bulletCount) {
        if (bulletCount < maxAmmo) {
            PaperEdu.getGunController().reload(player, gun, gunItemStack, bulletCount, reloadTime);
            return;
        }
        PaperEdu.getGunController().reload(player, gun, gunItemStack, maxAmmo, reloadTime);
    }

    default void throwBulletProjectile(Player player, int bulletSpeed, double damage) {
        Snowball projectile = player.launchProjectile(Snowball.class);
        projectile.getPersistentDataContainer().set(Bullet.DAMAGE_TAG_KEY, PersistentDataType.DOUBLE, damage);
        Vector multiplied = player.getEyeLocation().getDirection().multiply(bulletSpeed);
        projectile.setVelocity(multiplied);
        player.playSound(player.getLocation(), GunSoundConstant.FIRE_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
    }

 

default 메소드입니다. 구현하는 객체에서 사용할 수 있습니다.

 

countBullet()은 플레이어가 가지고 있는 총알이 몇 개 있는지 세는 메소드입니다.

 

이 메소드는 initReloadRunnable()과 이어지는 역할로 

 

만약 가지고 있는 총알이 한 총의 탄창의 최대 탄약 수보다 적은 경우를 파악하기 위함입니다.

 

만약 적다면 남은 총알을 다 리로드 하는 방식으로 진행합니다.

 

setCurrentAmmo() 는 PersistentDataContainer에 저장되어 있던 총의 탄약 수를 설정하는 메소드입니다.

 

throwBulletProjectile() 은 총알을 역할하는 눈덩이를 발사하는 메소드로

 

눈덩이는 Entity로 똑같이 PersistentDataContainer를 가지고 있습니다.

 

여기에 damage를 부여하여 차후에 플레이어와 충돌하는 이벤트 발생 시에 이 눈덩이의 데이터를 꺼내서

 

대미지를 입히는 방식으로 구현하였습니다.

 

 여기서 GunSoundConstant 객체를 잠깐 소개하겠습니다.

common.GunSoundConstant

그림 6. common 패키지

import org.bukkit.Sound;

public class GunSoundConstant {
    public static final float SOUND_VOLUME = 3.0F;
    public static final float SOUND_PITCH = 0.533F;

    public static final Sound RELOADING_SOUND= Sound.ITEM_AXE_SCRAPE;
    public static final Sound NOT_EXIST_AMMO_SOUND = Sound.BLOCK_CHAIN_STEP;
    public static final Sound FIRE_SOUND = Sound.ENTITY_FIREWORK_ROCKET_BLAST;
}

 

상수 이름대로 필요한 사운드 타입과 볼륨, 피치를 가지고 있습니다.

 

특별한 건 없습니다.

 

참고/ 만약 다른 사운드를 쓰고 싶다면?

더보기

Category:Sound effects – Minecraft Wiki

 

Sound effects

 

minecraft.fandom.com

 

여기서 다른 사운드를 확인할 수 있습니다.

 

다시 총 구현으로 돌아가겠습니다.

domain.model.NormalPistol

import org.blog.paperedu.weapon.gun.common.GunSoundConstant;
import org.blog.paperedu.weapon.gun.domain.model.bullet.Bullet;
import org.blog.paperedu.weapon.gun.domain.model.bullet.BulletType;
import org.blog.paperedu.weapon.gun.util.GunBuilder;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;

public class NormalPistol implements Gun {
    public static final String GUN_TAG = "NORMAL_PISTOL";
    public static final int MAX_AMMO = 7;
    public static final int RELOAD_TIME = 2;
    public static final double DAMAGE = 10.0;
    private final Bullet normalPistolBullet;
    private int currentAmmo;
    public NormalPistol(int currentAmmo) {
        this.currentAmmo = currentAmmo;

        ItemStack bulletItem = GunBuilder.buildBullet(
                Bullet.DEFAULT_MATERIAL,
                1,
                "딱총 총알",
                DAMAGE,
                "딱총 총알이다"
        );

        normalPistolBullet = new Bullet(BulletType.PISTOL, bulletItem);
    }

    @Override
    public void fire(Player player, ItemStack gunItemStack) {
        if (currentAmmo <= 0) {
            player.playSound(player.getLocation(), GunSoundConstant.NOT_EXIST_AMMO_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
            reload(player, gunItemStack);
            return;
        }

        throwBulletProjectile(player, normalPistolBullet.getType().getSpeed(), DAMAGE);
        setCurrentAmmo(gunItemStack, --currentAmmo);
        player.sendMessage("현재 남은 총알 :" + currentAmmo);
    }

    @Override
    public void giveGunToPlayer(Player player) {
        ItemStack itemStack = GunBuilder.buildGun(
                Gun.DEFAULT_MATERIAL,
                1,
                "딱총",
                GUN_TAG,
                "평범한 딱총이다"
        );
        itemStack.getItemMeta().getPersistentDataContainer().set(CURRENT_AMMO_TAG_KEY, PersistentDataType.INTEGER, MAX_AMMO);
        player.getInventory().addItem(itemStack);
    }

    @Override
    public void giveBulletToPlayer(Player player) {
        for (int i = 0; i < 32; i++) {
            player.getInventory().addItem(normalPistolBullet.getItemStack());
        }
    }

    @Override
    public void reload(Player player, ItemStack gunItemStack) {
        int bulletCount = countBullet(player, normalPistolBullet.getItemStack());
        if (bulletCount <= 0) {
            player.sendMessage("재장전할 총알이 부족합니다.");
            player.playSound(player.getLocation(), GunSoundConstant.NOT_EXIST_AMMO_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
            return;
        }
        initReloadRunnable(player, this, gunItemStack, RELOAD_TIME, MAX_AMMO, bulletCount);
    }

    @Override
    public void calculateReload(Player player, ItemStack gunItemStack, int bulletCount) {
        ItemStack bulletStack = normalPistolBullet.getItemStack().clone();
        bulletStack.setAmount(bulletCount);
        player.getInventory().removeItem(bulletStack);
        setCurrentAmmo(gunItemStack, Math.min(bulletCount, MAX_AMMO));
    }


}

 

아까 설명한 Gun 인터페이스를 구현하고 있습니다.

 

    public static final String GUN_TAG = "NORMAL_PISTOL";
    public static final int MAX_AMMO = 7;
    public static final int RELOAD_TIME = 2;
    public static final double DAMAGE = 10.0;
    private final Bullet normalPistolBullet;
    private int currentAmmo;
    public NormalPistol(int currentAmmo) {
        this.currentAmmo = currentAmmo;

        ItemStack bulletItem = GunBuilder.buildBullet(
                Bullet.DEFAULT_MATERIAL,
                1,
                "딱총 총알",
                DAMAGE,
                "딱총 총알이다"
        );

        normalPistolBullet = new Bullet(BulletType.PISTOL, bulletItem);
    }

 

NamespacedKey에 필요한 Gun_TAG,

 

최대 탄약 수를 정의하는 MAX_AMMO,

 

재장전 시간(초)을 정의하는 RELOAD_TIME,

 

대미지를 정의하는 DAMAGE,

 

총알 객체를 뜻하는 normalPistolBullet,

 

현재 총알을 뜻하는 currentAmmo,

 

그리고 생성자로 currentAmmo, normalPistolBullet을 초기화합니다.

    @Override
    public void fire(Player player, ItemStack gunItemStack) {
        if (currentAmmo <= 0) {
            player.playSound(player.getLocation(), GunSoundConstant.NOT_EXIST_AMMO_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
            reload(player, gunItemStack);
            return;
        }

        throwBulletProjectile(player, normalPistolBullet.getType().getSpeed(), DAMAGE);
        setCurrentAmmo(gunItemStack, --currentAmmo);
        player.sendMessage("현재 남은 총알 :" + currentAmmo);
    }

 

인터페이스 메소드를 구현하고 있습니다.

 

그리고 인터페이스의 default 메소드를 사용하여 총알 발사를 구현합니다.

 

여기서 현재 총알이 0 이하면 reload를 하고, 총알이 있으면 눈덩이 엔티티를 발사하고 탄약 수를 줄이고

 

사용자에게 현재 남은 총알을 보여줍니다.

 

    @Override
    public void giveGunToPlayer(Player player) {
        ItemStack itemStack = GunBuilder.buildGun(
                Gun.DEFAULT_MATERIAL,
                1,
                "딱총",
                GUN_TAG,
                "평범한 딱총이다"
        );
        itemStack.getItemMeta().getPersistentDataContainer().set(CURRENT_AMMO_TAG_KEY, PersistentDataType.INTEGER, MAX_AMMO);
        player.getInventory().addItem(itemStack);
    }

    @Override
    public void giveBulletToPlayer(Player player) {
        for (int i = 0; i < 32; i++) {
            player.getInventory().addItem(normalPistolBullet.getItemStack());
        }
    }

 

두 메소드를 총알과 총 ItemStack을 유저에게 전달하는 메소드로

 

특별한 건 없습니다.

 

@Override
    public void reload(Player player, ItemStack gunItemStack) {
        int bulletCount = countBullet(player, normalPistolBullet.getItemStack());
        if (bulletCount <= 0) {
            player.sendMessage("재장전할 총알이 부족합니다.");
            player.playSound(player.getLocation(), GunSoundConstant.NOT_EXIST_AMMO_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
            return;
        }
        initReloadRunnable(player, this, gunItemStack, RELOAD_TIME, MAX_AMMO, bulletCount);
    }

    @Override
    public void calculateReload(Player player, ItemStack gunItemStack, int bulletCount) {
        ItemStack bulletStack = normalPistolBullet.getItemStack().clone();
        bulletStack.setAmount(bulletCount);
        player.getInventory().removeItem(bulletStack);
        setCurrentAmmo(gunItemStack, Math.min(bulletCount, MAX_AMMO));
    }

 

reload() 는 장전해야 할 총알 개수를 계산하고 reload를 진행합니다.

 

다만, 여기서 저희는 재장전 시간이 있으므로 일정 시간 후에 재장전을 완료해야 합니다.

 

calculateReload()는 일정 시간 후에 재장전을 진행하는 메소드로

 

사용자가 가지고 있는 총알들을 없애고 총의 현재 탄약으로 바꿉니다.

 

두 메소드가 분리된 이유는 바로 밑에서 설명할 예정입니다.

domain.task.ReloadScheduler

그림 7. task 패키지

 

재장전 시간 후에 calculateReload()를 실행시켜 재장전을 완료해야 합니다.

 

즉, 일정 시간 후에 메소드를 실행시켜야 하는 것인데

 

이를 구현하기 위해 Scheduler가 필요합니다.

 

[자바로 마크 Paper 플러그인 만들기]6. 주기적으로 공지사항 띄우기

 

[자바로 마크 Paper 플러그인 만들기]6. 주기적으로 공지사항 띄우기

*본 강의는 1.20.2 Paper 기준으로 제작되었습니다* 이전 강의[자바로 마크 Paper 플러그인 만들기]5. 유저 데이터 관리하기(4) - Config로 데이터 저장하기(Config 만들기, 직렬화 사용) (tistory.com) [자바

zepelown.tistory.com

 

 

자세한 내용은 이전 강의에 설명해 놨고 여기서는 대략적으로 설명하겠습니다.

 

import org.blog.paperedu.weapon.gun.common.GunSoundConstant;
import org.blog.paperedu.weapon.gun.domain.model.gun.Gun;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;

import java.util.Map;
import java.util.UUID;
import java.util.concurrent.*;

public class ReloadScheduler {
    private final static ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
    private final Map<UUID, Boolean> reloadingPlayers = new ConcurrentHashMap<>();

    public void reload(Player player, Gun gun, ItemStack gunItemStack, int bulletCount, int reloadTime) {
        Executor delayedExecutor = CompletableFuture.delayedExecutor(reloadTime, TimeUnit.SECONDS, scheduledExecutorService);

        synchronized (player.getUniqueId()) {
            if (reloadingPlayers.containsKey(player.getUniqueId()) && reloadingPlayers.get(player.getUniqueId())) {
                player.sendMessage("이미 장전 중입니다.");
                return;
            }
            reloadingPlayers.put(player.getUniqueId(), true);
            player.sendMessage("장전 시작합니다.");


            CompletableFuture.supplyAsync(() -> {
                try {
                    gun.calculateReload(player, gunItemStack, bulletCount);
                    return "Reload Completed";
                } catch (Exception e) {
                    e.printStackTrace();
                    return "Reload Failed";
                }
            }, delayedExecutor).thenAccept(result -> {
                player.sendMessage("장전이 완료되었습니다.");
                player.playSound(player.getLocation(), GunSoundConstant.RELOADING_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
                synchronized (player.getUniqueId()) {
                    reloadingPlayers.remove(player.getUniqueId());
                }
            }).exceptionally(e -> {
                e.printStackTrace();
                synchronized (player.getUniqueId()) {
                    reloadingPlayers.remove(player.getUniqueId());
                }
                return null;
            });
        }
    }
}

 

이전 강의와 다른 방식으로 구현을 합니다.

 

왜 그렇게 되냐면 이건 플레이어마다 다르게 적용해야 하기 때문입니다.

 

만약, 그걸 고려하지 않는다면 한 명이 재장전하면 다른 사람들은 재장전하지 못하게 됩니다.

 

또한, 재장전을 하고 있다면 다시 재장전을 하면 안 됩니다.

 

그러므로 ConcurrentMap 과 synchronized 블록을 사용하여 사용자에 대한 구분 및 중복 확인을 진행합니다.

 

재장전을 비동기로 처리하여 서버의 성능을 확보하되 락을 걸어 중복 확인을 진행합니다.

 

CompletableFuture.supplyAsync(() -> {
    try {
        gun.calculateReload(player, gunItemStack, bulletCount);
        return "Reload Completed";
    } catch (Exception e) {
        e.printStackTrace();
        return "Reload Failed";
    }

 

재장전을 진행하는 메소드로 아까 NormalPistol 객체 안에 있던 calculateReload() 메소드입니다.

 

밖에서 사용해야 하기 때문에 reload()와 분리를 진행했던 겁니다.

delayedExecutor).thenAccept(result -> {
    player.sendMessage("장전이 완료되었습니다.");
    player.playSound(player.getLocation(), GunSoundConstant.RELOADING_SOUND, GunSoundConstant.SOUND_VOLUME, GunSoundConstant.SOUND_PITCH);
    synchronized (player.getUniqueId()) {
        reloadingPlayers.remove(player.getUniqueId());
    }
})

 

이 부분은 장전이 완료된 상황으로

 

현재 장전하고 있는 플레이어의 정보를 저장하고 있는 reloadingPlayers에서 빼주고 있습니다.

 

이렇게 총을 구성하고 있는 객체들을 확인했습니다.

 

제일 중요한 것 중 하나인 사용자가 총을 쏠 수 있도록 이벤트를 받아야겠죠.

 

controller.GunPlayerInteractController

그걸 하는 곳입니다.

 

사용자의 이벤트를 받습니다.

public class GunPlayerInteractController implements Listener {
    @EventHandler
    public void interactGun(PlayerInteractEvent event) {
        Player player = event.getPlayer();
        if ((event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.RIGHT_CLICK_AIR)
                || event.getItem() == null) {
            return;
        }

        if (GunBuilder.isGun(event.getPlayer().getInventory().getItemInMainHand())) {
            PersistentDataContainer persistentDataContainer = event.getItem().getItemMeta().getPersistentDataContainer();
            Gun gun;
            switch (persistentDataContainer.get(Gun.TAG_KEY, PersistentDataType.STRING)) {
                case NormalPistol.GUN_TAG:
                    gun = new NormalPistol(persistentDataContainer.getOrDefault(NormalPistol.CURRENT_AMMO_TAG_KEY, PersistentDataType.INTEGER, NormalPistol.MAX_AMMO));
                    gun.fire(player, event.getPlayer().getInventory().getItemInMainHand());
                    break;
                default:
                    gun = new NormalPistol(persistentDataContainer.getOrDefault(NormalPistol.CURRENT_AMMO_TAG_KEY, PersistentDataType.INTEGER, NormalPistol.MAX_AMMO));
                    gun.fire(player, event.getPlayer().getInventory().getItemInMainHand());
            }
        }
    }

    @EventHandler
    public void onProjectileDamaged(EntityDamageByEntityEvent e) {
        if (e.getDamager() instanceof Snowball && !e.getDamager().getPersistentDataContainer().isEmpty()) {
            e.setDamage(e.getDamager().getPersistentDataContainer().getOrDefault(Bullet.DAMAGE_TAG_KEY, PersistentDataType.DOUBLE, 0.5));
        }
    }
}

 

여기선 두 가지의 이벤트를 받습니다.

 

interactGun() 메소드는 사용자가 총 아이템을 우클릭하면 총을 쏘거나 재장전하는 이벤트 메소드입니다.

 

여기서 swtich 문으로 총의 종류를 구분하는데 이는 차후 추가할 다양한 총에 대한 확장성을 고려한 코드입니다.

 

onProjectileDamaged() 메소드는 눈덩이를 발사했는데 그 눈덩이가 몹이나 사람에게 부딪쳤을 때,

 

트리거되는 메소드로, 눈덩이에 저장해 놓았던 대미지 데이터를 사용해 대미지를 입히는 구조입니다.

 

 

이렇게 기본적인 총을 구성하는 메인 로직을 구현했습니다.

 

나머지는 자잘한 기본 구조를 나타내는 객체들로 간단하게 보시면 됩니다.

controller.GunController

public class GunController {

    private final GunPlayerInteractController gunPlayerInteractController;
    private final ReloadScheduler reloadScheduler;
    private final PaperEdu serverInstance;


    public GunController() {
        gunPlayerInteractController = new GunPlayerInteractController();
        reloadScheduler = new ReloadScheduler();
        serverInstance = PaperEdu.getServerInstance();

        registerEvents();
        registerCommands();
    }

    public void reload(Player player, Gun gun, ItemStack gunItemStack, int bulletCount, int reloadTime) {
        reloadScheduler.reload(player, gun, gunItemStack, bulletCount, reloadTime);
    }

    private void registerEvents() {
        serverInstance.getServer().getPluginManager().registerEvents(gunPlayerInteractController, serverInstance);
    }

    private void registerCommands() {
        serverInstance.getServer().getPluginCommand("getGun").setExecutor(new GetGunCommand());
        serverInstance.getServer().getPluginCommand("getBullet").setExecutor(new GetBulletCommand());
    }
}

 

이전에 만들었던 객체들의 인스턴스를 가지고 있습니다.

 

외부의 진입점이기도 하고 reload를 이어주는 역할도 합니다.

 

이제 이 총이랑 총알을 받을 수 있는 명령어를 만들겠습니다.

 

간단합니다.

controller.GetBulletCommand

public class GetBulletCommand implements CommandExecutor {
    @Override
    public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String s, @NotNull String[] args) {
        if (args.length == 1) {
        } else {
            if (!(sender instanceof Player)) {
                sender.sendMessage("플레이어만 이 명령어를 사용할 수 있습니다.");
                return false;
            }
            Player player = (Player) sender;
            NormalPistol normalPistol = new NormalPistol(NormalPistol.MAX_AMMO);
            normalPistol.giveBulletToPlayer(player);
        }

        return false;
    }
}

 

총알을 받는 커맨드입니다.

controller.GetGunCommand

public class GetGunCommand implements CommandExecutor {
    @Override
    public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String s, @NotNull String[] args) {

        if (args.length == 1) {
        } else {
            if (!(sender instanceof Player)) {
                sender.sendMessage("플레이어만 이 명령어를 사용할 수 있습니다.");
                return false;
            }
            Player player = (Player) sender;
            NormalPistol normalPistol = new NormalPistol(NormalPistol.MAX_AMMO);
            normalPistol.giveGunToPlayer(player);
        }

        return false;
    }
}

 

총을 얻는 명령어입니다.

 

특별한 건 없습니다.

resources.plugin.yml

name: PaperEdu
version: '${version}'
main: org.blog.paperedu.PaperEdu
api-version: '1.20'
commands:
  uinfo:
   description: display player data
  getGun:
    description: get custom gun
  getBullet:
    description: get custom gun bullet

 

getGun과 getBullet 커맨드를 추가해 줍니다.

(커맨드 추가 시 반드시 필요)

PaperEdu (메인클래스)

package org.blog.paperedu;

import org.blog.paperedu.server.management.controller.ServerManagementController;
import org.blog.paperedu.user.management.controller.UserManagementController;
import org.blog.paperedu.user.management.data.entity.UserData;
import org.blog.paperedu.weapon.gun.controller.GunController;
import org.bukkit.configuration.serialization.ConfigurationSerializable;
import org.bukkit.configuration.serialization.ConfigurationSerialization;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;

public final class PaperEdu extends JavaPlugin {
    private static PaperEdu serverInstance;
    private static UserManagementController userManagement;
    private static ServerManagementController serverManagement;
    private static GunController gunController;
    @Override
    public void onEnable() {
        getLogger().info("플러그인 시작 테스트");
        serverInstance = this;
        userManagement = new UserManagementController();
        serverManagement = new ServerManagementController();
        gunController = new GunController();
    }

    @Override
    public void onDisable() {
        userManagement.saveUserData();
        serverInstance = null;
        userManagement = null;

        getLogger().info("플러그인 종료 테스트");
    }

    public static PaperEdu getServerInstance() {
        return serverInstance;
    }

    public static UserManagementController getUserManagement(){
        return userManagement;
    }

    public static GunController getGunController(){return gunController;}

}

 

GunController를 생성해 줍니다.

 

이로써 총 기능들이 구현됩니다.

 


만들어진 결과물

그림 8. 만들어진 아이템들

 

 

댓글