주의) 플러그인 개발을 할 줄 아는 독자를 대상으로 한 가이드입니다.
기초적인 플러그인 개발을 가르쳐드리지 않습니다.

0. 이 포스트를 작성한 이유

개발자 중에서는 아직 배우는 단계라서 직접 PMMP의 업데이트 사항이나 메소드들을 파악하지 않고, 타 개발자가 작성한 코드와 가이드를 통해서 알게 된 기능만 사용하는 분들도 계십니다.

하지만 문제는 대체로 가이드를 작성하는 개발자 분들 또한 제대로 숙지가 안된 상태에서 작성하는 경우가 많아서 새로 생긴 메소드에 대한 가이드를 잘 적어주지 않는 다는 겁니다.

18-08-15에 추가된 메소드가 아직도 새로 생겼다고 구분되는 이유는 잘 모르겠지만

특히 Form 사용에 관한 가이드들이 대체로 패킷을 직접 생성하고, 이를 직접 보내주고, 응답 패킷까지 직접 처리하는 방법을 안내하는 문제가 있었습니다.

때문에, 이 포스트는 Form을 직접 패킷으로 보내는 방법의 문제점과 정상적인 사용 방법을 알려드리기 위해 작성되었습니다.

   


1. Form이란?

Form은 아래와 같은 버튼이나 슬라이드, 입력 창 등을 사용자에게 보내주고 사용자는 명령어를 외울 필요 없이 이 양식을 채워서 보내주기만 하면 되는 기능입니다.

플러그인을 만드는 개발자 분들 중에서는 명령어 입력보다 간단하게 기능을 사용할 수 있게 해주기 위해서 form을 사용하는 분들이 꽤 많습니다.

PMMP에서는 이미 2018년 8월 15일부터 이 기능을 공식적으로 지원합니다.

참고) github.com/pmmp/PocketMine-MP/commit/df8e10c


2. 잘못된 사용 방법 (패킷 직접 전송)

Form 패킷을 직접 보내고, 직접 처리하는 코드는 대체로 아래와 같습니다.

namespace example\form\wrong;

use pocketmine\event\Listener;
use pocketmine\event\player\PlayerJoinEvent;
use pocketmine\event\server\DataPacketReceiveEvent;
use pocketmine\network\mcpe\protocol\ModalFormRequestPacket;
use pocketmine\network\mcpe\protocol\ModalFormResponsePacket;
use pocketmine\plugin\PluginBase;

class WrongFormUsage extends PluginBase implements Listener{
    public function onEnable(){
        $this->getServer()->getPluginManager()->registerEvents($this, $this);
    }

    public function onPlayerJoin(PlayerJoinEvent $event) : void{
        $packet = new ModalFormRequestPacket();
        $packet->formId = 48630; //임의의 랜덤한 숫자를 고정적으로 넣음.
        $packet->formData = json_encode([
            "type" => "form",
            "title" => "예시 UI",
            "content" => "▶ 원하는 기능을 선택해주세요.",
            "buttons" => [
                ["text" => "1번기능 ▶ 날고싶습니다 ◀"],
                ["text" => "2번기능 ▶ 죽고싶습니다 ◀"]
            ]
        ]);
        $event->getPlayer()->sendDataPacket($packet);
    }

    public function onDataPacketReceive(DataPacketReceiveEvent $event) : void{
        $packet = $event->getPacket();
        if($packet instanceof ModalFormResponsePacket && $packet->formId == 48630){ //보내줬던 임의의 랜덤한 숫자와 일치하는지 체크
            $player = $event->getPlayer();
            $data = json_decode($packet->formData, true);
            if($data === null){
                return;
            }elseif($data === 0){
                $player->setFlying(true);
                $player->sendMessage("'날고싶습니다'를 선택했습니다.");
            }
            if($data === 1){
                $player->kill();
                $player->sendMessage("'죽고싶습니다'를 선택했습니다.");
            }
        }
    }
}

이 방식을 사용했을 때는 대표적으로 4개의 문제점이 있습니다.

1번째 문제점) Form ID가 중복될 수 있음

위 방식에선 Form ID를 직접 정해주어야 합니다.

대부분의 개발자들은 위처럼 다른 개발자들이 사용하지 않을 것 같은 임의의  숫자를 정해서 사용하거나
아예 rand() 함수를 사용해서 최대한 충돌이 없게 끔 정합니다.

일단 위와 같은 방법은 사용하면  Form ID가 중복되는 문제에 대한 완벽한 대비가 불가능하기 때문에 다른 플러그인과 같은 아이디가 사용되면 충돌이 생기는 문제가 있습니다.

 

2번째 문제점) Form이 늘어나면 코드 작성과 읽기가 힘들어짐

Form에 대한 응답을 DataPacketReceiveEvent를 핸들링하는 메소드에서 직접 처리하기 때문에 form이 늘어날수록 코드를 작성하고 읽는 것이 매우 힘들어집니다.

사용하는 form의 종류가 5개 이상만 돼도 코드가 굉장히 이상한 순서로 배치되기 때문에 가독성도 매우 떨어지게 됩니다.

보내는 코드와 응답을 처리하는 코드가 서로 멀~리 떨어지는 것부터 이 응답을 구분하기 위한 조건문이 추가되는 것까지 전부 가독성을 해치는 요소들이죠.

 

3번째 문제점) 각 플레이어에 대한 응답을 다르게 하기 힘듦

위 방식에선 사용자마다 다른 값을 가진 form을 보내준 경우에도 이를 처리하기 위해 보내줬던 데이터를 별도로 저장해야 합니다.

이해하기 쉽게 예시를 들어서 설명해드리자면

1. 서버 -> 플레이어의 이름 목록을 Form으로 보냅니다.

2. 클라이언트 -> 선택한 옵션의 인덱스 값으로 응답합니다.
   선택한 옵션의 내용이 아닌 인덱스 값입니다.

3. 서버 - Form을 보내줄때와 완전히 같은 배열에서 응답 받은 인덱스로 플레이어의 이름값을 가져옵니다.

만약 이때 Form을 보내줄 때 담았던 데이터를 저장하지 않고, 보낼 때와 같은 방법으로 새로 플레이어 배열을 가져와 사용하는 방법을 사용하게 되면

Form을 보낸 뒤 플레이어가 나가거나 들어온 경우에 잘못된 플레이어가 선택되기 때문에 엄청난 오류가 발생합니다.

(플레이어가 나가서 없는 인덱스가 되는 경우엔 undefined index 오류까지 터지죠)

 

4번째 문제점) Form에 대한 응답이 여러 번 옴

이는 마인크래프트 클라이언트 측의 문제지만 간혹 같은 Form에 대한 응답이 온 뒤에도 데이터가 빈(null) 응답이 다시 오는 경우가 있습니다.

위 방식을 사용할 땐 이에 대한 처리를 직접 해주어야 합니다.

빈 응답을 모두 무시하면 실제로 닫아서 빈 응답을 오는 경우에 대한 처리가 안되고,
모든 빈 응답을 처리하면 중복 처리가 되는 문제가 있죠.

따라서 여러분이 "해당 플레이어가 해당 Form ID로 응답을 한 적이 있는가?"를 직접 검증 해야 합니다.

만약 Form ID를 재활용 한다면... 검증 구조는 더더욱 말도 안되게 꼬이게 되겠죠.

(아니면 이 오류를 그대로 방치하고 ㅡ사용자문제ㅡ 라고 주장하거나요)

   


3. 정상적인 사용 방법 (sendForm() 사용)

일단 정상적인 사용 방법은 PlayersendForm 메소드와  Form 인터페이스를 사용해 보낸 후의 핸들링 부분까지 한번에 작성하는 것입니다.

namespace example\form\standard;

use pocketmine\event\Listener;
use pocketmine\event\player\PlayerJoinEvent;
use pocketmine\form\Form;
use pocketmine\Player;
use pocketmine\plugin\PluginBase;

class StandardFormUsage extends PluginBase implements Listener{
    public function onEnable(){
        $this->getServer()->getPluginManager()->registerEvents($this, $this);
    }

    public function onPlayerJoin(PlayerJoinEvent $event) : void{
        $event->getPlayer()->sendForm(new class() implements Form{
            public function jsonSerialize(){
                return [
                    "type" => "form",
                    "title" => "예시 UI",
                    "content" => "▶ 원하는 기능을 선택해주세요.",
                    "buttons" => [
                        ["text" => "1번기능 ▶ 날고싶습니다 ◀"],
                        ["text" => "2번기능 ▶ 죽고싶습니다 ◀"]
                    ]
                ];
            }

            public function handleResponse(Player $player, $data) : void{
                if($data === null){
                    return;
                }elseif($data === 0){
                    $player->setFlying(true);
                    $player->sendMessage("'날고싶습니다'를 선택했습니다.");
                }
                if($data === 1){
                    $player->kill();
                    $player->sendMessage("'죽고싶습니다'를 선택했습니다.");
                }
            }
        });
    }
}

1번 문제점 해결 (Form ID가 중복될 수 있음)

sendForm 메소드를 사용하게 되면 애초에 Form ID를 정하질 않습니다.

이는 PMMP에서 알아서 중복되지 않도록 처리해주기 때문에 이제 그냥 사용만 하면 됩니다.

   

2번 문제점 해결 (Form이 늘어나면 코드 작성과 읽기가 힘들어짐)

위 방식을 사용하면 응답을 처리하는 코드가 보내는 코드 바로 밑에 작성되기 때문에 코드 작성과 가독성이 더 좋습니다.

위 예제에선 익명 클래스를 사용해 인라인 코드로 넣었지만 실제 플러그인을 개발할 땐 아예 다른 클래스로 분리해 작성할 수 있기 때문에 좀 더 구조적인 프로그래밍이 가능합니다.

   

3번 문제점 해결 (각 플레이어에 대한 응답을 다르게 하기 힘듦)

위 방식을 사용하면  응답 처리에 필요한 데이터를 Form 클래스에 저장할 수 있습니다.

따로 멤버 변수나 전역 변수로 이를 저장할 필요가 없고,  2번과 마찬가지로 한 클래스의 코드로 작성되기 때문에 가독성도 매우 좋습니다.

   

4번 문제점 해결 (Form에 대한 응답이 여러 번 옴)

위 방식을 사용하면 한번 처리한 Form에 대해서 다시 처리하지 않기 때문에 응답이 여러 번 오더라도 첫 응답을 제외하고 모두 자동으로 무시됩니다.

   


+. 라이브러리 추천 (jojoe77777/FormAPI)

일부 개발자 분들은 이미 작성된 API를 사용하는 것에 대한 원인 모를 기피증을 갖고 있습니다.

직접 만들지 않으면 실력이 부족한거라고 생각하는 것 같은데, 그럼 복붙은 왜 하...?

하지만 이미 만들어진 API를 기피 하는 것은 개발자로서 그다지 좋은 습관이 아닙니다.

제가 추천드리는 API는  jojoe77777/FormAPI입니다.

Projects in jojoe77777/FormAPI
Projects in jojoe77777/FormAPI built by Poggit

일단 jojoe77777/FormAPI는 Poggit을 통해 제공되는 프로젝트이고 굉장히 많은 개발자들이 사용하고 있습니다.

실제로 가장 많이 사용되는 라이브러리입니다.

jojoe77777/FormAPI는 위처럼 FormAPI라는 플러그인 프로젝트와 libFormAPI라는 라이브러리 프로젝트 두 가지 형태로 제공됩니다.

초보 개발자 분들은 플러그인 형식을 사용하는 게 편하고,
개발을 할 줄 아는 분들은 라이브러리 형식을 사용하는 것을 추천 드립니다.

  • 플러그인 형식 : FormAPI의 클래스가 하나의 플러그인으로 제공되기 때문에 플러그인 사용자는 FormAPI를 추가로 받아서 사용해야 하는 방식입니다.
  • 라이브러리 형식 : Virion이라는 방식을 사용해 poggit에서 빌드 할 때 자동으로 해당 라이브러리를 플러그인 내부로 넣어줍니다. 덕분에 의존성 없이 플러그인 파일 하나로 작동이 가능합니다.

FormAPI의 사용 방법은 직접 https://github.com/jojoe77777/FormAPI 에서 소스를 확인하면서 숙지하시는 걸 추천드립니다.