Multiplayer mode: server, room tokens, docker setup, and client UI
This commit is contained in:
1 parent
176d7cc300
commit
434c81d0b6
19 files changed
+2563
-382
No files matched your search
@@ -0,0 +1,163 @@
|
||||
'use strict';
|
||||
|
||||
const Multiplayer = (() => {
|
||||
let ws = null;
|
||||
let asHost = false;
|
||||
let gameId = null;
|
||||
let myMark = null;
|
||||
let myTurn = false;
|
||||
let board = Array(9).fill(null);
|
||||
let gameOver = false;
|
||||
let scores = { you: 0, opponent: 0, draws: 0 };
|
||||
|
||||
function connect(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ws = new WebSocket(url);
|
||||
ws.onopen = () => {
|
||||
if (asHost) {
|
||||
ws.send(JSON.stringify({ type: 'host', id: gameId }));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'join', token: gameId }));
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
ws.onerror = (e) => {
|
||||
App.showToast('Connection failed');
|
||||
reject(e);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!gameOver) {
|
||||
App.renderStatus('Connection lost', 'normal');
|
||||
}
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMessage(msg);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'joined':
|
||||
myMark = msg.mark;
|
||||
myTurn = msg.mark === 'X';
|
||||
App.setScoreboardLabels(
|
||||
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
|
||||
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
|
||||
);
|
||||
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
|
||||
break;
|
||||
|
||||
case 'opponent-joined':
|
||||
App.showScreen('game');
|
||||
App.setScoreboardLabels(
|
||||
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
|
||||
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
|
||||
);
|
||||
App.renderStatus('Your turn', 'normal');
|
||||
myTurn = true;
|
||||
break;
|
||||
|
||||
case 'start':
|
||||
board = Array(9).fill(null);
|
||||
gameOver = false;
|
||||
myMark = msg.mark;
|
||||
myTurn = msg.first === myMark;
|
||||
App.setScoreboardLabels(
|
||||
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
|
||||
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
|
||||
);
|
||||
App.clearBoard();
|
||||
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
|
||||
break;
|
||||
|
||||
case 'move': {
|
||||
board[msg.index] = msg.mark;
|
||||
App.renderMark(msg.index, msg.mark);
|
||||
if (!gameOver && msg.mark !== myMark) {
|
||||
myTurn = !myTurn;
|
||||
App.renderStatus(myTurn ? 'Your turn' : 'Opponent is thinking…', myTurn ? 'normal' : 'thinking');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'end':
|
||||
gameOver = true;
|
||||
if (msg.winner === 'draw') {
|
||||
scores.draws++;
|
||||
App.renderStatus("It's a draw", 'normal');
|
||||
document.getElementById('board').classList.add('pulse');
|
||||
} else {
|
||||
const iWon = msg.winner === myMark;
|
||||
scores[iWon ? 'you' : 'opponent']++;
|
||||
App.renderStatus(iWon ? 'You win!' : 'Opponent wins', 'normal');
|
||||
if (!iWon) document.getElementById('board').classList.add('shake');
|
||||
}
|
||||
App.renderScores(scores);
|
||||
if (msg.line) App.drawWinLine(msg.line);
|
||||
setTimeout(() => {
|
||||
const title = msg.winner === 'draw' ? "It's a Draw" : (msg.winner === myMark ? 'You Win!' : 'Opponent Wins');
|
||||
App.showOverlay(title);
|
||||
if (msg.winner === myMark) App.burstConfetti();
|
||||
}, 600);
|
||||
break;
|
||||
|
||||
case 'new-round':
|
||||
board = Array(9).fill(null);
|
||||
gameOver = false;
|
||||
myTurn = msg.first === myMark;
|
||||
App.hideOverlay();
|
||||
App.clearBoard();
|
||||
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
|
||||
break;
|
||||
|
||||
case 'opponent-left':
|
||||
gameOver = true;
|
||||
App.renderStatus('Opponent left', 'error');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
App.showToast(msg.message || 'Error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(i) {
|
||||
if (gameOver || !myTurn || board[i] || myMark === null) return;
|
||||
board[i] = myMark;
|
||||
App.renderMark(i, myMark);
|
||||
ws.send(JSON.stringify({ type: 'move', index: i }));
|
||||
myTurn = false;
|
||||
App.renderStatus('Waiting for opponent…', 'thinking');
|
||||
}
|
||||
|
||||
function newRound() {
|
||||
ws.send(JSON.stringify({ type: 'new-round' }));
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
myMark = null;
|
||||
myTurn = false;
|
||||
board = Array(9).fill(null);
|
||||
gameOver = false;
|
||||
scores = { you: 0, opponent: 0, draws: 0 };
|
||||
}
|
||||
|
||||
function init(opts) {
|
||||
asHost = opts.asHost;
|
||||
gameId = opts.asHost ? opts.id : opts.token;
|
||||
const base = location.pathname === '/' ? '' : location.pathname.replace(/\/$/, '');
|
||||
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + '/ws';
|
||||
connect(wsUrl).catch((err) => {
|
||||
console.error('Connection failed:', err);
|
||||
App.renderStatus('Connection failed', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
return { init, handleMove, newRound, destroy };
|
||||
})();
|
||||
Reference in new issue
Block a user