TSM - Creează un joc: Ping Pong (III)

Ovidiu Mățan - Fondator @ Today Software Magazine

În primele articole am prezentat afișarea paletelor cât și verificarea coliziunilor pentru paletele din jocul nostru. Continuăm cu controlul paletelor folosind balansarea telefonului și vom implementa capacitatea de multiplayer folosind WebSocketuri.

Controlul paletei prin mișcarea telefonului

Vom realiza o corelare directă între gradul de înclinare a telefonului și mișcarea paletei pe ecran. Se va folosi doar componenta beta, axa X, din cele trei componente primite de la eveniment. În codul de mai jos se poate vedea formula de calcul a noii poziții:

let handler = (event) => {
  console.log(
    "Alpha (z-axis):",
    event.alpha
  );
  console.log(
    "Beta (x-axis):",
    event.beta
  );
  console.log(
    "Gamma (y-axis):",
    event.gamma
  );
  beta.textContent =
    event.beta;
  let newPos =
    (event.beta - 20) * 5;
  gamma.textContent =
    newPos;

  if (newPos > 0 &&
      newPos < 500) {
    this.setBallY(newPos);
   }
};

Dar, bineînțeles, partea cea mai dificilă rămâne integrarea iOS/Android și obținerea permisiunilor. Un amănunt important este că pagina web trebuie să aibă o adresă reală și sigură (https).

try {
  if (DeviceMotionEvent &&
    typeof DeviceMotionEvent
    .requestPermission === 
     "function") {
    // iOS 13+
    let resp = DeviceMotionEvent
      .requestPermission()
      .then(permissionState => {
        console.log(
          "got permission state:",
          permissionState
        );
        if (permissionState ===
            "granted") {
          window.addEventListener(
            "devicemotion",
            handler
          );
          window.addEventListener(
            "deviceorientation",
            handler
          );
        }
      })
      .catch(error => {
        console.error(error);
      });
  } else {
    // Android or older iOS
    window.addEventListener(
      "devicemotion",
      handler
    );
    window.addEventListener(
      "deviceorientation",
      handler
    );
    console.log(
      "no permission is possible!"
    );
  }
} catch (e) {
  console.error(
    "unable to init gestures:",
    e
  );
}

Optimizarea aplicației

Desenarea paletei se realiza până acum în funcție de numărul de frame-uri generate pe secundă. În cazul nostru avem 60/sec. Redesenarea de fiecare dată a elementelor grafice poate ridica o problemă de performanță. O soluție simplă este să creăm un dirty flag pe care să îl setăm (true) de fiecare dată când avem nevoie de o redesenare. Astfel metoda draw() devine:

draw(){
  if ( this.#isDirty) {
    this.ctx.beginPath();
    this.ctx.fillStyle = "yellow";
    this.ctx.fillRect(this.x - 20, 
     0, this.w + 40, 600);
    this.ctx.fillStyle = 
     this.#usedColor;
    this.ctx.fillRect(this.x,  
         this.y, this.w, this.h);
        this.#isDirty=false;

        if (!this.#isRemote) {
            this.#isRemote=false;
            return this.y;
        }
    }
}

setBallY(y){
    this.y=y;
    this.#isDirty=true;
}

Setarea dirty flagului se poate vedea în metoda de mai sus. Orice modificare a poziției paletei aduce după sine o redesenare a acesteia.

Componenta multiplayer

Server side

Nici unui joc nu ar trebui să îi lipsească componenta multiplayer.

Vom folosi Socket.io peste protocolul de transport WebSocket. Motivul este folosirea s-a mai simplă și faptul că ne scutește de câteva bătăi de cap legate de ID-ul unei conexiuni și modul de a trata mesajele primite.

const io = require('socket.io')(server,{
    maxHttpBufferSize: 1e7 // 10 MB
});

SocketRoutes(io);

În continuare, vom implementa un protocol de comunicare între clienții care se vor conecta la serverul nostru astfel:

  1. register

  2. joinRoom

  3. sendMove

io.on('connection', (socket) => {
    function addPlayer(socket, index, name){
        connections.push({
            id: socket.id,
            socket:socket,
            index:index,
            name: name,
        });
        return index;
    }
    function register(socket, name){
        let index=connections.length;
        addPlayer(socket, index, name);
        return index;
    }
    socket.on('register', (data) => {
        let index=register(socket, data.name);
        socket.emit('registered', {
            message: 'Welcome!',
            index:index
        });

    });
    socket.on('sendMove', (data) => {
       connections.filter(el=>el.room===data.index)
        .map(player=>{
            if (player.name!==data.name) {
                if (player.socket) {
                    player.socket.emit('sendMove', {
                        y:data.y,
                        name:data.name,
                    });
                } 
            }
        })
    })

    socket.on('joinRoom', (data) => {
        addPlayer(socket, data.room, data.name);
    })
});

Client side

Vom avea două tipuri de clienți: cei care inițiază un joc nou și cei care se alătură unuia existent. Cei din urmă vor putea scana un cod QR și să se alăture unuia existent.

socket = io(((SECURE)?'wss':'ws')+'://'+SERVER_NAME);
socket.on('connect', () => {
    console.log('Connected with ID:', socket.id);
    const params = new URLSearchParams(
      window.location.search);
    const indexParam=params.get('index');
    if (indexParam){
        roomIndex = indexParam;
        socket.emit('joinRoom',{
            room:indexParam,
            name:"Player 2"
        });
    } else {
        socket.emit('register', {
            msg: "Hello from the browser!",
            name:"Player 1"
        });
    }
});

Prin parametrul index, obținut prin scanarea codului QR putem diferenția ușor între cele două tipuri de jucători. Odată ce un jucător înregistrează o cameră nouă de joc, acesta poate să afișeze codul QR ce va codifica adresa serverului și parametrul index:

socket.on("registered", (data) => {
  if (data.index !== undefined) {
    roomIndex = data.index;

    QRCode.toCanvas(
      qrCanvas,
      ((SECURE ? "https" : "http") +
        "://" + SERVER_NAME +"?index=" + data.index))
      .then("canvas draw").catch(error => {
        console.error("canvas error:", error);
      });

    qrText.style.display = "block";
    startGame.style.display = "none";
  }
});

Odată înregistrat, putem transmite mișcările paletelor:

socket.on('sendMove', (data) => {
     if (data){
        if ("Player 1"===data.name){
            paddleLeft.setRemoteBallY(data.y)
        }else {
            paddleRight.setRemoteBallY(data.y)
        }
    }
});

Acestea se vor transmite doar dacă avem un socket activ:

function sendMove(y,name){
    if (y!==undefined && socket && roomIndex){
        //send move
        socket.emit('sendMove',{
            room:roomIndex,
            y:y,
            name:name,
        })
    }
}
function takePic() {
....
     sendMove(paddleLeft.draw(), paddleLeft.name);
     sendMove(paddleRight.draw(), paddleRight.name);
...
}

Dezvoltări ulterioare

Următorii pași vor reprezenta finalizarea gameplay-ului după care putem continua cu o integrare IoT ceea ce ar adăuga un pic de magie. La final, ar fi interesant de realizat un jucător care se joacă și chiar învață din greșelile făcute prin AI.