Наполняем сценарии
В этой части мы добавим логику в серверные сценарии. Для начала расширим нашу модель GameRoom. Добавим в неё поле список игроков (players - все подключенные к текущей игре игроки) и несколько методов: добавление игрока (addPlayer - будет вызываться при подключении к игре), проверка возможности запуска игры (canStart - будет вызываться при каждом подключении нового игрока) и закрытие игровой комнаты (close - когда присоединился последний игрок). Поле isOpen говорит нам о том могут ли подключаться к текущей игре новые игроки или она уже закрыта.
server.js
const gameRoom = {
id: randomPhrase.id,
title: randomPhrase.name,
numberOfPlayers,
isOpen: true,
players: [],
addPlayer: function(player) {
this.players.push(player);
},
canStart: function() {
return this.numberOfPlayers == this.players.length;
},
close: function() {
this.isOpen = false;
}
}
У каждого игрока при подключении будет свой уникальный socket.id. Будем использовать его для связи игрока с игровой комнатой. Добавим для сохранения этой связи функцию:
const createPlayer = (socketId, isOwner, gameRoomId) => {
const gameRoom = getGameRoomById(gameRoomId);
if (!gameRoom) {
return false;
}
if (gameRoom.players.length >= gameRoom.numberOfPlayers) {
return false;
}
gameRoom.addPlayer({
socketId,
isOwner
});
players.set(socketId, gameRoomId);
return true;
}
При создании игрока указываем его id, является ли он создателем игры (это важно для определения того, кто будет запускать следующий раунд, но об этом позже), а также id игровой комнаты для подключения.
Добавим дополнительный параметр socketId в функцию createGameRoom:
socket.on('create-game', (data) => {
const gameRoom = createGameRoom(socket.id, data.numberOfPlayers);
console.log('Game created:', gameRoom.id);
});
А в саму функцию добавим вызов createPlayer(socketId, true, gameRoom.id);
const createGameRoom = (socketId, numberOfPlayers) => {
//get free game id and title
let randomPhrase = {};
do {
randomPhrase = getPhrase();
} while (gameRooms.size && gameRooms.has(randomPhrase.id));
const gameRoom = {
id: randomPhrase.id,
title: randomPhrase.name,
numberOfPlayers,
isOpen: true,
players: [],
addPlayer: function(player) {
this.players.push(player);
},
canStart: function() {
return this.numberOfPlayers == this.players.length;
},
close: function() {
this.isOpen = false;
}
}
gameRooms.set(randomPhrase.id, gameRoom);
//create game owner player and associate it with a gameRoom
createPlayer(socketId, true, gameRoom.id);
return gameRoom;
}
Нам понадобится вспомогательная функция для получения комнаты по её id
const getGameRoomById = (id) => {
for (let value of gameRooms.values()) {
if (value.isOpen && id == value.id) {
return value;
}
}
return null;
}
Теперь самое время для функции подключения нового игрока:
const joinGameRoom = (socketId, gameRoomId) => {
//create game player and associate it with a gameRoom
if (!createPlayer(socketId, false, gameRoomId)) {
return false;
}
return true;
}
И соответствующий обработчик события в вебсокете:
socket.on('join-game-request', (data) => {
if (joinGameRoom(socket.id, data.gameId)) {
const gameRoom = getGameRoomById(data.gameId);
if (!gameRoom) {
socket.emit('join-game-error-response');
return;
}
socket.join(data.gameId);
const canStart = gameRoom.canStart();
//emit this to all clients of the room
io.in(data.gameId).emit('join-game-success-response', {
canStart
});
console.log('Client joined:', socket.id);
//auto start the game
if (canStart) {
const spyId = gameRoom.players[Math.floor(Math.random() * gameRoom.players.length)].socketId;
const spyImg = '/img/spy1.png';
const img = getImage().url;
io.in(data.gameId).emit('start-game', {
spyId,
spyImg,
img
});
gameRoom.close();
console.log('Game started');
}
} else {
socket.emit('join-game-error-response');
console.log('Client not joined:', socket.id);
}
});
При подключении игрока мы также проверяем если можно запускать игру. И если да, то выбираем случайного игрока в качестве шпиона и случайную картинку и отправляем всем игрокам комнаты событие 'start-game'. И закрываем комнату.
ВАЖНО: socket.join(data.gameId); создаёт вебсокет room (channel) канал с тем же названием, что и id игровой комнаты. Таким образом для каждой игровой комнаты будет создан свой канал. Далее мы будем использовать этот канал, чтобы отправлять сообщения всем игрокам нашей комнаты, таким образом: io.in(data.gameId).emit('start-game', {…})
Подробнее о канал и их использовании можно посмотреть здесь: https://socket.io/docs/v3/emit-cheatsheet/
Перейдём к клиентской части и добавим контейнер для случайной картинки. index.html
<div id="image-wrapper">
<img id="player-image" src="/img/qm.jpg" />
</div>
В директории public я создал директорию img и скопировал туда две картинки qm.jpg и spy1.png:
Таким образом, в режиме ожидания, до того как присоединятся все игроки, пользователь будет видеть картинку с вопросами. Когда же присоединится последний игрок, сервер запустит игру, т.е. отправит всем участникам событие 'start-game' с параметрами в виде объекта:
{
spyId – socket ID случайного игрока в качестве шпиона
spyImg – локальный url, картинка шпиона (public/img/spy1.png)
img – url случайной картинки (из json файла /data/images.json)
}
Добавим на клиенте обработчик события 'start-game'. client.js
socket.on('start-game', (data) => {
console.log('Starting the game');
const playerImg = data.spyId == socket.id ? data.spyImg : data.img;
$('#player-image').attr('src', playerImg);
});
Здесь можно было бы посмотреть, что же получилось, но давайте добавим еще обработку события когда игрок отключается, или выходит из комнаты.
На сервере server.js
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
removePlayer(socket.id);
});
const removePlayer = (socketId) => {
const gameRoomId = players.get(socketId);
if (!gameRoomId) {
return false;
}
const gameRoom = getGameRoomById(gameRoomId);
if (!gameRoom) {
return false;
}
//this is the only one player left, so can remove entire room
if (gameRoom.players.length == 1 && gameRoom.players[0].socketId == socketId) {
//remove the game room
gameRooms.delete(gameRoom.id);
} else {
gameRoom.removePlayer(socketId);
}
players.delete(socketId);
return true;
}
Если в комнате больше не осталось игроков, то удаляем и её.
Сейчас самое время проверить результат. Запускаем сервер, открываем три копии браузера (или три вкладки).
В любом из трёх браузеров кликаем Create Game, затем во всех List Games.
Игрок который создал игру, автоматически к ней подключен и находится в режиме ожидания. Подключимся к игре из остальных браузеров. по кнопке Join Game. Как только присоединяется последний игрок получаем:
И вроде-бы всё хорошо, но есть проблема. Когда игрок отключается, его комната не может быть найдена, так как поиск ведется среди открытых комнат, а наша уже закрыта. Для того чтобы это исправить, добавим функцию, которая будет искать по всем комнатам и сделаем небольшой рефакторинг. server.js
const getGameRoomById = (id) => {
for (let value of gameRooms.values()) {
if (id == value.id) {
return value;
}
}
return null;
}
const getOpenGameRoomById = (id) => {
const gameRoom = getGameRoomById(id);
return gameRoom?.isOpen ? gameRoom : null;
}
Заменим getGameRoomById на getOpenGameRoomById в
const createPlayer = (socketId, isOwner, gameRoomId) => {
const gameRoom = getOpenGameRoomById(gameRoomId);
socket.on('join-game-request', (data) => {
if (joinGameRoom(socket.id, data.gameId)) {
const gameRoom = getOpenGameRoomById(data.gameId);
Next Turn сценарий
Игрок создавший игру, считается её owner-ом. Ему будет доступна кнопка "Next turn", для следующего раунда. В каждом новом раунде, owner-ом игры становится шпион, т.е. запускать следующий раунд каждый раз будет шпион из текущего раунда.
Перейдём в server.js и подкорректируем 'join-game-request'
socket.on('join-game-request', (data) => {
…
//auto start the game
if (canStart) {
const spyPlayer = gameRoom.players[Math.floor(Math.random() * gameRoom.players.length)];
const ownerPlayer = gameRoom.players.find(i=>i.isOwner);
const spyId = spyPlayer.socketId;
const spyImg = '/img/spy1.png';
const img = getImage().url;
const ownerId = ownerPlayer.socketId;
// swap owner, assign it to spy, for the next turn
ownerPlayer.isOwner = false;
spyPlayer.isOwner = true;
io.in(data.gameId).emit('start-game', {
spyId,
spyImg,
img,
ownerId
});
…
Сейчас мы отправляем ownerID каждому игроку. Добавим на клиенте невидимую кнопку для следующего раунда. index.html
<div>
<button id="next-turn-btn" style="display: none;">Next turn</button>
</div>
В client.js добавим в обработчик события 'start-game' условное отображение либо скрытие этой кнопки:
socket.on('start-game', (data) => {
console.log('Starting the game');
const playerImg = data.spyId == socket.id ? data.spyImg : data.img;
const isOwner = data.ownerId == socket.id;
$('#player-image').attr('src', playerImg);
if (isOwner) {
$('#next-turn-btn').show();
} else {
$('#next-turn-btn').hide();
}
});
И сам обработчик события по этой кнопке:
$('#next-turn-btn').on('click', function(e){
e.preventDefault();
console.log(`Next turn ...`);
socket.emit('next-turn-request');
});
Функционал для серверного события next-turn, будет очень похож на start-game, поэтому сделаем небольшой рефакторинг.
Вынесем код start-game в отдельную функцию, назовём её play:
const play = gameRoom => {
const spyPlayer = gameRoom.players[Math.floor(Math.random() * gameRoom.players.length)];
const ownerPlayer = gameRoom.players.find(i=>i.isOwner);
const spyId = spyPlayer.socketId;
const spyImg = '/img/spy1.png';
const img = getImage().url;
const ownerId = ownerPlayer.socketId;
// swap owner, assign it to spy, for the next turn
ownerPlayer.isOwner = false;
spyPlayer.isOwner = true;
io.in(gameRoom.id).emit('start-game', {
spyId,
spyImg,
img,
ownerId
});
}
И соответствующий вызов:
socket.on('join-game-request', (data) => {
…
//auto start the game
if (canStart) {
play(gameRoom);
gameRoom.close();
console.log('Game started');
}
…
Сейчас можно сосредоточиться на функционале для следующего раунда.
Получаем игровую комнату, которой принадлежит игрок, и используем уже готовый функционал play.
const runNextTurn = socketId => {
// get palyer's gameRoom
console.log('Next turn, client ID:', socketId);
const gameRoomId = players.get(socketId);
if (!gameRoomId) {
return false;
}
const gameRoom = getGameRoomById(gameRoomId);
if (!gameRoom) {
return false;
}
play(gameRoom);
}
socket.on('next-turn-request', () => {
runNextTurn(socket.id);
});
Исходники того, что было сделано до сих пор: espia.v1.20250516.7z
Небольшие дополнения для клиента:
socket.on('create-game-response', (data) => {
console.log('Game created=',data);
});
Небольшие дополнения для сервера:
socket.on('create-game', (data) => {
const gameRoom = createGameRoom(socket.id, data.numberOfPlayers);
// join channel
if (gameRoom) {
socket.join(gameRoom.id);
console.log('Game created:', gameRoom.id);
socket.emit('create-game-response', {
gameRoom
})
} else {
console.error('Could not create Game');
}
});
Продолжение следует ...
