[사이드 프로젝트] 갈틱폰을 만들어보자

후노바스·2024년 1월 22일

TEXT-LIB

목록 보기
1/1

갈틱폰 파쿠리를 만들어보자

갈틱폰이 뭔데?

쉽게 말해 보드게임 텔레스트레이션을 웹 기반으로 만들어 온라인 유저가 참여 가능하게 만든 게임이다. 여기서 설명하는 이 게임의 한 줄 룰은 다음과 같다:

정답을 적는다 → 정답을 보고 그림을 그린다 → 그림을 보고 답을 유추한다 → 그 답을 보고 다시 그림을 그린다 → 그 그림을 보고 답을 유추한다.

추가로 릴레이가 한 바퀴 돌아 턴이 모두 끝난다면 다들 무슨 그림을 그렸고 어떤 정답을 적었는지 감상하는 시간을 가진다.

즉 한 판당 플레이어들이 릴레이 형식으로 자기 턴에 그림을 그리거나 정답을 맞추거나 해야 하며, 한 플레이어가 시작한 릴레이가 한 바퀴 도는 것을 기다리는 동안 다른 플레이어가 시작한 릴레이도 동시에 턴이 넘어가는 방식으로, 즉 게임 한 판이 끝나면 참여자 수만큼 릴레이의 결과물이 나오게 된다. 최근 여러가지 모드가 나왔는데 이번 글은 그 중 그림을 그릴 필요가 없는 릴레이 소설 모드에 초점을 맞추겠다.

이 게임의 특징은 다음과 같다.

  • 플레이어가 많을수록 더 재밌어지는 인싸용 게임이다.
  • 개성이 넘치고 창의적인 플레이어가 많을수록 혼돈 그 자체인 결과물이 나온다.
  • 릴레이 전개가 혼란하고 드리프트가 심할수록 재밌어지지만, 이어받는 사람이 난감해진다.
  • 이미 친한 그룹이 모여 자기들끼리의 내수용 밈을 공유할 때 이 게임이 빛을 발한다.
    즉, 아직 친하지 않은 그룹이 친해지기 위해서 할 게임으로는 적합하지 않다.

하지만 나는 친구도 적고 그나마도 직접 해봤을 때 썩 재밌진 않았다. 확실히 플레이어가 적고 아직 창의력을 거침없이 발휘할 사이가 아니라면 재미도 반감되는 것이 사실이다. 그런 사람들을 위해 요즘 많이들 써보는 chatGPT API를 끌어다가 플레이어로 만들면 훨씬 재밌지 않을까? 발상은 그랬다.

웹기반 게릴라 참여형 릴레이 소설 게임 (chatGPT를 곁들인)

시간 관계로 매칭 시스템은 생략하고, 대신 게릴라 참여형으로 게임을 만들어 보기로 했다.

기본적인 게임 진행 화면은 chatGPT의 UI처럼 만들었다. 즉 플레이어가 문장을 쓰면 그 다음 문장은 무조건 chatGPT가 이어받으며, chatGPT는 다음 턴에 이어받기 쉽게 문장을 넘겨주므로 다음 사람이 당황할 여지도 적다. 릴레이 소설 전개를 이어나갈 플레이어는 오직 바로 앞의 한 문장만 볼 수 있는데, 이 말은 모든 플레이어는 chatGPT가 쓴 문장을 보고 계속 이어나가야 한다는 뜻이다. 이것은 chatGPT 입장에서도 똑같이 적용된다. chatGPT는 앞의 내용을 기억하지 않으면서 사람이 쓴 앞 문장만 보고 이야기를 계속해서 전개해나갈 것이다.

여기서 몇 가지 문제를 맞닥뜨렸다.

1. 플레이어는 한 게임에 지긋이 붙어있으면 모든 문장을 관음할 수도 있다.


그 플레이어가 게임에 참여하지 않으면 상관없지만, 게릴라 참여형 게임의 특성상 그 플레이어가 게임에 참여할지 안할지, 언제 자기 문장을 쓸지는 완전 그 플레이어의 마음대로라서 게임의 정체성에 맞지 않을 것 같았다.

다른 유저의 접근 원천 봉쇄시키기

일단 모든 플레이어에게 현재 진행 중인 스토리 말풍선과 텍스트 인풋 칸을 원천 봉쇄시켜놓고, 특정 플레이어가 자기 턴이라는 것을 알리며 그 자리를 차지하는 "손들기" 버튼을 따로 만든다. 그 다음 손들기 버튼을 눌러야만 텍스트 인풋 칸에 접근하도록 만드는 것이다.

만약 누군가 손들기 버튼을 누른다면 게임 자체가 홀딩된다는 뜻이므로 DB에서 그 게임의 game_canListening 필드를 False 값으로 바꿔줘야 한다.
아래는 flask 프레임워크에서 해당 API를 ajax로 주고받도록 구현한 app.py 및 home.html 코드이다.

app.py:

db = client.TEXLIB

# 유저 아이디 및 비밀번호가 저장된 콜렉션
users_collection = db.users

# 현재 서버에 저장된 모든 게임에 대한 내용이 저장된 콜렉션
game_collection = db.game

# 특정 게임 한 판에 대한 내용이 저장된 콜렉션
story_collection = db.story


@app.route('/hand', methods=['POST'])
def switchListening():
    game_id = int(request.form.get('game_id'))
    filter_criteria = {'game_id': game_id}

    canListening = game_collection.find_one(filter_criteria, {'_id': 0})['game_canListening']

	# 이미 홀딩된 게임일 때 해당 게임의 텍스트 인풋 칸을 원천 봉쇄
    if (canListening == False):
        return jsonify({'canListening': False, 'description': '이미 누군가가 손을 들었습니다. 다음 차례를 기다려주세요!'})

	# 홀딩된 게임이 아니라면, game_canListening 필드를 False 값으로 업데이트
    game_collection.update_one({'game_id':game_id}, {'$set': {'game_canListening': False}})
    
    # 이번 게임의 마지막 문장이 뭐였는지 찾아서 클라이언트로 보내준다.
    recentGame = story_collection.find(filter_criteria).sort('story_ctr', DESCENDING).limit(1)
    lastStory = list(recentGame)[0]['story_stm']

    return jsonify({'canListening': True, 'last_story': lastStory})

home.js:

    function listenMessage(now_id, now_ctr, status) {
      let user_id = "{{ user_id }}";

      $.ajax({
            type: "POST",
            url: "/hand",
            data: {'game_id': now_id},
            success: function(response){
              
              // 백엔드에서 받아온 canListening의 상태가 참이라면
              if (response["canListening"]) {
                $("#listen-button").remove();

                // 텍스트 인풋 UI를 만들어서
                let chat_beforeListen = `<input type="text" id="user-message" placeholder="이야기를 이어나가세요..."token interpolation">${now_id}, ${now_ctr}, ${status}, '${user_id}')}">`
                let chat_afterListen = `<button id="send-button"token interpolation">${now_id}, ${now_ctr}, ${status}, '${user_id}')"> 보내기 </button>`
                
                // 적당한 위치에 붙여준다.
                $(".user-input").prepend(chat_beforeListen);
                $(".user-input").append(chat_afterListen);
                // 추가로 마지막 말풍선에 서버에서 받아온 문장을 띄워준다.
                $('.chat-messages li:nth-last-child(1)').text(`${response["last_story"]}`);
              } else {
                
                // canListening의 상태가 거짓이라면 누군가가 이미 손을 들었다는 알림창을 띄운다.
                alert(response["description"])
              }
            }
        })
    }

여기서 끝인 줄 알았으나 또다른 문제가 생겼다.

2. 플레이어의 화면이 실시간으로 서버 측의 화면과 연동되어 갱신되지 않는다.


이게 무슨 말이냐. A, B 두 플레이어가 한 게임방에 들어가있다고 치자. chatGPT가 쓴 첫 문장을 보려고 A 플레이어가 손들기 버튼을 누르면, B 플레이어는 손들기 버튼을 눌러도 위에서 설정했던 알림창이 띄워지며 접근이 차단된다. 이 때 A 플레이어가 문장을 모두 쓰고 게임방의 스토리를 갱신하면, A 플레이어는 갱신된 화면으로 가지만 B 플레이어는 새로고침하지 않는 한 A 플레이어가 문장을 쓰기 전의 게임방 상태 그대로이다.


A 플레이어의 화면B 플레이어의 화면


이게 게임플레이에 심각한 문제를 유발하는데, 위 상태에서 B 플레이어가 새로고침하지 않고 그대로 손들기 버튼을 누르면 A 플레이어가 쓴 문장에 답한 chatGPT의 문장을 보는게 아니라 첫 chatGPT의 문장을 보게 된다. 이건 A 플레이어도 봤던 문장이기 때문에, 릴레이 턴이라는 룰 자체가 붕괴된다.
이걸 위해 다른 유저에게 누군가 쓸 때마다 일일히 수동으로 새로고침하라고 할 수도 없는 노릇이다.

스토리 카운트 비교 후 자동 새로고침


서버의 게임방 카운트클라이언트의 게임방 카운트


게임방의 모든 클라이언트는 항상 서버에게 먼저 스토리 갱신을 요청하기 때문에 서버는 항상 최신 스토리 상태이다. 만약 최신 스토리 상태가 화면에 갱신되지 않은 클라이언트가 있다면, 그 클라이언트의 현재 화면에 띄워진 스토리 카운트와 서버의 최신 스토리 카운트를 비교한 다음 틀리다면 자동으로 새로고침하도록 한다.

이를 위해 웹소켓을 사용할 수 있지만, 이미 3일이라는 프로젝트 기간 중 2일을 써버렸기 때문에 아직 기본 원리도 모르는 웹소켓 구현을 포기하고 대신 한 가지 편법을 썼다. 웹소켓은 클라이언트의 화면을 실시간으로 조회하며 스토리 카운트를 자동으로 비교할 수 있다. 하지만 웹소켓을 쓰지 않는다면, 유저에게 스토리 카운트를 수동으로 비교할 트리거를 위임하면 된다. 그리고 그 트리거는 손들기 버튼에 병합시킬 예정이다.

게임 유저의 귀찮음은 사실 개발자의 귀찮음으로부터 비롯되었다는 불편한 사실을 알았다ㅋㅋ

home.js:

    function listenMessage(now_id, now_ctr, status) {
    let user_id = "{{ user_id }}";

    $.ajax({
        type: "POST",
        url: "/hand",
        // recent_li: 클라이언트의 현재 스토리 카운트가 얼마인지 서버로 보내준다.
        data: {'game_id': now_id, 'recent_li': $('.chat-messages li').length},
        success: function(response){
            if (response["canListening"]) {
              $("#listen-button").remove();

              let chat_beforeListen = `<input type="text" id="user-message" placeholder="이야기를 이어나가세요..."token interpolation">${now_id}, ${now_ctr}, ${status}, '${user_id}')}">`
              let chat_afterListen = `<button id="send-button"token interpolation">${now_id}, ${now_ctr}, ${status}, '${user_id}')"> 보내기 </button>`
                
              $(".user-input").prepend(chat_beforeListen);
              $(".user-input").append(chat_afterListen);
              $('.chat-messages li:nth-last-child(1)').text(`${response["last_story"]}`);
            } else {
              	alert(response["description"])
                // 카운트를 비교해서 서버와 맞지 않음을 확인했다면 새로고침한다.
              	chatOpen(now_id, now_ctr, $(".chat-header").text(), status);
            }
          }
      })
    }

app.py:

@app.route('/hand', methods=['POST'])
def switchListening():
    game_id = int(request.form.get('game_id'))
    # 클라이언트에서 받아온 스토리 카운트
    prev_liNum = int(request.form.get('recent_li'))

    filter_criteria = {'game_id': game_id}
    canListening = game_collection.find_one(filter_criteria, {'_id': 0})['game_canListening']
    
    # 서버 내 최신 상태의 스토리 카운트
    post_liNum = story_collection.count_documents(filter_criteria)

    if (canListening == False):
        return jsonify({'canListening': False, 'description': '이미 누군가가 손을 들었습니다. 다음 차례를 기다려주세요!'})
    # 클라이언트 <-> 서버 간 스토리 카운트가 맞지 않다면 접근 거부
    if (post_liNum != prev_liNum):
        return jsonify({'canListening': False, 'description': '누군가 답변을 했습니다. 화면을 갱신합니다.'})

    game_collection.update_one({'game_id':game_id}, {'$set': {'game_canListening': False}})
    
    lastCollection = story_collection.find(filter_criteria).sort('story_ctr', DESCENDING).limit(1)
    lastStory = list(lastCollection)[0]['story_stm']

    return jsonify({'canListening': True, 'last_story': lastStory})

이것으로 원시적인 방법만 사용했지만 해결이 된 모습이다. 그리고 총 10문장의 소설이 완성되면 아래처럼 종료된 게임에 저장되어 이 플레이어의 소설은 영원히 내 서버에 공개 박제된다.


내가 만들었지만 재밌다.



아직 다음과 같은 문제가 남아있다.

  • 손들기 버튼을 누른 사람이 그대로 세션을 종료해버리면 그 게임은 영원히 홀딩된다.
  • 손들기 버튼을 누르고 새로고침을 해도 마찬가지이다. 이는 메인 화면과 각 게임방을 정적 웹페이지(jinja2 등)로 구현하지 않고 동적 웹페이지(ajax)로 구현했기 때문에 도메인 구분이 되지 않아서이다.
  • 홀딩된 게임에서 다음 턴을 기다리는 플레이어는 언제 끝나는지 알 수 없어 답답하다. 이는 턴 당 시간 제한을 걸어두거나 웹소켓을 적용해서 쓰는 현황을 부분적으로 보여준다거나 하는 식으로 보완할 예정이다.
  • 10문장을 카운트하는 함수가 자꾸 씹힌다. chatGPT API가 느리기도 하고, GPT가 문장을 완성하는 동안 ajax가 병렬적으로 코드를 처리하여 그런 경우가 생기는 것 같다.
  • 그 외에도 턴을 기다리는 사람이 손들기 버튼을 연타하는 상황에서 자잘한 버그가 많다.

하지만 나는 아직 초보이기 때문에.. 정글을 수료한 다음 이 프로젝트는 웹소켓으로 구현한 프로젝트로써 리빌딩할 예정이다.

0개의 댓글