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

Интеграция Socket IO

Для работы с вебсокетами, как мы уже говорили, существует библиотека socket.io. Мы её использовали в предыдущей версии фронтэнда на jquery. Так же у них есть пакет для использования с Angular-ом. Установим его:

npm install ngx-socket-io

Для лучшей организации кода, все запросы, которые будем отправлять на сервер через вебсокеты, будем хранить в отдельном сервисе. Назовём его socket.service и создадим:

ng generate service socket.service

Сгенерированные файлы перенесём в frontend/services/socket

Минимальный рекомендованный 'конфиг':

    // src/app/socket.service.ts

    import { Injectable } from '@angular/core';

    import { io, Socket } from 'socket.io-client';

    import { Observable } from 'rxjs';


    @Injectable({

      providedIn: 'root'

    })

    export class SocketService {

      private socket: Socket;


      constructor() {

        this.socket = io('http://localhost:3000'); // Replace with your server URL

      }


      // Emit an event to the server

      emit(eventName: string, data: any): void {

        this.socket.emit(eventName, data);

      }


      // Listen for an event from the server

      on(eventName: string): Observable<any> {

        return new Observable(observer => {

          this.socket.on(eventName, (data: any) => {

            observer.next(data);

          });

        });

      }


      // Handle disconnection

      onDisconnect(): Observable<any> {

        return new Observable(observer => {

          this.socket.on('disconnect', (reason: string) => {

            observer.next(reason);

          });

        });

      }

    }

Начнём добавлять динамику в нашу вёрстку. Страница создания игры, шаблон компонента: create-game-page.html

 - добавим директиву ngModel для того чтобы связать поле ввода количества игроков с переменной компонента numberOfPlayers

 - добавим обработчик события для кнопки CREATE

        <input class="w-72 max-w-full py-4 border-2 border-white rounded-lg text-white text-xl font-semibold tracking-wide transition hover:bg-white hover:text-black text-center"

               placeholder="NUMBER OF PLAYERS"  [(ngModel)]="numberOfPlayers" />

        <button class="w-72 max-w-full py-4 border-2 border-white rounded-lg text-white text-xl font-semibold tracking-wide transition hover:bg-white hover:text-black"

                (click)="createGame()">

            CREATE

        </button>

Код компонента (контроллер): create-game-page.ts

import { Component, inject } from '@angular/core';

import { Router } from '@angular/router';

import { OnInit, OnDestroy } from '@angular/core';

import { SocketService } from '../services/socket/socket.service';

import { Subscription } from 'rxjs';

import { FormsModule } from '@angular/forms';


@Component({

  selector: 'app-create-game-page',

  imports: [FormsModule],

  templateUrl: './create-game-page.html',

  styleUrl: './create-game-page.css'

})


export class CreateGamePage implements OnInit, OnDestroy {

  private router = inject(Router);

  private messageSubscription: Subscription = new Subscription();


  numberOfPlayers: number = 3;


  constructor(private socketService: SocketService) {}


  ngOnInit(): void {

    

    this.messageSubscription = this.socketService.on('create-game-response').subscribe((data: any) => {

      console.log('Game created=',data);

      this.router.navigate(['/waiting-others-to-join']);

    });


  }


  ngOnDestroy(): void {

    if (this.messageSubscription) {

      this.messageSubscription.unsubscribe();

    }

  }


  createGame(): void {

    console.log('creating game...');


    this.socketService.emit('create-game', {

        numberOfPlayers: this.numberOfPlayers

    });

  }

}

Здесь мы используем subscription, для подписки на серверное событие create-game-response и рутер, для перенаправления на страницу ожидания: router.navigate(). Пока что количество игроков жестко прописано как 3.

Если бэк и фронт запускаются на разных серверах, то может возникнуть проблема из-за CORS. Чтобы её решить надо добавить опции для сервера.

const io = new Server(server, {

  cors: {

    origin: "*",

    methods: ["GET", "POST"]

  }

});

Проверим если это работает. В одном терминале запускаем бэк, в другом фронт. Жмём кнопку Create Game.

test routing 01
test routing 02

Как видно соединение вебсокета установлено, новая игра с id 76 создана, а также произошло перенаправление на страницу ожидания.

Навигация

Чуть выше мы уже видели принудительный (программный) переход по url при помощи router.navigate(). Сейчас рассмотрим случай навигации по ссылкам. Как раз то что нам необходимо для перехода с главной страницы на страницу создания новой игры или присоединения к существующей. Для создания ссылок используется RouterLink. Добавим его в main-page.ts

import { RouterLink } from '@angular/router';

@Component({

  selector: 'app-main-page',

  imports: [RouterLink],

И ссылки по кнопкам в main-page.html

    <div class="flex-1 flex flex-col items-center justify-start gap-6 mt-8">

      <button routerLink="/create-game" class="w-72 max-w-full py-4 border-2 border-white rounded-lg text-white text-xl font-semibold tracking-wide transition hover:bg-white hover:text-black">

        CREATE A GAME

      </button>

      <button routerLink="/join-game" class="w-72 max-w-full py-4 border-2 border-white rounded-lg text-white text-xl font-semibold tracking-wide transition hover:bg-white hover:text-black">

        JOIN GAME

      </button>

Управление состоянием (state management)

При создании новой игры мы как-то должны передавать id и title из одного компонента в другой: create-game-page -> waiting-others-to-join-page. Будем использовать объект с полями {id, title}. Самый простой способ - это injectable сервис. Создадим его:

ng g service services/game-state/game-state.service --skip-tests

Содержимое game-state.service.ts

import { Injectable } from '@angular/core';


export interface GameInterface {

  id: number;

  title: string;

  context: any;

}


@Injectable({

  providedIn: 'root'

})


export class GameStateService {

  private data: any;


  setData(data: GameInterface): void {

    this.data = data;

  }


  getData(): GameInterface {

    return this.data;

  }

}

Тут мы определили интерфейс GameInterface с полями: id, title и context. Объект data для хранения данных, сеттер и геттер.

Забегая немного вперёд, скажу что в поле context мы будем хранить ответ серверного события ‘start-game’, для того чтобы передать его из компонента waiting-others-to-join-page в компонент game-page.

Добавим (inject) этот сервис в компоненты где он нужен. Начнём с create-game-page.ts

import { GameStateService } from '../services/game-state/game-state.service';

  constructor(private socketService: SocketService, private gameStateService: GameStateService) {}


  ngOnInit(): void {

    

    this.messageSubscription = this.socketService.on('create-game-response').subscribe((data: any) => {

      console.log('Game created=',data);

      //set state

      const gameRoom = data.gameRoom;

      this.gameStateService.setData({

        id: gameRoom.id,

        title: gameRoom.title,

        context: null

      });

waiting-others-to-join-page.ts

import { GameInterface, GameStateService } from '../services/game-state/game-state.service';

  gameData: GameInterface;

  constructor(private socketService: SocketService, private gameStateService: GameStateService) {

    this.gameData = gameStateService.getData();

    console.log('Game state',this.gameData);

  }

    this.messageSubscription = this.socketService.on('start-game').subscribe((data: any) => {

      console.log('Starting the game');

      this.gameData.context = data;

      //this.gameStateService.setData(this.gameData);

      //change page

      this.router.navigate(['/game']);

    });

join-game-page.ts

import { Component, inject } from '@angular/core';

import { OnInit, OnDestroy } from '@angular/core';

import { Router } from '@angular/router';

import { Subscription } from 'rxjs';

import { SocketService } from '../services/socket/socket.service';

import { GameInterface, GameStateService } from '../services/game-state/game-state.service';


@Component({

  selector: 'app-join-game-page',

  imports: [],

  templateUrl: './join-game-page.html',

  styleUrl: './join-game-page.css'

})

export class JoinGamePage implements OnInit, OnDestroy {

  private router = inject(Router);

  private listGamesSubscription: Subscription = new Subscription();

  private joinGameSubscription: Subscription = new Subscription();

  private joinGameErrorSubscription: Subscription = new Subscription();

  games: any;


  constructor(private socketService: SocketService, private gameStateService: GameStateService) {}


  ngOnInit(): void {

    

    /**

     * Render a list of the games after 

     * server response to list-games-request

     */

    this.listGamesSubscription = this.socketService.on('list-games-response').subscribe((data: any) => {

      console.log('listing-games-from-server', data);

      this.games = data.games;

    });


    /**

     * User succesfully joined the game

     * server response to join-game-request

     */

    this.joinGameSubscription = this.socketService.on('join-game-success-response').subscribe((data: any) => {

      console.log('Joined, canStart='+data.canStart);

      //set auth token in order to have for autorejoin after reconnection on server side

      this.socketService.setAuth({

        gameRoomId: this.gameStateService.getData().id

      });

      //if canStart then back tick with autostart

      if (data.canStart) {

          console.log('Get ready...');

          //consider navigating directly to game page from here, not through waiting for others

          //TODO

      }

      //change page

      this.router.navigate(['/waiting-others-to-join']);

    });

    

    /**

     * Handle error joining the game

     * server response to join-game-request

     */

    this.joinGameErrorSubscription = this.socketService.on('join-game-error-response').subscribe((data: any) => {

      console.error('Could not join', data);

      //TODO handle

    });


    //list available games to join to

    this.socketService.emit('list-games-request', null);

  }


  ngOnDestroy(): void {

    if (this.listGamesSubscription) {

      this.listGamesSubscription.unsubscribe();

    }

    if (this.joinGameSubscription) {

      this.joinGameSubscription.unsubscribe();

    }

    if (this.joinGameErrorSubscription) {

      this.joinGameErrorSubscription.unsubscribe();

    }

  }


  joinGame(gameRoom: GameInterface): void {

    console.log('selected game', gameRoom);

    //send join request to serve

    this.socketService.emit('join-game-request', {

      gameId: gameRoom.id

    });


    //set state and navigate to waiting-for-others

    this.gameStateService.setData({

      id: gameRoom.id,

      title: gameRoom.title,

      context: null

    });

    //wait for server response: join-game-success-response | join-game-error-response   

  }


}

Присоединение к игре

До сих пор на странице присоединения к игре у нас была одна жестко прописанная в вёрстке игра. Пришло время исправим это. Будем отображать динамически сформированный список доступных игр, полученный с сервера. Начнём с шаблона join-game-page.html

        <ul>

          @for (item of games; track item.id) {

            <li class="w-80 max-w-full text-white text-center text-lg tracking-widest mt-2 font-bold uppercase cursor-pointer"

                (click)="joinGame(item)">{{ item.title }}</li>

          }          

        </ul>

Как видно из кода, при помощи @for итерируем массив games, генерируя объекты <li>

Игра

game-page.ts

import { GameInterface, GameStateService } from '../services/game-state/game-state.service';

import { Component, inject } from '@angular/core';

import { OnInit, OnDestroy } from '@angular/core';

import { Router } from '@angular/router';

import { Subscription } from 'rxjs';

import { SocketService } from '../services/socket/socket.service';


@Component({

  selector: 'app-game-page',

  imports: [],

  templateUrl: './game-page.html',

  styleUrl: './game-page.css'

})

export class GamePage implements OnInit, OnDestroy {

  private router = inject(Router);

  private messageSubscription: Subscription = new Subscription();

  gameData: GameInterface;

  imgUrl: string;

  isOwner: boolean;


  constructor(private socketService: SocketService, private gameStateService: GameStateService) {

    this.gameData = this.gameStateService.getData();

    this.imgUrl = "";

    this.isOwner = false;

  }


  ngOnInit(): void {

    this.messageSubscription = this.socketService.on('start-game').subscribe((data: any) => {

      console.log('Next round');

      this.gameData.context = data;

      this.startNewGame();

    });


    this.startNewGame();    

  }


  startNewGame(): void {

    console.log('Starting the game');

    this.gameData = this.gameStateService.getData();

    const data = this.gameData.context; 

    const socketId = this.socketService.getSocketId();

    this.imgUrl = data.spyId == socketId ? data.spyImg : data.img;

    this.isOwner = data.ownerId == socketId;

  }


  ngOnDestroy(): void {

    if (this.messageSubscription) {

      this.messageSubscription.unsubscribe();

    }

  }


  nextRound(): void {

    this.socketService.emit('next-turn-request', null);

  }  

}

Тут мы определили основную функцию startNewGame, которая как и в предыдущей версии на jQuery определяет картинку и owner-а текущего раунда. При инициализации компонента подписываемся на серверное событие  start-game и запускаем игру.

В game-page.html добавим динамический src для картинки и условное отображение для кнопки Next round

    <!-- Middle Block: Title (fixed height) -->

    <div class="flex flex-col items-center justify-center p-8">

        <img [src]="imgUrl" alt="Random image" 

        class="shadow-lg object-cover border-2 border-white rounded-lg" />

    </div>

    <!-- Bottom Block: Buttons -->

    <div class="flex-1 flex flex-col items-center justify-start gap-6 mt-8">

        @if (isOwner) {

        <button class="w-72 max-w-full py-4 border-2 border-white rounded-lg text-white text-xl font-semibold tracking-wide transition hover:bg-white hover:text-black"

                (click)="nextRound()">

            NEXT ROUND

        </button>

        }

    </div>

Всё то о чём мы говорили до сих пор можно скачать по кнопке ниже. Директории node-modules для бэкэнда и фронтэнда я удалил из архива, поэтому надо будет выполнить: node -i

Download

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

Leave a Reply

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