Jasontreks Blog

DM 보내기

메세지는 텔레그램 챗봇에 의해 익명으로 전달됩니다. 답장을 받으려면 이메일을 입력하세요.
Send

스팀 멀티플레이

P2P 멀티플레이의 한계

이전에 리슨 서버(Listen Server) 멀티 플레이 구현 포스트에서 리슨 서버 멀티플레이 방식에 대해서 다루었다. 리슨 서버는 P2P 연결과 같기 때문에 두 컴퓨터가 서로 공인 IP주소를 알아야 멀티플레이가 이루어질 수 있다. 하지만 대부분 사람들의 컴퓨터의 공인 IP주소는 외부로 공개되지 않기 때문에 포트 포워딩이 필요한데, 이게 상당히 귀찮은 일이기도 하고 이 게임을 배포한다고 했을 때에도 유저에 그런 불편함을 감수하게 할 수는 없는 노릇이다.

그래서 현재까지는 로컬 멀티플레이만 구현이 되어있었다. 포트 포워딩 없이 인터넷 연결을 통해 리슨 서버 멀티플레이가 이루어지는 다른 방법은 시그널링 서버를 통한 홀 펀칭을 이용하는 것이다.

홀 펀칭(Hole Punching)

시그널링 서버는 공인 IP가 공개되어 있는 서버로 두 컴퓨터가 서로의 공인 IP:포트를 기록하는 역할을 맡는다. 두 컴퓨터가 각자의 공인 IP:포트를 시그널링 서버에서 확인하면 그 주소로 동시에 패킷을 보낸다.

패킷을 보낸 직후 상대방으로부터 온 패킷을 받기 때문에 각 컴퓨터의 공유기는 이를 정상적인 응답으로 착각하고 NAT(Network Address Translation) 매핑 테이블에 기록하여 상대 컴퓨터와의 연결을 유지한다.

각 컴퓨터의 패킷 전송은 UDP 프로토콜로 이루어진다.

Steamworks

문제는 이 시그널링 서버를 누가 운영하느냐이다. 큰 회사의 경우 자사의 리소스로 직접 운영하는 일은 버겁지 않겠지만 인디 게임을 운영하는 개인 또는 소규모 개발자는 대기업이 구축한 인프라를 이용하는 수밖에 없다. 그것이 바로 Steamworks이다.

Steam. 전 세계의 게임 유통망을 담당하고 있는 대기업 중의 대기업이다. 이 회사의 게임 개발 및 유통 지원 서비스인 Steamworks는 개발자들이 게임을 만들 때 Steam의 생태계를 이용할 수 있도록 Steam API를 제공한다.

Godot 엔진에서 Steam API는 GodotSteam이라는 애드온으로 설치해 이용할 수 있다.

Steam API를 이용하면 P2P 연결은 물론 로비 생성, 참가, 친구 초대, 채팅과 같은 추가적인 커뮤니케이션 기능까지 Steam이 제공하는 인프라에서 제한 없이 이용할 수 있다. 이를 위해서는 Steamworks에 앱 등록 비용을 결제 후 발급받는 APP ID가 필요한데, 대신 480이라는 가상 게임의 APP ID를 무료로 공개하고 있기 때문에 개발 및 테스트 단계에선 이것을 사용해도 된다.

Steam.steamInitEx(app_id)

if Steam.loggedOn():
    my_steam_id = Steam.getSteamID()

게임의 실행과 동시에 Steam API 초기화 및 Steam 클라이언트 프로그램의 유효성을 확인한다. Steam 클라이언트가 켜져 있고, 로그인되어 있으면 Steam.getSteamID() 메서드를 통해 현재 로그인된 Steam 계정의 Steam ID 를 얻을 수 있다. Steam ID는 Steam 계정마다 존재하는 고유한 일련번호로 멀티플레이 연결 시 개별 피어를 식별하는 식별자로 쓰인다.

# 로비 생성 시 실행할 이벤트 등록
Steam.lobby_created.connect(
func(status: int, new_lobby_id: int):
    if status == 1:
        # 초대하기로 한 유저가 있으면 초대 전송
        if invite_steam_id:
            Steam.inviteUserToLobby(new_lobby_id, invite_steam_id)
            print("invite sended!: ", invite_steam_id)
        # 로비 정보 설정(로비 이름 등)
        Steam.setLobbyData(new_lobby_id, "game", "cannonball")
        Steam.setLobbyData(new_lobby_id, "lobby_name", "%s's match" % Steam.getFriendPersonaName(my_steam_id))
        # 게임 진행을 위해 씬을 전환하고 스팀 소켓 생성
        sceneMgr.set_scene(1)
        create_steam_socket()
        print("Lobby ID:", new_lobby_id)
        current_lobby_id = new_lobby_id
    else:
        print("Error on create lobby!")
)

Lobby ID는 하나의 멀티플레이 게임에 참여할 피어들을 함께 관리하는 객체인 Lobby에 발급되는 고유 식별자이다. 로비에 참가할 때 이 Lobby ID를 알아야 하며, 초대 전송이 바로 이 Lobby ID를 전송하는 일이다. 공개 로비의 경우 누구나 Lobby ID를 알 수 있어 모르는 사람들끼리도 멀티플레이를 즐길 수 있는 공개 매치, 무작위 매치 등에서 활용할 수 있다.

# 로비 참가 시 실행할 이벤트 등록
Steam.lobby_joined.connect(
func (join_lobby_id: int, _permissions: int, _locked: bool, response: int):
    # 로비 참가 승인 응답이면
    if response == Steam.CHAT_ROOM_ENTER_RESPONSE_SUCCESS:
        # 로비 주인(Player 1)의 Steam ID 확보
        var id = Steam.getLobbyOwner(join_lobby_id)
        # 로비 주인이 만든 소켓에 연결
        if id != Steam.getSteamID():
            sceneMgr.set_scene(1)
            connect_steam_socket(id)
            current_lobby_id = join_lobby_id
...
)

Lobby ID를 알아도 보안 때문에 곧바로 로비를 생성한 호스트의 Steam ID를 알 수는 없다. 로비 참가 승인이 이루어져야만 Steam ID를 알 수 있다. 로비 참가 후 응답으로 반환하는 response 값으로 참가 승인이 이루어졌는지 확인할 수 있다. 승인이 확인되면 Steam.getLobbyOwner() 메서드로 로비 주인의 Steam ID를 얻고 로비 주인이 생성한 소켓에 연결한다.

# 소켓 생성(로비 주인)
func create_steam_socket():
	peer = SteamMultiplayerPeer.new()
    # 호스트 생성
	peer.create_host(0)
	multiplayer.set_multiplayer_peer(peer)
    # 다른 피어가 연결되면 피어쪽에서 _add_player 함수가 호출되도록 이벤트를 등록함
	if not multiplayer.peer_connected.is_connected(_add_player):
		multiplayer.peer_connected.connect(_add_player)
    # 호스트도 바로 _add_player를 호출함
	_add_player()

    # _add_player는 월드에 플레이어를 비롯해 게임 시작에 필요한 여러 설정들을 세팅하는 함수.
    # 게임이 시작되는 진입점과 같음.

# 소켓 연결(참가자)
func connect_steam_socket(steam_id : int):
	peer = SteamMultiplayerPeer.new()
    # 클라이언트 생성
	peer.create_client(steam_id, 0)
	multiplayer.set_multiplayer_peer(peer)

이로써 로비 주인과 참가자는 각각 Player1, Player2가 되어 멀티플레이를 진행할 수 있게 된다. 호스트인 Player1이 서버 역할을 맡으며 동기화를 책임지게 된다.

다음 포스트

화염 필드와 독 구름