토이 프로젝트/Spring&Java 갖고놀기

Spring Boot Web Chatting : 스프링 부트로 실시간 화상 채팅 만들기(14) 실시간 CatchMind 게임 만들기

TerianP 2024. 5. 5.
728x90

1. 시작하면서

이번에는 비교적 빠르게? 글을 쓰게 되었습니다ㅎ 그거 아세요? catchmind 게임 만들겠다고 첫 포스팅을 작성한지 벌써 2달이 넘었습니다! 

멋지게 2024 년 목표로 노션에 캐치마인드를 개발해보자 라고 적어두었던 것은 12월 31일 이니...아마 기획을 포함한 실제 개발은 5개월 정도가 흘렀지 않을까 생각됩니다.

2024년 목표로 적어둔 캐치마인드

 

조금의 잡설을 포함해서 이야기를 더 적어보자면 솔직히 3월 정도에는 '이런거 한다고 돈을 받는것도 아니고, 어디 좋은곳으로 스카웃 되는 것도 아니고 내가 대체 뭐하는거지?' 싶어서 현타가 조금 심하게 오기도 했던 것 같습니다. 말로는 이거는 나의 즐거움을 위해서, 나의 재미를 위해서 취미생활로 개발하는거야 라고 하더라도 일도 힘들고, 이직은 더 힘들고 하다보니 이게 과연 의미가 있나? 차라리 지금이라도 코테에 매달리는게 답이 아닐까? 라고 생각했던 것 같습니다.

 

그렇게 2~3 주 정도는 정말 플젝 개발에 손을 놓았던 것 같습니다. 매번 일끝나고 집 와서 플젝을 두드리거나 어떻게 게임을 기획할까? 이런저런 것들에 대해서 고민하고는 했는데, 그때만큼은 정말 계속 게임만 했던 것 같네요. 그럼에도 꾸역꾸역 어떻게든 게임을 만들고, 처음으로 친구들을 제 웹 페이지에 초대(납치)해서 같이 게임을 했던 순간은....정말이지, 차마 말로는 다 표현 하지 못할만큼의 기쁨과 즐거움 이었습니다.

사실 정말 별것 아닌 단순히 서로의 목소리를 들으면서, 그림을 그리면서 그렇게 정답을 맞추는 그런 게임임에도, 심지어는 처음 테스트 했을 때는 온갖 버그가 있어서 라운드가 하나 지나고 방을 새로 만들고, 다시 한 라운드하고 방을 새로만들고 그랬는데도 너무나 행복하게 즐겁게 테스트를 마쳤던 것 같습니다. '서비스에 애정을 갖고 만들어라' 라는 말을 들을 때면 그게 뭔 소리야...하고 이야기하곤 했는데, 새삼 어떤 의미인지 정말 절실히 알겠더라구요

 

현재 개발된 버전은 catchmind v1.0 버전정도 되겠습니다. 일단 가장 기본적이고 단순하게만 게임을 만들었습니다. 브라우저 2개를 열어서 하나의 컴퓨터에서도 게임을 해보실 수 있을 겁니다. 코드의 경우 기존의 코드와 합치기에는 너무 많은 부분이 바뀌었던지라 완전히 브렌치를 교체했습니다. catchmind 를 포함한 chatforyou 서비스를 로컬에서 구동하기 위해서는 python api server 와 chatGPT API 를 사용할 수 있어야합니다.

https://github.com/SeJonJ/Spring-WebSocket-WebRTC-Chatting

 

GitHub - SeJonJ/Spring-WebSocket-WebRTC-Chatting: SpringBoot WebSocket & WebRTC Chatting

SpringBoot WebSocket & WebRTC Chatting. Contribute to SeJonJ/Spring-WebSocket-WebRTC-Chatting development by creating an account on GitHub.

github.com

 

https://github.com/SeJonJ/chatforyou_python_api

 

GitHub - SeJonJ/chatforyou_python_api: chatforyou python api server using by fastapi

chatforyou python api server using by fastapi. Contribute to SeJonJ/chatforyou_python_api development by creating an account on GitHub.

github.com


2. CatchMind 게임 기획

전체적인 게임 기획은 이전과 동일합니다만 세부적인 사항이 조금 수정 추가되었습니다.

검정색은 현재 개발 사양이며, 빨간색으로 된 부분은들은 추후 개발 예정입니다.

  1. 게임 개요
    • 목적: 참여자들이 주제에 맞는 그림을 그리고, 다른 참여자들이 그 그림을 보고 주제를 맞추는 게임.
    • 정답 입력 방식:
      • PC: 'Tell Your Answer' 버튼을 클릭하여 음성 인식으로 정답 확인.
      • Mobile: 'Type Your Answer' 버튼을 통해 텍스트로 정답 입력 후 제출.
    • 정답과 오답 처리:
      • 정답: 정답자의 닉네임을 TTS를 통해 읽어줌.
      • 오답: 오답에 대해 토스트 메시지를 띄워 오답임을 알림.
    • 정답 기회: M회 제한. 모든 참여자의 기회가 소진되거나 모두가 PASS 선택 시, 점수를 얻고 다음 라운드로 넘어감.
    • 라운드: 총 3회 반복, 추후 사용자가 라운드 수를 설정할 수 있도록 변경 예정.
  2. 역할 분담
    • GameLeader (게임 진행자):
      • 대주제(title) 선택 후, 해당 대주제 내에서 세부 주제(subject)를 고름.
      • 선택한 주제에 맞게 60초 동안 그림을 그림.
    • Game Participant (게임 참여자):
      • 주어진 그림을 보고 정답을 맞춤.
  3. 게임 진행 흐름
    • 참여자들이 닉네임 입력 후 방에 입장.
    • 진행자가 대주제와 세부 주제를 선택하고 다른 참여자에게 게임 참여 요청.
    • 참여자들이 게임 참여 여부 결정 및 게임 시작.
    • 진행자가 주제에 맞는 그림을 그림, 참여자들이 정답을 맞춤.
    • 정답을 맞춘 참여자가 다음 진행자가 되어 게임 반복.
    • 3라운드 후 게임 결과창에서 점수 확인.
  4. 게임 시작 조건
    • 진행자는 총 3회까지 캔버스를 초기화할 수 있으며, 그 이상은 불가능.
  5. 기능 개발 스펙
    • BackEnd:
      • 게임 정보 저장: 방 정보, 게임 참여자, 점수, 주제 등.
      • 게임 주제 선택 후 HTTP 요청을 통한 서버와의 통신.
      • 이전 라운드 주제 저장하여 중복 제거.
    • FrontEnd:
      • 이벤트 전송을 위한 DataChannel 사용.
      • 캔버스 그리기, 초기화 이벤트.
      • TTS를 통한 승리자 닉네임 발표.
      • 결과 이벤트 처리.
    • Python Server:
      • ChatGPT API를 활용한 랜덤 대주제 및 소주제 제시.
  6. 추가 구현 사항
    • 캔버스 부분 지우기 기능 (지우개).
    • 진행자 또는 참여자가 게임 중간에 나간 경우 처리.
    • 전체 게임 참여자 정보 및 UI 표시 방법.
    • 참여자가 동시에 정답을 맞춘 경우 처리 방안.

3. CatchMind BackEnd 개발

전체적인 코드를 쓰자면 사실 포스팅 하나로는 되지 않을듯해서 일단 간단하게 어디가 추가되었는지 변경되었는지 작성한다

1) CatchMindConfig

- catchmind config 클래스입니다. Python api server 의 url 정보를 저장하고 활용하기 위한 클래스

- url 은 환경변수로 입력되는 값이 있다면 그것을 사용하고, 없다면 application.properties 에서 끌어와서 사용

@Configuration
@Getter
public class CatchMindConfig {

    @Value("${catchmind.python.api.url}")
    private String url;

    // catchmind python api server 의 url 을 세팅하기 위한 postConstruct
    @PostConstruct
    private void initCatchMindConfig(){
        String envCatchMindUrl = System.getenv("CATCH_MIND_API");
        if(!StringUtil.isNullOrEmpty(envCatchMindUrl)){
            url = envCatchMindUrl;
        }
    }
}

 

2) CatchMindService

- 캐치마인드의 전반적인 서비스를 담당하는 클래스

- 각 메서드의 구현은 serviceimpl 을 확인필요!! 여기에 다 적기는 너무 많아요ㅠㅠ

-> 중간중간 아직 예외처리가 미흡한 부분이 있는데 이 부분들은 "어떻게" 예외처리하면 좋을지 고민되서 남겨둔 부분으로 꼭 개선이 필요하다

public interface CatchMindService {
    /**
     * 방에서 게임이 플레이 된 적이 있는지 확인
     * @param roomId
     * @return 게임 플레이 여부
     */
    boolean chkAlreadyPlayedGame(String roomId);

    /**
     * python server 에 게임 대주제를 요청
     * @return 5개의 대주제를 return
     * @throws Exception
     */
    GameTitles getTitles() throws Exception;

    /**
     * 대주제에 맞는 게임 소주제 요청
     * @param roomId
     * @param gameSubjects
     * @return 5개의 게임 소주제 return
     * @throws Exception
     */
    GameSubjects getSubjects(String roomId, GameSubjects gameSubjects) throws Exception;

    /**
     * 게임 전체 정보 세팅
     * @param gameSettingInfo
     */
    void setGameSettingInfo(GameSettingInfo gameSettingInfo);

    /**
     * 유저 정보 업데이트
     * @param gameStatus
     * @param roomId
     * @param userId
     * @return 유저 정보
     */
    CatchMindUser updateUser(GameStatus gameStatus, String roomId, String userId);

    /**
     * 게임 결과 정보 return
     * @param roomId
     * @return 방에서의 게임 결과
     */
    GameSettingInfo getGameResult(String roomId);
    List<CatchMindUser> getGameUserInfos(String roomId);
    boolean chkDuplicateNickName(String nickName);
}

 

3) setBeforeSubjects

- 이전 라운드의 대주제와 소주제를 저장한 후 다음 라운드에서 활용하기 위한 메서드

- 정말 중요한 메서드 중 하나인데, 이게 없으면 만약 라운드에서 '게임' 이라는 대주제가 선택될 때마다 2~3 개이상 혹은 5개 모두 동일한 소주제가 튀어나오는 경우가 많았다.

- 이는 chatGPT 가 랜덤하게 선택해서 보여주는 소주제 5개가 어디까지나 '대주제' 라는 카테고리 안에서 '랜덤'한 주제일 뿐 이전 라운드 즉, 이전 대화주제에서 나왔던 소주제를 확인할 수 없고, 이를 제외하라는 프롬프트가 없기 때문이 아닐까 생각된다.

- 이를 해결하기 위해 매 라운드마다 대주제와 소주제 5개를 저장해두고, 만약 이번 라운드에서 이전에 선택된 대주제가 다시 선택되었다면 소주제를 http 요청에 함께 포함해서 보내고, python 에서 chatgpt 에 소주제를 요청할때 이전에 선택되었던 before_subject 를 제외하라는 프롬프트를 넣어주면 이전 소주제를 거의 포함하지 않는 랜덤한 소주제 5개를 뽑을 수 있다.

private GameSubjects setBeforeSubjects(GameSettingInfo gameSettingInfo, GameSubjects gameSubjects) {
    if (CollectionUtils.isEmpty(gameSettingInfo.getBeforeSubjects())) {
        Map<String, List<String>> beforeSubjects = new ConcurrentHashMap<>();
        beforeSubjects.put(gameSubjects.getTitle(), Collections.emptyList());
        gameSettingInfo.setBeforeSubjects(beforeSubjects);
    } else {
        List<String> beforeSubjects = gameSettingInfo.getBeforeSubjects()
                .getOrDefault(gameSubjects.getTitle(), Collections.emptyList());
        gameSubjects.setBeforeSubjects(beforeSubjects);
    }
    return gameSubjects;
}

4. Catchmind 프론트 개발

사실 당연한 이야기일 수 있지만 캐치 마인드 게임의 개발의 상당 부분을 차지하는것은 프론트 엔드쪽이다. 캔버스에 그리는 마우스 이벤트부터 각 참여자에 대한 요청 이벤트까지 비율로 따지면 약 60%는 프론트가 차지한다고 생각한다.

 

1) datachannel.js

- datachannel 에 추가된 게임 이벤트! 게임 요청, 게임 거절, 유저의 준비 이벤트, 새로운 게임 이벤트, 마우스 이벤트 등등을 포함한다. 

- 실제 동작은 catchmind.js 에 작성되어있고, 여기서는 각 이벤트가 일어날 때마다 혹은 이벤트가 일어나기 전 각 참여자에게 datachannel 을 통해 어떤 이벤트가 일어나야하는지 요청을 보내고 여기서는 어떤 이벤트를 실행해야하는지 정의한다.

gameEvent: function (event) {
    switch (event.gameEvent) {
        case 'gameRequest':
            $('#gameRequestModal').modal('show');
            break;
        case 'rejectGame':
            catchMind.rejectGame();
            break;
        case 'addReadyUser':
            catchMind.addGameReady('participant', event.gameUser, event.nickName);
            break;
        case 'newGame':
            catchMind.subject = event.newSubject;
            catchMind.title = event.newTitle;
            break;
        case 'mouseEvent':
            catchMind.canvasDrawingEvent(event);
            break;
        case 'newWiner':
            catchMind.speakWiner(event.winer);
            catchMind.resetGameRound(event.winer);
            break;
        case 'clearCanvas':
            catchMind.clearCanvas();
            break;
        case 'newRoundSetting':
            catchMind.newRoundSubject(event);
            break;
        default:
            if (event === 'gameStart') {
                catchMind.participantGameStartEvent();
            }
            break;
    }
}

 

2) catchmind.js

- catchmind 의 모든 정수를 담은 메인 js. 거의 모든 이벤트가 이쪽에 모여있다.

- 캐치마인드 게임을 만들면서 가장 오래 고민했던 것은 바로 canvas 에 마우스 이벤트를 어떻게 구현할까? 였다. 물론 당연히 이게 메인 이벤트니까 고민하는것이 당연하지만 문제는 "어떻게" 라는 부분이었다. 어떤 로직을 갖고 어떻게 구현할 수 있을까? 라는 것이었다.

- 일단 그림을 그릴 수 있는 경우는 drawing  와  isGameStart 가 true 이며 isTimeRemain 이 true 인 경우이다.

- 마우스 이벤트는 단순히 canvas 위에 마우스가 있다고 바로 실행되는것이 아니라 캔버스 영역 안에서 1)마우스를 누르는 동안 그 이동 좌표에 따라서 선이 그려져야하고, 2) 마우스를 떼면 선도 더 이상 그려지면 안된다. 동시에 3) 마우스가 이전에 끝마쳤던 지점에서 완전히 다른 지점에서 시작하면 다시 그곳에서 부터 그림이 그려져야만 했다.

- 위에서 설명한 1) 2) 3) 을 모두 만족시키 위해서 처음부터 하나하나 조건을 잡으면서 개발을 시작했다. 사실 이 부분은 회사에서 같이 스터디를 하시는 동료분께서 도움을 많이 주셨다. 계속 코테를 준비하는 분이라 그런지 역시나 이런 부분에 대해서는 금방 로직을 생각해내고 이야기해주시는 걸 보면서 놀라움과 '나도 진짜 알고리즘 공부를 해야겠구나'라는 생각도 들었다.

마우스 이벤트와 좌표
saveX, saveY : 선 시작점 x, y 좌표
lastX, lastY : 진행자의 마지막 x, y 좌표
moseX , mouseY : 참여자가 받은 진행자의 mouse 의 X, Y 좌표

1) 마우스를 누르는 동안 이동 좌표에 따라서 선을 그린다
- 마우스를 누를때 drawing 을 true 로 변경하고, mouseInit 에 true 를 준다.
- setMousePosition 함수를 통해 마우스의 현재 위치를 계속 갱신하며 선을 그린다. 이때 갱신되는 마우스의 좌표는 계속 lastX, 와 lastY 에 업데이트한다.
- 시작하는 saveX, saveY 에서 새로 받은 mouseX, mouseY  까지 선을 그린다
- 가장 마지막에 받았던 lastX 와 lastY 는 다음 마우스 이벤트의 시작점 saveX, saveY 가 된다.

2) 마우스를 떼면 선도 더 이상 그려지면 안된다.
- 이 부분은 마우스를 떼거나 캔버스 밖으로 나갔을 때 drawing 의 값을 false 로 설정해서 마우스 이벤트가 발생되지 않도록 했다.

3) 마우스가 이전에 끝마쳤던 지점에서 완전히 다른 지점에서 시작하면 다시 그곳에서 부터 그림을 시작한다
- 마우스를 떼는 순간 mouseInit 값을 받고, saveX 와 saveY 를 0, 0 으로 초기화한다. 이는 마우스를 한번 뗀 후 캔버스의 어느 위치에서 다시 시작할지 모르기에 처음 시작하는 위치를 초기화하기 위함이다.
- 만약 saveX 와 saveY 가 0,0 이라면 mouseX 와 mouseY 는 시작점 saveX, saveY 가 된다.
initCanvasEvent: function () {
    let self = this;
    // 마우스 움직일 때
    self.canvas.addEventListener('mousemove', function (e) {
        if (self.drawing && self.isGameStart) {
            self.ctx.beginPath();
            self.ctx.moveTo(self.lastX, self.lastY);
            self.setMousePosition(e);
            self.ctx.lineTo(self.lastX, self.lastY);
            self.ctx.stroke();

            // console.log("x pos : ", lastX + " ::::: " + "y pos : ", lastY);

            const pos = {
                "gameEvent": "mouseEvent",
                "mouseX": self.lastX,
                "mouseY": self.lastY
            }

            dataChannel.sendMessage(pos, 'gameEvent');
        }
    });

    // 마우스 누를 때
    self.canvas.addEventListener('mousedown', function (e) {
        if (self.isTimeRemain) { // 게임 시간이 남아있다면
            self.drawing = true;
            self.setMousePosition(e);
            const pos = {
                "gameEvent": "mouseEvent",
                "mouseInit": true
            }

            dataChannel.sendMessage(pos, 'gameEvent');
        }
    });

    // 마우스 뗄 때와 캔버스 밖으로 나갈 때
    self.canvas.addEventListener('mouseup', function () {
        self.drawing = false;
    });
    self.canvas.addEventListener('mouseout', function () {
        self.drawing = false;
    });

    $('#answerBtn').text('Tell Your Answer!');
},

    canvasDrawingEvent: function (event) {

        let mouseX = event.mouseX;
        let mouseY = event.mouseY;

        if (event.mouseInit) {
            this.saveX = 0;
            this.saveY = 0;
            return;
        }

        if (this.saveX === 0 && this.saveY === 0) {
            this.saveX = mouseX;
            this.saveY = mouseY;
        }

        this.ctx.beginPath();
        this.ctx.moveTo(this.saveX, this.saveY); // 시작점 설정

        // console.log("x pos : ", this.lastX + " ::::: "+"y pos : ", this.lastY);

        this.ctx.lineTo(mouseX, mouseY); // 끝점 설정 (여기서는 시작점에서 조금 떨어진 위치로 설정)
        this.ctx.stroke(); // 선 그리기

        this.saveX = mouseX;
        this.saveY = mouseY;
    },
     setMousePosition: function (e) {
        // 정리 필요!!
        let rect = this.canvas.getBoundingClientRect();
        if (e.clientX) {
            this.lastX = e.clientX - rect.left;
            this.lastY = e.clientY - rect.top;
        } else if (e.touches) {
            this.lastX = e.touches[0].clientX - rect.left;
            this.lastY = e.touches[0].clientY - rect.top;
        }
    },

 

5. 개발 결과

게임 시작부터 게임 결과 확인까지 테스트!!

드디어....

 

6. 내가 개발하는 나만의 게임과 3개월의 여정

무식하면 용감하다고 멋지게 나만의 게임을 만들꺼야! 라는 포부와 함께 게임을 시작한 3개월의 여정의 끝이 났습니다. 특히나 제가 고민해왔고, 구성해왔던 일부를 실제로 구현했다는 것만해도 하나의 벽을 넘은 기분입니다. 여기에는 미처 다 담지는 못했지만 개발하면서 정말 많은 것들을 공부할 수 있었고, 시행착오도 많았습니다. 시간상 따지면 제가 처음으로 N:M 화상채팅을 만들때만큼 소요한 것 같습니다. 사실은 꼼꼼하게 이것도 체크하고 저것도 체크하고하면서 만들어진게 아니라 얼기설기 이렇게해보면 어떨까? 저렇게 해보면 어떨까? 하면서 만들었기에 아직 갈길이 멀다는 생각이 듭니다. 자세히 구석구석보면 버그도 엄청 많고, 생각만해두고 구현안된 기능들도 많거든요ㅋㅋㅋ

 

그럼에도, 누가 정해준게 아니라 오직 제가 즐겁기 때문에 만들기 시작했기에 계속계속 이것저것 만들고, 부딪치면서 가보겠습니다. 화이팅!ㅋㅋㅋ

 


Reference

- 게임 버튼

https://codepen.io/reulison/pen/WNNVPZq

 

Video Game Buttons

Some video-game style buttons in css3...

codepen.io

 

- Toast 팝업

https://apvarun.github.io/toastify-js/#

 

Toastify JS - Pure JavaScript Toast Notificaton Library

Toastify JS Better notification messages Try Docs Tweet Usage Toastify({ text: "This is a toast", duration: 3000 }).showToast();

apvarun.github.io

https://github.com/apvarun/toastify-js/blob/master/README.md

 

toastify-js/README.md at master · apvarun/toastify-js

Pure JavaScript library for better notification messages - apvarun/toastify-js

github.com

 

- 로딩 팝업 관련

https://chobopark.tistory.com/188

 

[JQuery] 로딩창 설정 방법 (+LoadingOverlay사용법)(영상 有)

안녕하세요. 오늘은 프로젝트에서 로딩시에 화면을 나타내는 로딩 페이지를 이야기해보겠습니다. 스크립트 함수나 AJAX 사용 시, 로딩시간이 있는 경우, 사용자 입장에서 화면이 움직이지 않을

chobopark.tistory.com

 

https://spin.js.org/#?lines=15&length=30&width=14&radius=46&scale=2.25&corners=1&speed=1.3&rotate=0&animation=spinner-line-shrink&direction=1&color=%233d6ee1&fadeColor=transparent&top=50&left=50&shadow=0%200%201px%20transparent&zIndex=2000000000&className=spinner&position=absolute

 

spin.js

spin.js Example Share it! If checked, the option values will be stored in the URL so that you can easily share your settings. Features No images No dependencies Highly configurable Resolution independent Uses CSS keyframe animations Works in all major brow

spin.js.org

https://kkh0977.tistory.com/1031

 

97. (javascript/자바스크립트) spin js 라이브러리 사용해 로딩 loading 스핀 구현 실시

[ 개발 환경 설정 ] ​ 개발 툴 : Edit++ 개발 언어 : javascript [소스 코드] ​ [결과 출력] ​ [요약 설명] ​ /* [JS 요약 설명] 1. window.onload : 브라우저 로드 완료 상태를 나타냅니다 2. spin js : 브라우저

kkh0977.tistory.com

 

- 프로그래스 바

https://codebyzach.github.io/pace/

 

PACE — Automatic page load progress bars

What is Pace? Include pace.js and a CSS theme of your choice, and you get a beautiful progress indicator for your page load and ajax navigation. No need to hook into any of your code, progress is detected automatically. Get Started Themes Download a theme

codebyzach.github.io

https://progressbarjs.readthedocs.io/en/latest/?q=remove&check_keywords=yes&area=default

 

ProgressBar.js

Get started ProgressBar.js is lightweight, MIT licensed and supports all major browsers including IE9+. See complete examples in full examples section. Loading module CommonJS var ProgressBar = require('progressbar.js') var line = new ProgressBar.Line('#co

progressbarjs.readthedocs.io

 

댓글