ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flutter로 멀티플레이 게임만들기 with WebRTC
    개발/Flutter Flame - 게임개발 2024. 6. 9. 23:04

     

    Flutter로 멀티플레이 게임을 만들기 위해, WebRTC를 사용하기로 결정했다. WebRTC를 사용하기로 결정한 이유는 아래와 같다.

    WebRTC로 게임을 만드는 이유

    1. 지연시간이 낮다.
    현존하는 실시간 스트리밍 통신 기술 중에서 가장 지연시간이 낮다고 한다. 나는 게임에서 위치정보 같은 데이터 정도만 통신할거라서 영상이나 오디오 스트리밍 같이 무거운 데이터를 주고받을 건 아니지만.. 그래도 뭐든지 빠르면 좋지 않은가! 안 쓸 이유가 없다. 

     

    2.P2P 연결방식이라 서버 비용을 절약할 수 있다.

    WebRTC는 P2P 방식으로 연결하는 방식이기 때문에, 무엇보다 서버 비용을 절약할 수 있다. 

    게임은 실시간으로 주고받는 데이터가 많다 보니.. 서버비용이 만만치 않을 것 같다. 이왕이면 서버 비용을 최대한 줄일 수있는 방식이 좋지 않을까! 이 부분 또한 내 선택의 주요 이유가 되었다.

     

    3. 모든 포맷의 데이터를 전송할 수 있다.

    WebRTC는 실시간으로 음성, 영상을 전송하기 위해 주로 쓰이지만, WebRTC의 스펙 중 DataChannel 이라는 것을 이용해서 어떠한 포맷의 데이터라도 실시간 전송이 가능하다. 즉 WebRTC로 멀티플레이 게임을 만드는 데는 아무런 문제가 없다. 나는 플레이어의 위치정보같은 게임 데이터를 전송하기 위해 이 DataChannel을 사용할 예정이다.

    Flutter로 WebRTC 게임을 만드는 과정

    이 글에서는 내가 WebRTC 학습용으로 만들어 본 게임 코드를 가지고 Flutter로 WebRTC 게임을 만드는 과정을 간략하게 소개하고자 한다. 친구도 Flutter로 IOT 앱 대신 멀티플레이 게임을 만들기로 했기 때문에, 친구에게도 도움이 도움이 될 수 있을 것 같다. ㅎ

     

    1. WebRTC 통신 방식 소개

    먼저 WebRTC 통신 방식에 대해 간단하게 소개해보려고 한다.

    WebRTC는 표준화된 API를 통해 별도의 플러그인 없이 실시간 통신을 가능하게끔 하는 기술로, 아래와 같은 순서로 이루어진다.

     

    1. 시그널링 서버와의 연결 (나 쟤랑 P2P 통신할거야! 근데 쟤 주소를 모르니까 그떄까지 너가 중개 좀 해줘):

    시그널링은 클라이언트 간 P2P 연결이 만들어지기 전까지 "시그널링 서버"를 통해 필요한 정보를 교환하는 과정이다.

    시그널링 서버는 P2P 연결을 위해 중개해주는 서버라고 생각하면 쉽다. 이 시그널링 서버는 별도로 구축해야 한다.(그치만 코드 몇줄이면 짤 수 있다.) 

    시그널링 서버를 통해 교환하는 데이터는 SDP 와 ICE가 있는데, 이에 대해서 밑에서 설명한다.

    2. ICE Candidate 수집 (나한테 연결할 수 있는 최적의 통로를 찾아내줘):

    클라이언트는 ICE(Interactive Connectivity Establishment)라는 프레임워크를 이용해 자신에게 연결될 수 있는 최적의 네트워크 연결통로 후보들을 찾아낸다. 수집된 네트워크 연결통로 후보들은 나중에 상대방에게 보내줄 예정이다.

    연결통로 후보를 찾는 과정은 아래와 같다.

    먼저 STUN 서버에게 자신에게 연결 가능한 통로를 알아내달라고 요청한다.(공인 IP 주소와 포트이다)

    만약 NAT 방화벽 등의 이슈로 못 알아온 경우, 대안으로 TURN 서버를 경유해 클라이언트 간 통신을 수행하는 네트워크 정보를 가져오게 된다.

    - STUN 서버: 클라이언트의 공용 IP 주소와 포트를 반환하는 서버.

    - TURN 서버: 클라이언트 간에 직접적인 P2P 연결이 불가능한 경우에 중계 역할을 수행하는 서버. TURN 서버는 네트워크 주소 변환(NAT) 및 방화벽 문제로 인해 직접적인 연결이 불가능한 환경에서도 실시간으로 데이터를 전송할 수 있도록 도와준다.

    3. SDP 생성 및 교환 (연결할 수 있는 통로는 찾았고, 일단 내가 보낼 정보가 어떤 정보인지 알려줄게)

    SDP(Session Description Protocol)는 자신이 어떤 정보를 보낼 건지 알려주는 정보 포맷이다.

    미디어 유형, 코덱, 네트워크 관련 정보 등을 포함한다. 이 SDP를 시그널링 서버를 통해 상대방과 서로 교환한다.

    SDP 교환 과정은 일반적으로 OFFER SDP, ANSWER SDP로 부르는 것 같다. 

    4. ICE Candidate 교환 (나한테 연결할 수 있는 통로를 알려줄게 이제 연결해!):

    SDP 교환 이후, 각 클라이언트는 수집한 ICE Candidate 정보를 시그널링 서버를 통해 상대 클라이언트에게 전송한다.

    이 과정이 끝나면 두 클라이언트 간 직접적인 P2P 연결이 완료된다!

    5. DataChannel 생성 (선택사항, 게임 데이터 같이 일반 데이터 전송 시 사용) :

    나처럼 DataChannel을 통해 데이터를 전송하려고 한다면, 이제 DataChannel을 추가로 생성해주면 된다.

     

    위 과정을 도식화하면 아래 그림과 같다.

     

     

    이미지 출처:https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Connectivity

     

     

    2. 코드 설명

    이제 Flutter WebRTC 멀티플레이 게임 예제의 코드를 설명해보겠다.

    (먼저 이 멀티플레이 게임 예제 코드는  https://devmemory.tistory.com/106 의 WebRTC 영상통화 예시 코드에서 영상통화 관련 부분을 제거하고, DataChannel 생성 부분을 추가하여 만들었다! 그래서 영상통화 관련 부분을 제외하면 거의 동일하다. 이 블로그의 코드 구조가 잘 짜여져 있어서 이해하기 편했다.)

     

    전체 코드는 Github에 올려두었다.

     

    Flutter 클라이언트 코드 레포지토리:

    https://github.com/koseyeon/flutter-webrtc-gamedemo-client

     

    GitHub - koseyeon/flutter-webrtc-gamedemo-client

    Contribute to koseyeon/flutter-webrtc-gamedemo-client development by creating an account on GitHub.

    github.com

     

     

    시그널링 서버 코드 레포지토리:

    https://github.com/koseyeon/flutter-webrtc-gamedemo-server

     

    GitHub - koseyeon/flutter-webrtc-gamedemo-server

    Contribute to koseyeon/flutter-webrtc-gamedemo-server development by creating an account on GitHub.

    github.com

     

     

     

    먼저 사전에 의존성 주입 필요한 라이브러리는 아래 두 개다.

    (socket_io_client 는 시그널링 서버와의 연결시 WebSocket으로 연결하도록 구현하였기 때문에 필요하다.)

    flutter_webrtc: ^0.9.7
    socket_io_client: ^2.0.0

     

    그럼 이제 코드를 설명해보겠다.

    화면을 구성하는 코드는 생략하고, WebRTC로 연결하는 과정의 코드만 설명하겠다.  

     

    1. 시그널링 서버와의 연결

    시그널링 서버와 연결하기 위해 WebSocket으로 시그널링 서버와 연결하는 부분이다. 시그널링 서버와 연결 된 후, 각각의 이벤트를 받을 때마다 실행될 메소드들도 달아준다.

    Future<void> _initSocket() async {
      from = await super.connectSocket();
      if (from != null) {
        super.socketOn('updateUserList', _updateUserList);
        super.socketOn('offer', _receiveOffer);
        super.socketOn('answer', _receiveAnswer);
        super.socketOn('iceCandidate', _receiveIceCandidate);
        super.socketOn('disconnectResponse', disconnectResponse);
      }
    }

     

     

    2. ICE Candidate 수집 

    WebRTC 연결을 본격적으로 시작하는 부분이다. PeerConnetion을 생성하고, ICE 를 이용해 STUN 서버와 연결하여 ICE 후보를 수집하해서 내부적으로 처리한다. STUN 서버는 일반적으로 구글 꺼를 많이 쓰는것 같다. ICE 이벤트와 PeerConnection 이벤트를 받을때마다 실행될 메소드를 달아준다.

    (DataChannel 관련 이벤트들을 받을 때마다 실행될 메소드들도 미리 달아주었다.)

     

    Future<void> _initPeer() async {
      _peer = await createPeerConnection({
        'iceServers': [
          {'url': 'stun:stun.l.google.com:19302'},
        ],
      });
      _peer!.onIceCandidate = _onIceCandidateEvent;
      _peer!.onConnectionState = _onConnectionStateEvent;
      _peer!.onDataChannel = (RTCDataChannel channel) {
        channel.onMessage = _handleDataChannelMessage;
        channel.onDataChannelState = _handleDataChannelState;
      };
    }

     

    3. SDP 생성 및 교환

    SDP를 생성해서 시그널링 서버로 전송/응답하는 부분이다.

    (offer SDP 전송/응답, answer SDP 전송/응답)

    이 멀티플레임 게임 예시는 영상이나 음성을 보낼 것이 아니기 때문에 추가적인 설정정보는 없이 기본적으로 생성되는 SDP만 주고받아도 충분하다.

    // offer SDP 전송
      Future<void> sendOffer() async {
        if (to == null) {
          return;
        }
        final RTCSessionDescription offer = await _peer!.createOffer({});
        await _peer!.setLocalDescription(offer);
        WebRTCModel model = WebRTCModel(offerSDP: offer.sdp, offerType: offer.type, to: to, from: from);
        debugPrint('[webRTC] send offer : ${model.from} to ${model.to}');
        super.socketEmit('offer', model.toJson());
        screenNotifier.value = ScreenState.waiting;
      }
    
      // offer SDP 수신
      void _receiveOffer(data) async {
        WebRTCModel model = WebRTCModel.fromJson(data);
        debugPrint('[webRTC] receive offer : ${model.to} from ${model.from}');
        await _peer!.setRemoteDescription(RTCSessionDescription(model.offerSDP, model.offerType));
        to = model.from;
        screenNotifier.value = ScreenState.receivedOffer;
      }
    
      // answer SDP 전송
      void sendAnswer() async {
        debugPrint('[webRTC] send answer to $to');
        final RTCSessionDescription answer = await _peer!.createAnswer();
        await _peer!.setLocalDescription(answer);
        final model = WebRTCModel(
          answerSDP: answer.sdp,
          answerType: answer.type,
          to: to,
        );
        super.socketEmit('answer', model.toJson());
      }
    
      // answer SDP 수신
      void _receiveAnswer(data) async {
        WebRTCModel model = WebRTCModel.fromJson(data);
        debugPrint('[webRTC] receive answer : ${model.answerType}');
        await _peer!.setRemoteDescription(RTCSessionDescription(model.answerSDP, model.answerType));
        for (IceCandidateModel candidateModel in _candidateList) {
          debugPrint('[webRTC] send iceCandidate : ${candidateModel.toJson()}');
          super.socketEmit('iceCandidate', candidateModel.toJson());
          break;
        }
      }

     

    4. ICE Candidate 교환

    클라이언트간 ICE 후보를 교환하는 부분이다. Answer SDP를 수신받으면 이어서 ICE 후보 교환을 시작한다.

    // answer SDP 수신 (answer SDP 수신과 동시에 ice 후보 교환을 시작한다.)
    void _receiveAnswer(data) async {
      WebRTCModel model = WebRTCModel.fromJson(data);
      debugPrint('[webRTC] receive answer : ${model.answerType}');
      await _peer!.setRemoteDescription(RTCSessionDescription(model.answerSDP, model.answerType));
      for (IceCandidateModel candidateModel in _candidateList) {
        debugPrint('[webRTC] send iceCandidate : ${candidateModel.toJson()}');
        super.socketEmit('iceCandidate', candidateModel.toJson());
        break;
      }
    }
    
    // ICE 후보 수신
      void _receiveIceCandidate(data) async {
        debugPrint('[webRTC] remoteIceCandidate $data');
        try {
          IceCandidateModel model = IceCandidateModel.fromJson(data);
          await _peer!.addCandidate(model.candidate);
        } catch (e) {
          debugPrint('[webRTC] remoteIceCandidate error : $e');
        }
      }

     

     

    5. DataChannel 생성 및 이벤트 처리

    DataChannel을 생성하고, 채널의 상태가 OPEN인지 확인 후 열렸다면 해당 채널을 통해 메시지를 주고 받는다.

    DataChannel에 대해 좀 더 설명하자면,

    한 커넥션 당 데이터채널은 여러 개 만들 수 있으며,

    createDataChannel('라벨명', 데이터채널변수)와 같이 특정 라벨명으로 지정할 수 있다.

    그리고 주고받는 데이터의 타입은 text와 binary로 나뉘는데,

    만약 binary 데이터를 주고받으려면 Uint8List 의 타입으로 변환해서 주고받으면 된다.

     

    // 데이터 채널 초기화
    Future<void> _initDataChannel() async {
      RTCDataChannelInit dataChannelForChat = RTCDataChannelInit();
      _dataChannel = await _peer!.createDataChannel('gameData', dataChannelForChat);
    }
    
    // 데이터 채널 상태 처리
      void _handleDataChannelState(RTCDataChannelState state) {
        if (state == RTCDataChannelState.RTCDataChannelOpen) {
          screenNotifier.value = ScreenState.connected;
          _dataChannel!.send(RTCDataChannelMessage("Connected with " + super.user.toString()));
        }
      }
      
    // 데이터 채널 메시지 처리
      void _handleDataChannelMessage(RTCDataChannelMessage message) {
        debugPrint("receive success");
        switch (message.type) {
          case MessageType.text:
            chatMessageListNotifer.value = List.from(chatMessageListNotifer.value)..add(message.text);
            break;
          case MessageType.binary:
            final Uint8List bytes = message.binary!;
            PlayerModel player = decodePlayerData(message.binary);
            updatePlayerInList(player);
            break;
        }
      }

     

     

    3. 멀티플레이 게임 실행 화면

    플레이어의 ID =  Socket ID.

    연결하고자 하는 플레이어를 선택 후 손 모양 버튼을 클릭한다.

    상대방이 Offer를 받아서 Answer를 보낼 때까지 기다린다.

    상대방의 Answer를 받으면 ICE 교환 후 성공하면 연결이 완료되어 게임화면으로 이동한다.

    채팅을 서로 보낼 수 있고, 하단의 박스 두개를 이동시키며 실시간 데이터 전송 여부를 확인할 수 있다.

     

     

     

     

    + 위와 같이 만든 WebRTC 게임을 내가 Flutter Flame으로 개발하고 있는 게임에도 적용해 봤다. 잘 되는데 약간씩 버그가 있어서 고치고 있다. 



    참고한 블로그

    위에서도 언급했듯이, 이 Flutter WebRTC 게임 예제는 다른 Flutter WebRTC 영상통화 코드를 참고해서 만들었다. 

    나는 뭔가를 만들 때 일단 관련 예제 코드부터 찾아서 현재도 잘 동작하는지 실행시켜본다. 왜냐하면 관련 라이브러리들이 업데이트가 되면서, 현재 기준으로는 맞지 않는 코드들도 많기 때문이다. 직접 돌려보고 잘 동작하면 그 코드를 참고해서 만든다.

    내가 찾아본 코드 중에서는, 아래 블로그의 코드가 잘 돌아갔고, 코드 구조도 잘 짜여져 있어서 이걸 보고 참고해서 만들었다.

    https://devmemory.tistory.com/106

     

    WebRTC - 4. Flutter WebRTC

    먼저 사용한 패키지는 3가지입니다 1. flutter_webrtc : webRTC를 사용하기 편하도록 개발해놓은 패키지 2. socket_io_client : 소켓연결 3. vibration : 전화 온것같은 효과(?) 여기서 상태관리는 어디있냐 라는

    devmemory.tistory.com

     

Steadyyeon