MOODUL 09 · TASE 3 — KESKMINE

🌐 Multiplayer ja Networking

WebSocket'id, Socket.io, klient-server arhitektuur, latentsuse kompenseerimine — ehita mängud mida saab koos mängida!

⏱️ ~6-8 tundi 📝 6 harjutust 🟠 Keskmine

🔌 Samm 1 — WebSocket'i Põhialused

server.js — WebSocket server
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 3000 });
const players = new Map();

wss.on('connection', (ws) => {
  const playerId = crypto.randomUUID();
  players.set(playerId, {
    id: playerId, x: 400, y: 300, color: randomColor()
  });

  // Saada mängija oma ID
  ws.send(JSON.stringify({
    type: 'init',
    id: playerId,
    players: Object.fromEntries(players)
  }));

  // Teata teistele uuest mängijast
  broadcast({ type: 'playerJoin', player: players.get(playerId) }, ws);

  ws.on('message', (data) => {
    const msg = JSON.parse(data);

    if (msg.type === 'move') {
      const player = players.get(playerId);
      if (player) {
        player.x = msg.x;
        player.y = msg.y;
        // Saada kõigile teistele
        broadcast({ type: 'playerMove', id: playerId, x: msg.x, y: msg.y }, ws);
      }
    }
  });

  ws.on('close', () => {
    players.delete(playerId);
    broadcast({ type: 'playerLeave', id: playerId });
  });
});

function broadcast(msg, exclude = null) {
  const data = JSON.stringify(msg);
  wss.clients.forEach(client => {
    if (client !== exclude && client.readyState === 1) {
      client.send(data);
    }
  });
}

function randomColor() {
  return `hsl(${Math.random() * 360}, 70%, 60%)`;
}

console.log('Server käivitatud portil 3000');

🖥️ Samm 2 — Klient

client.js — WebSocket klient
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const ws = new WebSocket('ws://localhost:3000');

let myId = null;
const players = new Map();

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  switch (msg.type) {
    case 'init':
      myId = msg.id;
      Object.entries(msg.players).forEach(([id, p]) => players.set(id, p));
      break;

    case 'playerJoin':
      players.set(msg.player.id, msg.player);
      break;

    case 'playerMove':
      const player = players.get(msg.id);
      if (player) {
        player.targetX = msg.x;  // Interpolatsiooni sihtmärk
        player.targetY = msg.y;
      }
      break;

    case 'playerLeave':
      players.delete(msg.id);
      break;
  }
};

// Klaviatuur
const keys = {};
window.addEventListener('keydown', (e) => keys[e.key] = true);
window.addEventListener('keyup', (e) => keys[e.key] = false);

function update() {
  const me = players.get(myId);
  if (!me) return;

  const speed = 5;
  let moved = false;

  if (keys['w'] || keys['ArrowUp'])    { me.y -= speed; moved = true; }
  if (keys['s'] || keys['ArrowDown'])  { me.y += speed; moved = true; }
  if (keys['a'] || keys['ArrowLeft'])  { me.x -= speed; moved = true; }
  if (keys['d'] || keys['ArrowRight']) { me.x += speed; moved = true; }

  if (moved) {
    ws.send(JSON.stringify({ type: 'move', x: me.x, y: me.y }));
  }

  // Interpolatsioon teiste mängijate positsioonidele
  for (const [id, p] of players) {
    if (id !== myId && p.targetX !== undefined) {
      p.x += (p.targetX - p.x) * 0.2;
      p.y += (p.targetY - p.y) * 0.2;
    }
  }
}

function draw() {
  ctx.fillStyle = '#0f172a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  for (const [id, p] of players) {
    ctx.beginPath();
    ctx.arc(p.x, p.y, 15, 0, Math.PI * 2);
    ctx.fillStyle = p.color || '#22c55e';
    ctx.fill();

    // Oma mängija äärejoon
    if (id === myId) {
      ctx.strokeStyle = '#ffffff';
      ctx.lineWidth = 2;
      ctx.stroke();
    }
  }
}

function gameLoop() {
  update();
  draw();
  requestAnimationFrame(gameLoop);
}
gameLoop();

🚀 Samm 3 — Socket.io (Lihtsam Alternatiiv)

Socket.io server + klient
// SERVER (server.js)
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';

const app = express();
const http = createServer(app);
const io = new Server(http, { cors: { origin: '*' } });

app.use(express.static('public'));

const gameState = { players: {} };

io.on('connection', (socket) => {
  console.log(`Mängija ühines: ${socket.id}`);

  gameState.players[socket.id] = {
    x: 400, y: 300,
    color: `hsl(${Math.random() * 360}, 70%, 60%)`
  };

  socket.emit('init', { id: socket.id, state: gameState });
  socket.broadcast.emit('playerJoin', {
    id: socket.id, ...gameState.players[socket.id]
  });

  socket.on('move', (data) => {
    if (gameState.players[socket.id]) {
      gameState.players[socket.id].x = data.x;
      gameState.players[socket.id].y = data.y;
      socket.broadcast.emit('playerMove', { id: socket.id, ...data });
    }
  });

  socket.on('disconnect', () => {
    delete gameState.players[socket.id];
    io.emit('playerLeave', { id: socket.id });
  });
});

http.listen(3000, () => console.log('Server käivitatud: http://localhost:3000'));

// KLIENT (public/game.js)
// const socket = io();
// socket.on('init', (data) => { ... });
// socket.on('playerMove', (data) => { ... });
// socket.emit('move', { x: player.x, y: player.y });

⚡ Samm 4 — Latentsuse Kompenseerimine

⚠️

Networking'i raskaimad probleemid:

  • Latentsus (lag): Sõnum võtab aega — client-side prediction aitab
  • Paketikadu: Mõned sõnumid ei jõua kohale — interpolatsioon silub probleemi
  • Sünkroniseerimine: Erinevad kliendid näevad erinevat olekut — server on autoriteetne
Interpolatsioon ja prediction
// Client-side prediction — liiguta kohe, server valideerib hiljem
function handleInput(input) {
  // 1. Rakenda kohe lokaalselt
  applyInput(localPlayer, input);
  
  // 2. Salvesta sisendihistooria
  inputHistory.push({ seq: sequenceNumber++, input, state: {...localPlayer} });
  
  // 3. Saada serverile
  socket.emit('input', { seq: sequenceNumber, input });
}

// Server kinnitab — kui erinev, paranda
socket.on('serverState', (state) => {
  // Keera tagasi viimase kinnitatud olekuni
  localPlayer.x = state.x;
  localPlayer.y = state.y;
  
  // Rakenda uuesti kõik kinnitamata sisendid
  const unacked = inputHistory.filter(i => i.seq > state.lastProcessedSeq);
  for (const entry of unacked) {
    applyInput(localPlayer, entry.input);
  }
});

// Entity interpolation — teised mängijad
class RemotePlayer {
  constructor() {
    this.posHistory = []; // [{time, x, y}]
    this.renderDelay = 100; // ms
  }

  addPosition(x, y) {
    this.posHistory.push({ time: Date.now(), x, y });
    if (this.posHistory.length > 10) this.posHistory.shift();
  }

  getPosition() {
    const renderTime = Date.now() - this.renderDelay;
    // Leia kaks positsiooni mille vahel interpoleerida
    for (let i = 1; i < this.posHistory.length; i++) {
      const a = this.posHistory[i - 1];
      const b = this.posHistory[i];
      if (renderTime >= a.time && renderTime <= b.time) {
        const t = (renderTime - a.time) / (b.time - a.time);
        return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) };
      }
    }
    return this.posHistory[this.posHistory.length - 1] || { x: 0, y: 0 };
  }
}

📝 Harjutused

  • WebSocket chat

    Loo lihtne WebSocket chat server + klient. Mitu brauseri akent saavad sõnumeid saata ja vastu võtta. Lisa kasutajanimed ja ajatemplid.

  • Multiplayer liikumine

    Loo server mis haldab mängijate positsioone. Kliendid liiguvad WASD-ga, näevad teisi reaalajas. Lisa interplolaitsioon sujuvaks kuvamiseks.

  • Socket.io mänguruum

    Loo Socket.io-ga mänguroomide süsteem: mängijad saavad luua ja liituda ruumidega. Iga ruum on eraldi mäng. Lisa mängijate nimekiri ruumis.

  • Server autoriteetne mudel

    Liiguta mänguloogika serverile — klient saadab ainult sisendid, server arvutab positsiooni ja saadab tulemused. Tee vahet kliendi ja serveri olekul.

  • Client-side prediction

    Implementeeri client-side prediction: liiguta mängijat kohe, saada input serverile, paranda kui server erineb. Lisa kunstlik lag testimiseks (setTimeout).

  • Multiplayer mäng

    Ehita täielik multiplayer mäng: 2-4 mängijat, tulistamine, skoor, mänguroomid. Server valideerib kõik. Deploy Render/Railway platvormile.

← Moodul 08b Moodul 09b: Protseduurne →