Web игрa Spy на Node.JS, Angular и Websockets (Часть 4)

Наполняем сценарии 

В этой части мы добавим логику в серверные сценарии. Для начала расширим нашу модель 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:

tmp03

Таким образом, в режиме ожидания, до того как присоединятся все игроки, пользователь будет видеть картинку с вопросами. Когда же присоединится последний игрок, сервер запустит игру, т.е. отправит всем участникам событие '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;

}

Если в комнате больше не осталось игроков, то удаляем и её.

Сейчас самое время проверить результат. Запускаем сервер, открываем три копии браузера (или три вкладки).

тестируем game play

В любом из трёх браузеров кликаем Create Game, затем во всех List Games.

тестируем game play

Игрок который создал игру, автоматически к ней подключен и находится в режиме ожидания. Подключимся к игре из остальных браузеров. по кнопке Join Game. Как только присоединяется последний игрок получаем:

тестируем game play

И вроде-бы всё хорошо, но есть проблема. Когда игрок отключается, его комната не может быть найдена, так как поиск ведется среди открытых комнат, а наша уже закрыта. Для того чтобы это исправить, добавим функцию, которая будет искать по всем комнатам и сделаем небольшой рефакторинг. 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

Download

Небольшие дополнения для клиента:

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');

        }

    });  

Продолжение следует ...

Leave a Reply

Your email address will not be published. Required fields are marked *