Pong online multiplayer - phaser js + WebRTC

4

Hej,

pong

moja pierwsza prosta gra sieciowa oparta na komunikacji WebRTC,
można pograć z żywym przeciwnikiem pod warunkiem, że obie przeglądarki wspierają WebRTC.
Gra korzysta z biblioteki phaser.js
Zasady proste, aż do bólu :)

link do gry - www.pong-multiplayer.eu

1

Fajnie byłoby przeczytać jakiś artykuł w stylu "post mortem" (Taka refleksja jak na Gamasutra.com), który by analizował co poszło dobrze w developerce, co poszło źle, z czym były problemy, czy WebRTC jest już gotowe do używania itp.

To, przynajmniej dla mnie, byłoby bardziej ciekawe niż samo granie w grę typu pong.

2

Ok, więc do rzeczy :)

Po pierwsze nie jest trudno się w wgryźć w temat, bo WebRTC jest dobrze opisana i udokumentowana:
https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API

Na dodatek z implementacją WebRTC na potrzeby takiej małej aplikacji nie ma większych problemów.
Mamy tylko jeden strumień (dane) pomiędzy użytkownikami, którego stan obserwujemy.
Co innego w bardziej złożonych aplikacjach gdzie mamy strumienie video, audio, danych, połączenia punkt<->wielopunkt, ... ale to nie ten przypadek.

Wiadomo trudnością w aplikacjach sieciowych jest potrzeba reagowania na potencjalne problemy z połączeniem, a te mogą się pojawić spontanicznie.
WebRTC jest w stanie się "naprawić" tzn wejść w stan "disconnected" i po rozwiązaniu problemów wrócić do stanu "connected".
Ciężko jest jednak takie zachowanie wymusić, w sieci nic nie znalazłem jak symulować problemy z połączeniem via webrtc,
a odłączanie co chwila laptopa od wifi jest męczące, niepewne i w ogóle do d... :)

Dlatego jeśli strumień danych zostanie nawet chwilowo "uszkodzony", traktuje to jako całkowitą utratę połączenia.
Zadbałem o czytelne informowanie uczestników gry o tym fakcie oraz o możliwość ponownego dołączenia do tej samej rozgrywki.
Gdyby udało mi się znaleźć odpowiednie narzędzie do symulowania problemów z połączeniami to chętnie bym dodał aplikacji więcej "sprytu" :)
Może ktoś z Was mógłby coś podpowiedzieć w tej materii.

poza tym korzystanie z webrtc wymaga:

  • postawienia serwera STUN/TURN na jakiejś maszynie w sieci, do translacji NAT
  • posiadanie kanału komunikacji do początkowej negocjacji połączenia WebRTC, np serwera websocket

Minusem jest na pewno brak wsparcia przez wszystkie przeglądarki, działa bez problemu w FF, CH, O.

Trudno też testować całość. Testowanie ma sens jeśli uruchamia się aplikacje z dwóch różnych sieci.
W sieci lokalnej nie ma translacji NAT, połączenia jest zestawiane błyskawicznie, a komunikacja praktycznie bez opóźnień.
W warunkach domowych mogłem potestować jedynie na szybkim połączeniu stacjonarnym (100 / 10 MB), mobilnym (1 / 0.25 MB) i komórkowym lte.
Działało, a opóźnienia były na tyle małe, że nie psuły przyjemności z gry :)

Tyle w skrócie z moich przemyśleń :D

0

@lhp: wrzucales gdzies na guthuba/bitbucketa ?

2
WhiteLightning napisał(a):

@lhp: wrzucales gdzies na guthuba/bitbucketa ?

nie upubliczniałem całego kodu, czuje w nim potencjał komercyjny ;)

ale klasę opakowującą RTC mogę zapodać:

import {Observable, Subscriber, Subject, ReplaySubject, Subscription } from 'rxjs';

export const enum WebRTCPeerConnectionStateTypes {
    Connecting = "[RTC] Connecting",
    Connected = "[RTC] Connected",
//    Exit = "[RTC] Exit",
    Failed = "[RTC] Failed",
//    Closed = "[RTC] Closed"
}

interface DataChannel {
    readyState:string; //experimental
    send:(data:any) => void;
    onopen:(ev:Event) => void; //experimental
    onmessage:(ev:Event) => void; //experimental
    onclose:(ev:Event) => void; //experimental
    close:() => void;
}

export class WebRTCPeerConnectionState {
    constructor(
        public state: WebRTCPeerConnectionStateTypes,
        public failedReason: string = null
    ) {}
}

export class WebRTCPeerConnection {

    private rtcCfg:object = {
        iceServers:[
            {urls:["stun:xx.xx.xxx.xx:xxxx"]},
            {urls:["turn:xx.xx.xxx.xx:xxxx"], username:"xxxxxx", credential:"xxxxxxxx"}
        ]
    };

    private initialized:boolean = false;
    private webRTCSupported:boolean;

    private rtcPeerConnection:RTCPeerConnection;
    private connectionState:WebRTCPeerConnectionState;
    private dataChannel:DataChannel;

    private stateObserver:Subscriber<WebRTCPeerConnectionState>;
    private state$:Subject<WebRTCPeerConnectionState>;
    private stateSub:Subscription;
    
    private dataObserver:Subscriber<object>;
    private data$:Subject<object>;
    private dataSub:Subscription;

    constructor(
        private connectionMaster:boolean,
        private sendMessageToRemote:Function,
        private debugMode:boolean
    ) {
    
        this.state$ = new ReplaySubject();
        this.stateSub = (new Observable(observer => {
            this.stateObserver = observer;
        })).subscribe(this.state$);
        
        this.data$ = new Subject();
        this.dataSub = (new Observable(observer => {
            this.dataObserver = observer;
        })).subscribe(this.data$);
        
    }
    
    ini() {

        if(this.initialized) {
            throw new Error("Instance already initialized.");
        }
                
        this.initialized = true;
        
        this.setStateConnecting();
        
        if (WebRTCPeerConnection.browserSupportWebRTC()) {
            this.webRTCSupported = true;
            this.iniWebRTC();
        } else {
            this.webRTCSupported = false;
            this.setStateFailed("WebRTC not supported.");
        }
        
    }
    
    private iniWebRTC() {
        
        this.rtcPeerConnection = new RTCPeerConnection(this.rtcCfg);   //experimental
    
        this.rtcPeerConnection.oniceconnectionstatechange = 
            this.onIceConnectionStateChange.bind(this);  //experimental
            
        this.rtcPeerConnection.onicecandidate = 
            this.onIceCandidate.bind(this); //experimental
        
        if(this.connectionMaster) {
            
            this.dataChannel = this.rtcPeerConnection["createDataChannel"]("dataChannel");  //experimental
            this.dataChannel.onopen //experimental
                = (ev:Event) => {
                    this.setStateConnected();
                    this.logInternalStates();
                }
            this.dataChannel.onmessage //experimental
                = (ev:Event) => {
//                    console.log("channel data message", ev["data"]);
                    try { this.dataObserver.next(JSON.parse(ev["data"])); } catch (ex) {}
                };    
            this.dataChannel.onclose //experimental
                = (ev:Event) => {
                    this.setStateFailed("Data channel suddenly closed");
                };    
            
            this.createOffer();
            
        } else {
        
            this.rtcPeerConnection["ondatachannel"] //experimental
                = (ev:Event) => {
                    this.dataChannel = ev["channel"];
                    this.dataChannel.onopen //experimental
                        = (ev:Event) => {
                            this.setStateConnected();
                            this.logInternalStates();
                        }
                    this.dataChannel.onmessage //experimental
                        = (ev:Event) => {
//                            console.log("channel data message", ev["data"]);
                            try { this.dataObserver.next(JSON.parse(ev["data"])); } catch (ex) {}
                        };    
                    this.dataChannel.onclose //experimental
                        = (ev:Event) => {
                            this.setStateFailed("Data channel suddenly closed");
                        };   
                }
                
        }
              
        this.logInternalStates();
        
    }
    
    destroy() {
        
        if(!this.initialized) {
            throw new Error("Instance not initialized yet. Call method ini().");
        }
        
        if(this.rtcPeerConnection) {
            this.rtcPeerConnection.close();
            this.rtcPeerConnection.oniceconnectionstatechange = null;
            this.rtcPeerConnection.onicecandidate = null;
            this.rtcPeerConnection["ondatachannel"] = null;
            this.rtcPeerConnection = null;
        }
        
        if(this.dataChannel) {
            this.dataChannel.onopen = null;
            this.dataChannel.onmessage = null;
            this.dataChannel.onclose = null;
            this.dataChannel = null;
        }
        
        this.stateSub.unsubscribe();
        this.state$.unsubscribe();
        this.dataSub.unsubscribe();
        this.data$.unsubscribe();
        
        this.logStatus("!!!WRAPPER TOTAL DESTROYED!!!");
        
    }
    
    subscribeState(observer:{next:(state:WebRTCPeerConnectionState)=>void}) {
        
        if(this.initialized) {
            throw new Error("Instance already initialized.");
        }
        
        return this.state$.subscribe(observer);
        
    }
    
    //wysyla dane do zdalnego klienta
    sendData(data:string) {
        if(this.connectionState.state !== WebRTCPeerConnectionStateTypes.Connected) {
            throw new Error("Data channel is unavailable.");
        }
        
        try {
            "open" === this.dataChannel.readyState && this.dataChannel.send(data);
            
        //for chrome bug fix, mimo ze readyState ma wartosc open send rzuca 
        //wyjatek DOMException "Failed to execute 'send' on 'RTCDataChannel'    
        } catch (ex) {} 
        
    }
    
    //subskrybuje dane od zdalnego klienta
    subscribeData(observer:{next:(data:object)=>void}) {
        return this.data$.subscribe(observer);
    }
    
    setReceivedMessage(msg:string) {
        
        if(!this.initialized) {
            throw new Error("Instance not initialized yet. Call method ini().");
        }
        
        if(this.webRTCSupported) {
        
            let data:object;
            try { data = JSON.parse(msg); } catch (ex) {}

            if(data && "offer" === data["type"] && data["sdp"]) {

                this.logStatus("received offer");

//                this.rtcPeerConnection.setRemoteDescription(new RTCSessionDescription({type:"offer", sdp:"foo"})) //SYMULOWANE WYWOLANIE BLEDU
                this.rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(data))
                    .then(
                        () => this.createAnswer(),
                        (err:object) => {
                            this.setStateFailed(err.toString())
                        }
                    );

            } else if(data && "answer" === data["type"] && data["sdp"]) {        

                this.logStatus("received answer");

//                this.rtcPeerConnection.setRemoteDescription(new RTCSessionDescription({type:"offer", sdp:"foo"})) //SYMULOWANE WYWOLANIE BLEDU
                this.rtcPeerConnection.setRemoteDescription(new RTCSessionDescription(data))
                    .then(
                        () => {},
                        (err:object) => {
                            this.setStateFailed(err.toString())
                        }
                    );


            } else if(data && data["candidate"]) {

                this.logStatus("received candidate");

//                this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate({candidate:null})) //SYMULOWANE WYWOLANIE BLEDU
                this.rtcPeerConnection.addIceCandidate(new RTCIceCandidate(data))
                    .catch(
                        (err:object) => {
                            this.setStateFailed(err.toString())
                        }
                    );    

            } else {
                this.logStatus("received unknow data");
            }
            
        }
        
    }
    
    manualInterruptConnection(sig:number) {
        this.logStatus(`Requested manual interrupt connection for sig=${sig}.`);
        switch(sig) {
            case 1:
                if(!this.rtcPeerConnection) throw new Error("Rtc peer conneciton not exists.");
                this.rtcPeerConnection.close();
                break;
            case 2:
                if(!this.dataChannel) throw new Error("Data channel not exists.");
                this.dataChannel.close();
                break;
        }
    }

    static browserSupportWebRTC():boolean {
        
//        return false; //SYMULOWANE WYWOLANIE BLEDU
        
        if(!!~window.navigator.userAgent.indexOf("Edge")) return false;
        
        var isWebRTCSupported:boolean = !!(
            navigator["webkitGetUserMedia"] ||
            navigator["mozGetUserMedia"] ||
            navigator["msGetUserMedia"] ||
            window["RTCPeerConnection"]
        );
        
        return isWebRTCSupported;
    }

    private createOffer() {
        
//        this.rtcPeerConnection.createOffer(null, null, {}).then( //SYMULOWANE WYWOLANIE BLEDU
        this.rtcPeerConnection.createOffer().then(
            (offer: RTCSessionDescription) => {
                this.rtcPeerConnection.setLocalDescription(offer); //experimental
                this.sendMessageToRemote(JSON.stringify(offer));
            },
            (err:object) => {
                this.setStateFailed(err.toString())
            }
        );
    }
    
    private createAnswer() {
        
//        this.rtcPeerConnection.createAnswer(null, null).then( //SYMULOWANE WYWOLANIE BLEDU
        this.rtcPeerConnection.createAnswer().then( //experimental
            (answer:RTCSessionDescription) => {
                this.rtcPeerConnection.setLocalDescription(answer); //experimental
                this.sendMessageToRemote(JSON.stringify(answer));
            },
            (err:object) => {
                this.setStateFailed(err.toString())
            }
        );
    }
    
    private onIceCandidate(ev:RTCPeerConnectionIceEvent) {
        if(ev.candidate) {
            this.sendMessageToRemote(JSON.stringify(ev.candidate.toJSON()));
        }
    }
    
    private onIceConnectionStateChange(ev:Event) {
        
        switch(this.rtcPeerConnection.iceConnectionState) { //experimental
        
            /*
             * nie uzywamy tych stanow, dlatego sa zakomentowane 
             */
//            case "new":
//            case "checking":
//                this.setStateConnecting();
//                break;
//            
//            case "connected":
//            case "completed":
//                this.setStateConnected();
//                break;
                    
            case "failed":
                this.setStateFailed("ice connection state " + this.rtcPeerConnection.iceConnectionState);
                break; 
                
//            case "disconnected":
//            case "closed":
//                this.setStateFailed("ice connection state " + this.rtcPeerConnection.iceConnectionState);
//                break; 
                   
        }
        
        this.logInternalStates();
        
    }
    
    private setStateConnecting() {
        this.connectionState = new WebRTCPeerConnectionState(
            WebRTCPeerConnectionStateTypes.Connecting
        );
        this.stateObserver.next(this.connectionState);
    }
    
    private setStateConnected() {
        this.connectionState = new WebRTCPeerConnectionState(
            WebRTCPeerConnectionStateTypes.Connected
        );
        this.stateObserver.next(this.connectionState);
    }
    
    private setStateFailed(reason:string) {
        this.connectionState = new WebRTCPeerConnectionState(
            WebRTCPeerConnectionStateTypes.Failed,
            reason
        );
        this.stateObserver.next(this.connectionState);
        this.logFailed(reason);
    }
        
    private logInternalStates() {
        this.logStatus(
            "SIGNALING_STATE:" + (this.rtcPeerConnection ? this.rtcPeerConnection.signalingState : "rtcPeerConnection not exists") + " " +
            "ICE_CONNECTION_STATE:" + (this.rtcPeerConnection ? this.rtcPeerConnection.iceConnectionState : "rtcPeerConnection not exists") + " " +
            "MY_STATE.STATE:" + this.connectionState.state + " " +
            "MY_STATE.FAILED_MSG:" + this.connectionState.failedReason + " " +
            "CHANNEL_DATA:" + (this.dataChannel ? this.dataChannel.readyState : "dataChannel not exists")
        );
    }
    
    private logStatus(status:string) {
        this.debugMode && console.log("WebRTC LOG STATUS >>", status);
    }
    
    private logFailed(reason:any) {
        this.debugMode && console.log("WebRTC LOG FAILED >>", reason);
    }
    
}

1 użytkowników online, w tym zalogowanych: 0, gości: 1