[Paper 플러그인 실전 제작기 #02] 명령어 시스템 실전 (/heal 권한, 인자 파싱, 탭 완성)

2026. 2. 21. 12:32Java/마인크래프트

1편에서 플러그인 뼈대를 만들었다면,
2편에서는 실제로 많이 쓰는 명령어 시스템을 가장 쉬운 예제로 익혀보겠습니다.

오늘 목표는 딱 3개입니다.

  1. /heal 만들기
  2. 권한 분리하기 (자기 자신 / 다른 사람)
  3. 탭 자동완성 붙이기

기준 환경: Java 21, Maven, Paper API 1.21.11-R0.1-SNAPSHOT, api-version 1.21


1) plugin.yml에서 명령어/권한 등록

name: PaperPractice
version: 0.2.0
main: com.example.paperpractice.PaperPracticePlugin
api-version: "1.21"

commands:
  heal:
    description: Heal yourself or another player
    usage: /heal [player] [amount]

permissions:
  paperpractice.heal.self:
    description: Allows healing yourself
    default: true
  paperpractice.heal.others:
    description: Allows healing others
    default: op

쉽게 말하면:

  • paperpractice.heal.self
    • 내 자신 회복 권한
    • default: true = 기본적으로 모든 유저 허용
  • paperpractice.heal.others
    • 다른 사람 회복 권한
    • default: op = 기본적으로 OP만 허용

즉, 기본 상태에서 일반 유저는 /heal만 가능, OP는 /heal 다른사람도 가능입니다.


2) 메인 클래스에서 명령 연결

@Override
public void onEnable() {
    HealCommand healCommand = new HealCommand();
    PluginCommand command = getCommand("heal");
    if (command == null) {
        getLogger().severe("Command 'heal' is not defined in plugin.yml");
        getServer().getPluginManager().disablePlugin(this);
        return;
    }
    command.setExecutor(healCommand);
    command.setTabCompleter(healCommand);
}

왜 2줄이 필요할까요?

  • setExecutor(...) = 명령 실행(/heal) 담당 등록
  • setTabCompleter(...) = 탭 키 자동완성 담당 등록

같은 객체를 넣어도, 실행 기능과 자동완성 기능은 Paper 내부에서 따로 등록해야 합니다.


3) HealCommand 구현 (핵심만)

public final class HealCommand implements TabExecutor {
    private static final String PERM_SELF = "paperpractice.heal.self";
    private static final String PERM_OTHERS = "paperpractice.heal.others";

    @Override
    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        // /heal (자기 자신)
        if (args.length == 0) {
            if (!(sender instanceof Player player)) {
                sender.sendMessage("콘솔에서는 /heal <player> 로 사용하세요.");
                return true;
            }
            if (!player.hasPermission(PERM_SELF)) {
                player.sendMessage("권한이 없습니다. (" + PERM_SELF + ")");
                return true;
            }
            healFull(player);
            player.sendMessage("체력/허기 회복 완료");
            return true;
        }

        // /heal <player> ...
        Player target = Bukkit.getPlayerExact(args[0]);
        if (target == null) {
            sender.sendMessage("플레이어를 찾을 수 없습니다.");
            return true;
        }

        // 타인 회복 권한 체크
        if (!sender.hasPermission(PERM_OTHERS)) {
            sender.sendMessage("권한이 없습니다. (" + PERM_OTHERS + ")");
            return true;
        }

        // /heal <player>
        if (args.length == 1) {
            healFull(target);
            sender.sendMessage(target.getName() + " 회복 완료");
            return true;
        }

        // /heal <player> <amount>
        int amount;
        try {
            amount = Integer.parseInt(args[1]);
        } catch (NumberFormatException e) {
            sender.sendMessage("숫자를 입력하세요. 예: /heal Steve 8");
            return true;
        }

        if (amount <= 0) {
            sender.sendMessage("회복량은 1 이상이어야 합니다.");
            return true;
        }

        double maxHealth = target.getAttribute(Attribute.MAX_HEALTH).getValue();
        target.setHealth(Math.min(maxHealth, target.getHealth() + amount));
        sender.sendMessage(target.getName() + " 체력 " + amount + " 회복");
        return true;
    }

    @Override
    public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
        if (args.length == 1) {
            String prefix = args[0].toLowerCase(Locale.ROOT);
            return Bukkit.getOnlinePlayers().stream()
                    .map(Player::getName)
                    .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix))
                    .sorted()
                    .toList();
        }

        if (args.length == 2) {
            List<String> amounts = List.of("4", "8", "12", "20");
            String prefix = args[1];
            return amounts.stream().filter(a -> a.startsWith(prefix)).toList();
        }

        return List.of();
    }

    private void healFull(Player player) {
        double maxHealth = player.getAttribute(Attribute.MAX_HEALTH).getValue();
        player.setHealth(maxHealth);
        player.setFoodLevel(20);
        player.setSaturation(20f);
        player.setFireTicks(0);
    }
}

4) 이 코드에서 꼭 이해할 4줄

  • hasPermission(...)
    • 이 유저가 권한 있는지 확인
  • Integer.parseInt(...)
    • 숫자 인자 변환 (실패하면 예외 처리)
  • onTabComplete(...)
    • 탭 누를 때 추천 목록 반환
  • Math.min(maxHealth, 현재체력 + amount)
    • 최대 체력 초과 방지

5) 빠른 테스트

  1. 일반 유저로 /heal
  2. 일반 유저로 /heal 다른유저 (막혀야 정상)
  3. OP로 /heal 다른유저
  4. OP로 /heal 다른유저 8
  5. /heal 다른유저 abc (숫자 안내 문구 확인)
  6. 탭으로 플레이어 이름/수치 추천 확인

마무리

이번 편 핵심은 “명령어 실행”보다 운영 가능한 명령어를 만드는 방법입니다.

  • 권한 분리
  • 인자 검증
  • 탭 자동완성

이 3가지만 습관화해도, 플러그인 완성도가 확 올라갑니다.