From cb83c820418768da1ae60fcef8181e8ad4cf9eb7 Mon Sep 17 00:00:00 2001 From: jiyun Date: Tue, 19 Dec 2023 18:08:49 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20game=20invi=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=8B=9C=20reponse=20=EB=93=B1,=20gameGateway=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/events.ts | 6 +- src/game/dto/create-game-param.dto.ts | 40 ++- src/game/dto/create-initial-game-param.dto.ts | 42 +++ src/game/dto/delete-invitations-param.dto.ts | 4 - ... => emit-event-invitation-reaponse.dto.ts} | 2 +- .../dto/emit-event-match-score-param.dto.ts | 4 + src/game/dto/emit-event-match-status.dto.ts | 14 + src/game/dto/game.dto.ts | 42 ++- .../dto/gateway-join-game-room-param.dto.ts | 5 - src/game/dto/view-map.dto.ts | 204 ++++++++++++-- src/game/game.controller.ts | 2 +- src/game/game.gateway.ts | 263 ++++++++++++++++-- src/game/game.repository.ts | 3 +- src/game/game.service.ts | 83 +++--- 14 files changed, 590 insertions(+), 124 deletions(-) create mode 100644 src/game/dto/create-initial-game-param.dto.ts delete mode 100644 src/game/dto/delete-invitations-param.dto.ts rename src/game/dto/{gateway-send-invitation-reaponse.dto.ts => emit-event-invitation-reaponse.dto.ts} (70%) create mode 100644 src/game/dto/emit-event-match-score-param.dto.ts create mode 100644 src/game/dto/emit-event-match-status.dto.ts delete mode 100644 src/game/dto/gateway-join-game-room-param.dto.ts diff --git a/src/common/events.ts b/src/common/events.ts index 0e0cec1..f1ed9e7 100644 --- a/src/common/events.ts +++ b/src/common/events.ts @@ -6,4 +6,8 @@ export const EVENT_GAME_INVITATION = 'gameInvitation'; export const EVENT_GAME_INVITATION_REPLY = 'gameInvitationReply'; -export const EVENT_GAME_READY = 'gameReady'; +export const EVENT_GAME_START = 'gameStart'; + +export const EVENT_MATCH_SCORE = 'matchScore'; + +export const EVENT_MATCH_STATUS = 'matchStatus'; diff --git a/src/game/dto/create-game-param.dto.ts b/src/game/dto/create-game-param.dto.ts index d6d5d54..bc1f7b7 100644 --- a/src/game/dto/create-game-param.dto.ts +++ b/src/game/dto/create-game-param.dto.ts @@ -1,4 +1,5 @@ import { GameStatus, GameType } from '../../common/enum'; +import { GameDto } from './game.dto'; export class CreateGameParamDto { winnerId: number; @@ -10,28 +11,21 @@ export class CreateGameParamDto { ballSpeed: number; racketSize: number; - constructor(player1Id: number, player2Id: number, gameType: GameType) { - this.winnerId = player1Id; - this.loserId = player2Id; - this.gameType = gameType; - this.winnerScore = 0; - this.loserScore = 0; - this.gameStatus = GameStatus.WAITING; - if ( - this.gameType === GameType.SPECIAL_INVITE || - this.gameType === GameType.SPECIAL_MATCHING - ) { - this.ballSpeed = this.getRandomNumber(1, 3); - this.racketSize = this.getRandomNumber(1, 3); - } else { - // Set to 1 for NORMAL - this.ballSpeed = 1; - this.racketSize = 1; - } - } - - private getRandomNumber(min: number, max: number): number { - // Generate a random integer between min and max (inclusive) - return Math.floor(Math.random() * (max - min + 1)) + min; + constructor( + gameDto: GameDto, + winnerId: number, + loserId: number, + winnerScore: number, + loserScore: number, + gameStatus: GameStatus, + ) { + this.winnerId = winnerId; + this.loserId = loserId; + this.gameType = gameDto.gameType; + this.winnerScore = winnerScore; + this.loserScore = loserScore; + this.gameStatus = gameStatus; + this.ballSpeed = gameDto.viewMap.ballSpeed; + this.racketSize = gameDto.viewMap.racketSize; } } diff --git a/src/game/dto/create-initial-game-param.dto.ts b/src/game/dto/create-initial-game-param.dto.ts new file mode 100644 index 0000000..0bfda59 --- /dev/null +++ b/src/game/dto/create-initial-game-param.dto.ts @@ -0,0 +1,42 @@ +import { GameStatus, GameType } from '../../common/enum'; + +export class CreateInitialGameParamDto { + winnerId: number; + loserId: number; + gameType: GameType; + winnerScore: number; + loserScore: number; + gameStatus: GameStatus; + ballSpeed: number; + racketSize: number; + + constructor( + player1Id: number, + player2Id: number, + gameType: GameType, + gameStatus: GameStatus, + ) { + this.winnerId = player1Id; + this.loserId = player2Id; + this.gameType = gameType; + this.winnerScore = 0; + this.loserScore = 0; + this.gameStatus = gameStatus; + if ( + this.gameType === GameType.SPECIAL_INVITE || + this.gameType === GameType.SPECIAL_MATCHING + ) { + this.ballSpeed = this.getRandomNumber(1, 3); + this.racketSize = this.getRandomNumber(1, 3); + } else { + // Set to 1 for NORMAL + this.ballSpeed = 1; + this.racketSize = 1; + } + } + + private getRandomNumber(min: number, max: number): number { + // Generate a random integer between min and max (inclusive) + return Math.floor(Math.random() * (max - min + 1)) + min; + } +} diff --git a/src/game/dto/delete-invitations-param.dto.ts b/src/game/dto/delete-invitations-param.dto.ts deleted file mode 100644 index 957004f..0000000 --- a/src/game/dto/delete-invitations-param.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class DeleteGameInvitationsParamDto { - invitingUserId: number; - invitedUserId: number; -} diff --git a/src/game/dto/gateway-send-invitation-reaponse.dto.ts b/src/game/dto/emit-event-invitation-reaponse.dto.ts similarity index 70% rename from src/game/dto/gateway-send-invitation-reaponse.dto.ts rename to src/game/dto/emit-event-invitation-reaponse.dto.ts index c5def15..dafeef5 100644 --- a/src/game/dto/gateway-send-invitation-reaponse.dto.ts +++ b/src/game/dto/emit-event-invitation-reaponse.dto.ts @@ -1,4 +1,4 @@ -export class GatewaySendInvitationReplyDto { +export class EmitEventInvitationReplyDto { targetUserId: number; targetUserChannelSocketId: string; isAccepted: boolean; diff --git a/src/game/dto/emit-event-match-score-param.dto.ts b/src/game/dto/emit-event-match-score-param.dto.ts new file mode 100644 index 0000000..73dbb70 --- /dev/null +++ b/src/game/dto/emit-event-match-score-param.dto.ts @@ -0,0 +1,4 @@ +export class EmitEventMatchScoreParamDto { + myScore: number; + rivalScore: number; +} diff --git a/src/game/dto/emit-event-match-status.dto.ts b/src/game/dto/emit-event-match-status.dto.ts new file mode 100644 index 0000000..689e0f0 --- /dev/null +++ b/src/game/dto/emit-event-match-status.dto.ts @@ -0,0 +1,14 @@ +export class EmitEventMatchStatusDto { + myRacket: { + x: number; + y: number; + }; + rivalRacket: { + x: number; + y: number; + }; + ball: { + x: number; + y: number; + }; +} diff --git a/src/game/dto/game.dto.ts b/src/game/dto/game.dto.ts index 18bd9f3..364d1ed 100644 --- a/src/game/dto/game.dto.ts +++ b/src/game/dto/game.dto.ts @@ -1,33 +1,55 @@ -import { GameStatus } from '../../common/enum'; +import { GameStatus, GameType } from '../../common/enum'; import { Game } from '../entities/game.entity'; import { ViewMapDto } from './view-map.dto'; export class GameDto { private gameId: number; - player1Id: number; - player2Id: number; + playerLeftId: number; + playerRightId: number; winnerId: number | null; loserId: number | null; - score1: number; - score2: number; + scoreLeft: number; + scoreRight: number; gameStatus: GameStatus; + gameType: GameType; viewMap: ViewMapDto; + readyCnt: number; + gameInterrupted: boolean; - constructor(game: Game) { + constructor(game: Game, readonly maxScore = 9) { this.setGameId(game.id); - this.player1Id = game.winnerId; - this.player2Id = game.loserId; + this.playerLeftId = game.winnerId; + this.playerRightId = game.loserId; this.winnerId = null; this.loserId = null; - this.score1 = game.winnerScore; - this.score2 = game.loserScore; + this.scoreLeft = game.winnerScore; + this.scoreRight = game.loserScore; this.gameStatus = game.gameStatus; + this.gameType = game.gameType; this.viewMap = new ViewMapDto(game.ballSpeed, game.racketSize); + this.readyCnt = 0; + this.gameInterrupted = false; } + getGameId() { return this.gameId; } + bothReady() { + return this.readyCnt == 2; + } + + isOver(): boolean { + if ( + this.scoreLeft === this.maxScore || + this.scoreRight === this.maxScore + ) { + this.gameStatus = GameStatus.FINISHED; + return true; + } + return false; + } + private setGameId(gameId: number) { this.gameId = gameId; } diff --git a/src/game/dto/gateway-join-game-room-param.dto.ts b/src/game/dto/gateway-join-game-room-param.dto.ts deleted file mode 100644 index e6cfae1..0000000 --- a/src/game/dto/gateway-join-game-room-param.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class GatewayJoinGameRoomParamDto { - invitingUserId: number; - invitedUserId: number; - roomName: string; -} diff --git a/src/game/dto/view-map.dto.ts b/src/game/dto/view-map.dto.ts index 8e1bf81..d23d080 100644 --- a/src/game/dto/view-map.dto.ts +++ b/src/game/dto/view-map.dto.ts @@ -1,43 +1,211 @@ // ball 변하는 값 -type ball = { +export type ball = { x: number; y: number; - speed: number; + vx: number; + vy: number; + xVelocity: number; // 속력: 속도 * 방향 + yVelocity: number; + accel: number; // 새로운 판마다 증가 }; // racket 변하는 값 -type racket = { +export type racket = { y: number; - size: number; + action: string; // up, down }; +export class UpdateDto { + racketLeft: { + x: number; + y: number; + }; + racketRight: { + x: number; + y: number; + }; + ball: { + x: number; + y: number; + }; + scoreLeft: boolean; + scoreRight: boolean; + + isScoreChanged(): boolean { + return this.scoreRight || this.scoreRight; + } +} + export class ViewMapDto { ball: ball; - racket1: racket; - racket2: racket; + ballSpeed: number; + racketLeft: racket; + racketRight: racket; + racketSize: number; + private updateDto: UpdateDto; constructor( ballSpeed: number, racketSize: number, // 고정값 - private readonly canvasWidth = 1400, - private readonly canvasHeight = 1000, + readonly canvasWidth = 1400, + readonly canvasHeight = 1000, + + readonly ballRadius = 2, - private readonly ballRadius = 2, + readonly racketWidth = canvasWidth * 0.1, + readonly racketHeight = canvasHeight * 0.4, + readonly racketLeftX = 0, + readonly racketRightX = canvasWidth - racketWidth, + readonly racketSpeed = 6, - private readonly racketWidth = canvasWidth * 0.1, - private readonly racketHeight = canvasHeight * 0.4, - private readonly racket1X = 0, - private readonly racket2X = canvasWidth - racketWidth, + readonly deltaTime = 1 / 60, ) { + this.updateDto = new UpdateDto(); this.ball.x = canvasWidth / 2; this.ball.y = canvasHeight / 2; - this.ball.speed = ballSpeed; + this.ballSpeed = ballSpeed; + this.ball.xVelocity = + ballSpeed * (Math.random() < 0.5 ? 0 : 1) === 0 ? 1 : -1; + this.ball.yVelocity = + ballSpeed * (Math.random() < 0.5 ? 0 : 1) === 0 ? 1 : -1; + this.ball.accel = 0; + + this.racketLeft.y = canvasHeight / 2 - racketHeight / 2; + this.racketRight.y = canvasHeight / 2 - racketHeight / 2; + this.racketSize = racketSize; + } + + async init() { + this.ball.vx = 0; + this.ball.vy = 0; + this.updateDto.scoreLeft = false; + this.updateDto.scoreRight = false; + + this.ball.x = this.canvasWidth / 2; + this.ball.y = this.canvasHeight / 2; + this.ball.xVelocity = + this.ballSpeed * (Math.random() < 0.5 ? 0 : 1) === 0 ? 1 : -1; + this.ball.yVelocity = + this.ballSpeed * (Math.random() < 0.5 ? 0 : 1) === 0 ? 1 : -1; + + this.ball.accel += 0.2; + this.racketLeft.y = this.canvasHeight / 2 - this.racketHeight / 2; + this.racketRight.y = this.canvasHeight / 2 - this.racketHeight / 2; + } + + private async updateBall() { + const ball = this.ball; + const dt = this.deltaTime; + + // 공의 위치 업데이트 + const x = ball.x + ball.xVelocity * dt + ball.accel * dt * dt * 0.5; + const y = ball.y + ball.yVelocity * dt + ball.accel * dt * dt * 0.5; + // 공의 속력 업데이트 + ball.xVelocity += ball.accel * dt * (ball.xVelocity > 0 ? 1 : -1); + ball.yVelocity += ball.accel * dt * (ball.yVelocity > 0 ? 1 : -1); + + this.ball.vx = x - ball.x; + this.ball.vy = y - ball.y; + this.ball.x = x; + this.ball.y = y; + } + + updateRacketLeft(action: string) { + const racket = this.racketLeft; + + if (action === 'arrowUp') racket.y -= this.racketSpeed; + else if (action === 'arrowDown') racket.y += this.racketSpeed; + + if (racket.y <= 0) racket.y = 0; + if (racket.y + this.racketHeight >= this.canvasHeight) + racket.y = this.canvasHeight - this.racketHeight; + } + + updateRacketRight(action: string) { + const racket = this.racketRight; + + if (action === 'up') racket.y -= this.racketSpeed; + else if (action === 'down') racket.y += this.racketSpeed; + + if (racket.y <= 0) racket.y = 0; + if (racket.y + this.racketHeight >= this.canvasHeight) + racket.y = this.canvasHeight - this.racketHeight; + } + + async changes() { + const updateDto = this.updateDto; + const ball = this.ball; + await this.updateBall(); + + // racket, 천장, 바닥에 부딪히는지 + await this.detectCollision(); + + //score + if (ball.x + this.ballRadius >= this.canvasWidth) + updateDto.scoreRight = true; // right + else if (ball.x - this.ballRadius <= 0) updateDto.scoreLeft = true; // left + + // 내보내기 + updateDto.racketLeft = { + x: this.racketLeftX, + y: this.racketLeft.y, + }; + updateDto.racketRight = { + x: this.racketRightX, + y: this.racketRight.y, + }; + updateDto.ball = { + x: this.ball.x, + y: this.ball.y, + }; + + return updateDto; + } + + private async detectCollision() { + const ball = this.ball; + let dx, dy; + + // 새로운 방향이 양수면 오른쪽 racket, 음수면 왼쪽 racket이랑 부딪히는지 검사 + if (this.ball.vx > 0) { + dx = Math.abs(ball.x - this.getRacketRightCenter().cx); + dy = Math.abs(ball.y - this.getRacketRightCenter().cy); + if ( + dx <= this.ballRadius + this.racketWidth / 2 && + dy <= this.ballRadius + this.racketHeight / 2 + ) + ball.xVelocity *= -1; + } else if (this.ball.vx < 0) { + dx = Math.abs(ball.x - this.getRacketLeftCenter().cx); + dy = Math.abs(ball.y - this.getRacketLeftCenter().cy); + if ( + dx <= this.ballRadius + this.racketWidth / 2 && + dy <= this.ballRadius + this.racketHeight / 2 + ) + ball.xVelocity *= -1; + } + + // 바닥, 천장 + if ( + ball.y + this.ballRadius >= this.canvasHeight || + ball.y - this.ballRadius <= 0 + ) + ball.yVelocity *= -1; + } + + private getRacketLeftCenter() { + return { + cx: this.racketLeftX + this.racketWidth / 2, + cy: this.racketLeft.y + this.racketHeight / 2, + }; + } - this.racket1.y = canvasHeight / 2 - racketHeight / 2; - this.racket2.y = canvasHeight / 2 - racketHeight / 2; - this.racket1.size = racketSize; - this.racket2.size = racketSize; + private getRacketRightCenter() { + return { + cx: this.racketRightX + this.racketWidth / 2, + cy: this.racketRight.y + this.racketHeight / 2, + }; } } diff --git a/src/game/game.controller.ts b/src/game/game.controller.ts index 9125924..4908837 100644 --- a/src/game/game.controller.ts +++ b/src/game/game.controller.ts @@ -35,7 +35,7 @@ export class GameController { gameType: invitationRequestDto.gameType, }; - await this.gameService.createInvitation(invitationParamDto); + return await this.gameService.createInvitation(invitationParamDto); } /** diff --git a/src/game/game.gateway.ts b/src/game/game.gateway.ts index 029caa1..2d56fdb 100644 --- a/src/game/game.gateway.ts +++ b/src/game/game.gateway.ts @@ -16,13 +16,19 @@ import { GatewayCreateGameInvitationParamDto } from './dto/gateway-create-invita import { EVENT_GAME_INVITATION, EVENT_GAME_INVITATION_REPLY, - EVENT_GAME_READY, + EVENT_GAME_START, + EVENT_MATCH_SCORE, + EVENT_MATCH_STATUS, } from '../common/events'; import { ChannelsGateway } from '../channels/channels.gateway'; -import { GatewaySendInvitationReplyDto } from './dto/gateway-send-invitation-reaponse.dto'; +import { EmitEventInvitationReplyDto } from './dto/emit-event-invitation-reaponse.dto'; import { GameRepository } from './game.repository'; import { WSBadRequestException } from '../common/exception/custom-exception'; import { GameDto } from './dto/game.dto'; +import { GameStatus, UserStatus } from '../common/enum'; +import { EmitEventMatchScoreParamDto } from './dto/emit-event-match-score-param.dto'; +import { EmitEventMatchStatusDto } from './dto/emit-event-match-status.dto'; +import { CreateGameParamDto } from './dto/create-game-param.dto'; @WebSocketGateway({ namespace: 'game' }) @UseFilters(WsExceptionFilter) @@ -61,7 +67,19 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { await this.usersRepository.update(user.id, { gameSocketId: null, }); + + const gameId = this.userIdToGameId.get(user.id); + if (gameId) { + const gameDto = this.gameIdToGameDto.get(gameId); + if (gameDto) { + if (gameDto.gameStatus === GameStatus.PLAYING) + gameDto.gameInterrupted = true; + this.gameIdToGameDto.delete(gameId); + } + this.userIdToGameId.delete(user.id); + } this.userIdToClient.delete(user.id); + client.rooms.clear(); } @@ -85,15 +103,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { } async sendInvitationReply( - sendInvitationReplyDto: GatewaySendInvitationReplyDto, + sendInvitationReplyDto: EmitEventInvitationReplyDto, ) { const targetUserId = sendInvitationReplyDto.targetUserId; const targetUserChannelSocketId = sendInvitationReplyDto.targetUserChannelSocketId; const isAccepted = sendInvitationReplyDto.isAccepted; const gameId = sendInvitationReplyDto.gameId; - // gameId 저장하기 - if (isAccepted && gameId) this.userIdToGameId.set(targetUserId, gameId); this.channelsGateway.server .to(targetUserChannelSocketId) @@ -104,43 +120,244 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { } @SubscribeMessage('gameRequest') - async prepareGame(@ConnectedSocket() client: Socket) { + async prepareGame( + @ConnectedSocket() client: Socket, + @MessageBody() data: { gameId: number }, + ) { /* TODO: Game 세팅 * 1. game Data 만들기 * - * 2. map 준비하기 + * 2. map 준비하기 ✅ * 3. game room join하기 ✅ - * 4. game start event emit ✅*/ + * 4. INGAME으로 상태 바꾸기 ✅ + * 5. game start event emit ✅*/ const user = await this.authService.getUserFromSocket(client); if (!user) return client.disconnect(); - const gameId = this.userIdToGameId.get(user.id); const game = await this.gameRepository.findOne({ - where: { id: gameId }, + where: { id: data.gameId }, }); if (!game) throw WSBadRequestException( - `gameId ${gameId} 가 유효하지 않습니다`, + `game id ${data.gameId} 는 유효하지 않습니다`, ); const gameDto = new GameDto(game); - if (!this.gameIdToGameDto.get(game.id)) - this.gameIdToGameDto.set(game.id, gameDto); + const gameId = this.userIdToGameId.get(user.id); + if (gameId) { + const gameDto = this.gameIdToGameDto.get(gameId); + if (gameDto) { + // userId -> gameId -> game이 아직 있는데 + if ( + gameDto.playerLeftId !== user.id && + gameDto.playerRightId !== user.id + ) { + // user가 player가 아니라면 + throw WSBadRequestException( + `유저에게 진행 중이거나 처리되지 않은 다른 game id ${gameId}가 있습니다`, + ); + } + } + this.userIdToGameId.delete(user.id); + } + this.userIdToGameId.set(user.id, game.id); + this.gameIdToGameDto.set(game.id, gameDto); + + const playerLeftSocket = this.userIdToClient.get(gameDto.playerLeftId); + const playerRightSocket = this.userIdToClient.get( + gameDto.playerRightId, + ); + if (!playerLeftSocket || !playerRightSocket) + throw WSBadRequestException( + `두 플레이어의 게임 소켓이 모두 필요합니다. 게임 불가`, + ); + + playerLeftSocket.join(gameDto.getGameId().toString()); + playerRightSocket.join(gameDto.getGameId().toString()); + + // 4. INGAME으로 상태 바꾸기 + await this.usersRepository.update(user.id, { + status: UserStatus.INGAME, + }); + } + + @SubscribeMessage('matchKeyDown') + async updateBallAndRacket( + @ConnectedSocket() client: Socket, + @MessageBody() + data: { + gameId: number; + keyStatus: string; + keyName: string; + }, + ) { + const user = await this.authService.getUserFromSocket(client); + if (!user) return client.disconnect(); + + const gameDto = this.gameIdToGameDto.get(data.gameId); + if (!gameDto) { + throw WSBadRequestException( + `유저가 진행 중인 게임을 찾지 못했습니다`, + ); + } else { + if ( + gameDto.playerLeftId !== user.id && + gameDto.playerRightId !== user.id + ) { + // user가 player가 아니라면 + throw WSBadRequestException( + `유저에게 해당하는 게임이 아닙니다`, + ); + } + } + + if (data.keyStatus === 'down') { + if (user.id === gameDto.playerLeftId) + gameDto.viewMap.updateRacketLeft(data.keyName); + else gameDto.viewMap.updateRacketRight(data.keyName); + } + } + + @SubscribeMessage('gameReady') + async gaming( + @ConnectedSocket() client: Socket, + @MessageBody() data: { gameId: number }, + ) { + /* TODO: + * 1. ball racket update emit + * 2. score update, emit -> restart + * 3. 둘 중 하나 끊겼는지 확인하고 승패 처리 -> gameEnd(game DB 저장, gameIdToGameDto에서 game을 지우기) + * 4. max score인지 확인 -> gameEnd + * 5. init하기 + * */ + // auth guard + const user = await this.authService.getUserFromSocket(client); + if (!user) return client.disconnect; - const player1Socket = this.userIdToClient.get(gameDto.player1Id); - const player2Socket = this.userIdToClient.get(gameDto.player2Id); - if (!player1Socket || !player2Socket) + const gameDto = this.gameIdToGameDto.get(data.gameId); + if (!gameDto) throw WSBadRequestException('유효하지 않은 게임입니다'); + + const playerLeftSocket = this.userIdToClient.get(gameDto.playerLeftId); + const playerRightSocket = this.userIdToClient.get( + gameDto.playerRightId, + ); + if (!playerLeftSocket || !playerRightSocket) throw WSBadRequestException( `두 플레이어의 게임 소켓이 모두 필요합니다. 게임 불가`, ); - player1Socket.join(gameDto.getGameId().toString()); - player2Socket.join(gameDto.getGameId().toString()); - // 프론트 무슨 데이터 필요한지 ? - this.server.to(player1Socket.id).emit(EVENT_GAME_READY, {}); - this.server.to(player2Socket.id).emit(EVENT_GAME_READY, {}); + gameDto.readyCnt++; + if (gameDto.bothReady()) { + this.server + .to(gameDto.getGameId().toString()) + .emit(EVENT_GAME_START); + } else return; + + const intervalId: NodeJS.Timeout = setInterval(async () => { + const viewMap = gameDto.viewMap; + + // update objects + const updateDto = await viewMap.changes(); + + // emit update objects to each user + const playerLeftMatchStatusDto: EmitEventMatchStatusDto = { + myRacket: updateDto.racketLeft, + rivalRacket: updateDto.racketRight, + ball: updateDto.ball, + }; + const playerRightMatchStatusDto: EmitEventMatchStatusDto = { + myRacket: updateDto.racketRight, + rivalRacket: updateDto.racketLeft, + ball: updateDto.ball, + }; + this.server + .to(playerLeftSocket.id) + .emit(EVENT_MATCH_STATUS, playerLeftMatchStatusDto); + this.server + .to(playerRightSocket.id) + .emit(EVENT_MATCH_STATUS, playerRightMatchStatusDto); + + // update score + if (updateDto.isScoreChanged()) { + if (updateDto.scoreLeft) gameDto.scoreLeft++; + else if (updateDto.scoreRight) gameDto.scoreRight++; + + //emit score to each user + const playerLeftMatchScoreDto: EmitEventMatchScoreParamDto = { + myScore: gameDto.scoreLeft, + rivalScore: gameDto.scoreRight, + }; + const playerRightMatchScoreDto: EmitEventMatchScoreParamDto = { + myScore: gameDto.scoreRight, + rivalScore: gameDto.scoreLeft, + }; + this.server + .to(playerLeftSocket.id) + .emit(EVENT_MATCH_SCORE, playerLeftMatchScoreDto); + this.server + .to(playerRightSocket.id) + .emit(EVENT_MATCH_SCORE, playerRightMatchScoreDto); + + if (gameDto.isOver()) { + clearInterval(intervalId); + await this.gameEnd(gameDto); + } else await this.gameRestart(gameDto); + } + if ( + !(await this.isSocketConnected(playerLeftSocket)) || + !(await this.isSocketConnected(playerRightSocket)) + ) { + clearInterval(intervalId); + await this.gameEnd(gameDto); + } + }, 1000 / 60); + } + + async gameEnd(gameDto: GameDto) { + // game DB update + const winnerId = + gameDto.scoreLeft > gameDto.scoreRight + ? gameDto.playerLeftId + : gameDto.playerRightId; + const loserId = + gameDto.scoreLeft < gameDto.scoreRight + ? gameDto.playerLeftId + : gameDto.playerRightId; + + const winnerScore = + gameDto.scoreLeft > gameDto.scoreRight + ? gameDto.scoreLeft + : gameDto.scoreRight; + const loserScore = + gameDto.scoreLeft < gameDto.scoreRight + ? gameDto.scoreLeft + : gameDto.scoreRight; + + const gameResult = new CreateGameParamDto( + gameDto, + winnerId, + loserId, + winnerScore, + loserScore, + GameStatus.FINISHED, + ); + await this.gameRepository.update(gameDto.getGameId(), gameResult); + + // gameDto 지워주기 + this.gameIdToGameDto.delete(gameDto.getGameId()); } - @SubscribeMessage('gameStart') - async gameStart(@ConnectedSocket() client: Socket) {} + async gameRestart(gameDto: GameDto) { + await gameDto.viewMap.init(); + } + + private async isSocketConnected(client: Socket) { + const socket = (await this.server.fetchSockets()).find( + (s) => s.id === client.id, + ); + if (!socket) { + return false; + } + return true; + } } diff --git a/src/game/game.repository.ts b/src/game/game.repository.ts index 3c239a2..ba958ab 100644 --- a/src/game/game.repository.ts +++ b/src/game/game.repository.ts @@ -3,13 +3,14 @@ import { GAME_DEFAULT_PAGE_SIZE } from 'src/common/constants'; import { DataSource, Repository } from 'typeorm'; import { Game } from './entities/game.entity'; import { DBUpdateFailureException } from '../common/exception/custom-exception'; +import { CreateInitialGameParamDto } from './dto/create-initial-game-param.dto'; import { CreateGameParamDto } from './dto/create-game-param.dto'; export class GameRepository extends Repository { constructor(@InjectRepository(Game) private dataSource: DataSource) { super(Game, dataSource.manager); } - async createGame(gameParamDto: CreateGameParamDto) { + async createGame(gameParamDto: CreateInitialGameParamDto) { const game = this.create(gameParamDto); // console.log(JSON.stringify(game)); // {"winnerId":8,"loserId":7,"gameType":"NORMAL_INVITE","winnerScore":0,"loserScore":0,"gameStatus":"WAITING","ballSpeed":1,"racketSize":1} // console.log(game.id); // undefined diff --git a/src/game/game.service.ts b/src/game/game.service.ts index b84603e..6035dc1 100644 --- a/src/game/game.service.ts +++ b/src/game/game.service.ts @@ -1,19 +1,15 @@ -import { - BadRequestException, - ImATeapotException, - Injectable, -} from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { GameRepository } from './game.repository'; import { UsersRepository } from '../users/users.repository'; import { CreateGameInvitationParamDto } from './dto/create-invitation-param.dto'; import { GatewayCreateGameInvitationParamDto } from './dto/gateway-create-invitation-param.dto'; import { GameGateway } from './game.gateway'; -import { CreateGameParamDto } from './dto/create-game-param.dto'; +import { CreateInitialGameParamDto } from './dto/create-initial-game-param.dto'; import { GameInvitationRepository } from './game-invitation.repository'; import { DeleteGameInvitationParamDto } from './dto/delete-invitation-param.dto'; -import { UserStatus } from '../common/enum'; +import { GameStatus, UserStatus } from '../common/enum'; import { acceptGameParamDto } from './dto/accept-game-param.dto'; -import { GatewaySendInvitationReplyDto } from './dto/gateway-send-invitation-reaponse.dto'; +import { EmitEventInvitationReplyDto } from './dto/emit-event-invitation-reaponse.dto'; import { User } from '../users/entities/user.entity'; import * as moment from 'moment'; import { BlocksRepository } from '../users/blocks.repository'; @@ -40,13 +36,13 @@ export class GameService { // 상대가 존재하는 유저인지 const invitedUser = await this.checkUserExist(invitedUserId); - // 상대가 ONLINE 인지 => 접속 중인 유저가 아니라면 없던 일이 됨 + // 상대가 OFFLINE / INGAME이면 없던 일이 됨 if ( - invitedUser.status === UserStatus.OFFLINE || + invitedUser.status !== UserStatus.ONLINE || !invitedUser.channelSocketId ) - throw new ImATeapotException( - `초대된 유저 ${invitedUserId} 는 OFFLINE 상태입니다`, + throw new BadRequestException( + `초대된 유저 ${invitedUserId} 는 OFFLINE 상태이거나 게임 중입니다`, ); const isBlocked = await this.blocksRepository.findOne({ @@ -68,7 +64,7 @@ export class GameService { const diff = currentTime.diff(olderInvitation.createdAt, 'seconds'); if (diff < 10) { - throw new ImATeapotException( + throw new BadRequestException( `아직 응답을 기다리고 있는 초대입니다`, ); } else { @@ -93,6 +89,11 @@ export class GameService { gameType: gameType, }; await this.gameGateway.inviteGame(gatewayInvitationParamDto); + + const createInvitationResponseDto = { + gameInvitationId: gameInvitation.id, + }; + return createInvitationResponseDto; } async createGame(createGameParamDto: acceptGameParamDto) { @@ -106,7 +107,7 @@ export class GameService { }, }); if (!invitation) - throw new ImATeapotException( + throw new BadRequestException( `해당하는 invitation id ${invitationId} 가 없습니다`, ); @@ -115,28 +116,28 @@ export class GameService { const diff = currentTime.diff(invitation.createdAt, 'seconds'); if (diff >= 10) { - throw new ImATeapotException( - `해당하는 invitation id ${invitationId} 는 시간초과로 유효하지 않습니다`, + throw new BadRequestException( + `시간초과로 유효하지 않은 요청입니다`, ); } // 두 유저 모두 ONLINE 인지 확인 if ( - invitedUser.status === UserStatus.OFFLINE || + invitedUser.status !== UserStatus.ONLINE || !invitedUser.channelSocketId ) - throw new ImATeapotException( - `초대된 유저 ${invitedUser.id} 는 OFFLINE 상태입니다`, + throw new BadRequestException( + `초대된 유저 ${invitedUser.id} 는 OFFLINE 상태이거나 게임중입니다`, ); const invitingUser = await this.checkUserExist( invitation.invitingUserId, ); if ( - invitingUser.status === UserStatus.OFFLINE || + invitingUser.status !== UserStatus.ONLINE || !invitingUser.channelSocketId ) - throw new ImATeapotException( - `초대한 유저 ${invitingUser.id} 는 OFFLINE 상태입니다`, + throw new BadRequestException( + `초대한 유저 ${invitingUser.id} 는 OFFLINE 상태이거나 게임중입니다`, ); /* TODO: DB에 쌓인 유효하지 않은 초대장들은 어떻게 지우지 ? @@ -156,21 +157,25 @@ export class GameService { const plyer2Id = invitedUser.id; const gameType = invitation.gameType; - const gameDto = new CreateGameParamDto(plyer1Id, plyer2Id, gameType); + const gameDto = new CreateInitialGameParamDto( + plyer1Id, + plyer2Id, + gameType, + GameStatus.WAITING, + ); const game = await this.gameRepository.createGame(gameDto); // 성사됐으니까 game invitation 지워주기 await this.gameInvitationRepository.softDelete(invitationId); // 두 유저에게 game id emit - const invitationReplyToInvitingUserDto: GatewaySendInvitationReplyDto = - { - targetUserId: invitingUser.id, - targetUserChannelSocketId: invitingUser.channelSocketId, - isAccepted: true, - gameId: game.id, - }; - const invitationReplyToInvitedUserDto: GatewaySendInvitationReplyDto = { + const invitationReplyToInvitingUserDto: EmitEventInvitationReplyDto = { + targetUserId: invitingUser.id, + targetUserChannelSocketId: invitingUser.channelSocketId, + isAccepted: true, + gameId: game.id, + }; + const invitationReplyToInvitedUserDto: EmitEventInvitationReplyDto = { targetUserId: invitedUser.id, targetUserChannelSocketId: invitedUser.channelSocketId, isAccepted: true, @@ -198,7 +203,7 @@ export class GameService { }, }); if (!invitation) - throw new ImATeapotException( + throw new BadRequestException( `해당하는 invitation id ${invitationId} 가 없습니다`, ); @@ -207,7 +212,9 @@ export class GameService { const diff = currentTime.diff(invitation.createdAt, 'seconds'); if (diff >= 10) { - throw new ImATeapotException(`시간초과로 유효하지 않은 요청입니다`); + throw new BadRequestException( + `시간초과로 유효하지 않은 요청입니다`, + ); } await this.gameInvitationRepository.softDelete(invitationId); @@ -226,7 +233,7 @@ export class GameService { }, }); if (!invitation) - throw new ImATeapotException( + throw new BadRequestException( `해당하는 invitation id ${invitationId} 가 없습니다`, ); // 10초 지나면 유효하지 않은 취소 (악의 취소) @@ -234,7 +241,9 @@ export class GameService { const diff = currentTime.diff(invitation.createdAt, 'seconds'); if (diff >= 10) { - throw new ImATeapotException(`시간초과로 유효하지 않은 요청입니다`); + throw new BadRequestException( + `시간초과로 유효하지 않은 요청입니다`, + ); } await this.gameInvitationRepository.softDelete(invitationId); @@ -248,11 +257,11 @@ export class GameService { invitingUser.status === UserStatus.OFFLINE || !invitingUser.channelSocketId ) - throw new ImATeapotException( + throw new BadRequestException( `초대한 유저 ${invitingUser.id} 는 OFFLINE 상태입니다`, ); - const sendInvitationReplyDto: GatewaySendInvitationReplyDto = { + const sendInvitationReplyDto: EmitEventInvitationReplyDto = { targetUserId: invitingUser.id, targetUserChannelSocketId: invitingUser.channelSocketId, isAccepted: false, From fe1219114da54ed0162edd911e107f2c8eebd3c6 Mon Sep 17 00:00:00 2001 From: jiyun Date: Tue, 19 Dec 2023 18:09:55 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20import=20=EC=A7=80=EC=9A=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/game.repository.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/game/game.repository.ts b/src/game/game.repository.ts index ba958ab..78580fa 100644 --- a/src/game/game.repository.ts +++ b/src/game/game.repository.ts @@ -4,7 +4,6 @@ import { DataSource, Repository } from 'typeorm'; import { Game } from './entities/game.entity'; import { DBUpdateFailureException } from '../common/exception/custom-exception'; import { CreateInitialGameParamDto } from './dto/create-initial-game-param.dto'; -import { CreateGameParamDto } from './dto/create-game-param.dto'; export class GameRepository extends Repository { constructor(@InjectRepository(Game) private dataSource: DataSource) { super(Game, dataSource.manager); From a2158f682882b8e611684f35573d06dbfed1a0dd Mon Sep 17 00:00:00 2001 From: cjho0316 Date: Wed, 20 Dec 2023 19:26:26 +0900 Subject: [PATCH 3/6] feat(game): implement game queue logic --- src/common/events.ts | 2 + .../dto/emit-event-invitation-reply.dto.ts | 6 + .../dto/emit-event-matchmaking-param.dto.ts | 5 + .../emit-event-server-game-ready-param.dto.ts | 5 + src/game/dto/match-game-delete-param.dto.ts | 6 + src/game/dto/match-game-param.dto.ts | 6 + src/game/game.controller.ts | 30 +++++ src/game/game.gateway.ts | 17 +++ src/game/game.service.ts | 106 +++++++++++++++++- 9 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/game/dto/emit-event-invitation-reply.dto.ts create mode 100644 src/game/dto/emit-event-matchmaking-param.dto.ts create mode 100644 src/game/dto/emit-event-server-game-ready-param.dto.ts create mode 100644 src/game/dto/match-game-delete-param.dto.ts create mode 100644 src/game/dto/match-game-param.dto.ts diff --git a/src/common/events.ts b/src/common/events.ts index f1ed9e7..28b73b2 100644 --- a/src/common/events.ts +++ b/src/common/events.ts @@ -6,6 +6,8 @@ export const EVENT_GAME_INVITATION = 'gameInvitation'; export const EVENT_GAME_INVITATION_REPLY = 'gameInvitationReply'; +export const EVENT_GAME_MATCHED = 'gameMatched'; + export const EVENT_GAME_START = 'gameStart'; export const EVENT_MATCH_SCORE = 'matchScore'; diff --git a/src/game/dto/emit-event-invitation-reply.dto.ts b/src/game/dto/emit-event-invitation-reply.dto.ts new file mode 100644 index 0000000..dafeef5 --- /dev/null +++ b/src/game/dto/emit-event-invitation-reply.dto.ts @@ -0,0 +1,6 @@ +export class EmitEventInvitationReplyDto { + targetUserId: number; + targetUserChannelSocketId: string; + isAccepted: boolean; + gameId: number | null; +} diff --git a/src/game/dto/emit-event-matchmaking-param.dto.ts b/src/game/dto/emit-event-matchmaking-param.dto.ts new file mode 100644 index 0000000..f963adc --- /dev/null +++ b/src/game/dto/emit-event-matchmaking-param.dto.ts @@ -0,0 +1,5 @@ +export class EmitEventMatchmakingReplyDto { + targetUserId: number; + targetUserChannelSocketId: string; + gameId: number | null; +} diff --git a/src/game/dto/emit-event-server-game-ready-param.dto.ts b/src/game/dto/emit-event-server-game-ready-param.dto.ts new file mode 100644 index 0000000..e0f0be9 --- /dev/null +++ b/src/game/dto/emit-event-server-game-ready-param.dto.ts @@ -0,0 +1,5 @@ +export class EmitEventServerGameReadyParamDto { + rivalNickname: string; + rivalAvatar: string; + myPosition: string; +} diff --git a/src/game/dto/match-game-delete-param.dto.ts b/src/game/dto/match-game-delete-param.dto.ts new file mode 100644 index 0000000..b9f4f79 --- /dev/null +++ b/src/game/dto/match-game-delete-param.dto.ts @@ -0,0 +1,6 @@ +import { GameType } from 'src/common/enum'; + +export class gameMatchDeleteParamDto { + userId: number; + gameType: GameType; +} diff --git a/src/game/dto/match-game-param.dto.ts b/src/game/dto/match-game-param.dto.ts new file mode 100644 index 0000000..fcf0522 --- /dev/null +++ b/src/game/dto/match-game-param.dto.ts @@ -0,0 +1,6 @@ +import { GameType } from 'src/common/enum'; + +export class gameMatchStartParamDto { + userId: number; + gameType: GameType; +} diff --git a/src/game/game.controller.ts b/src/game/game.controller.ts index 4908837..a72b724 100644 --- a/src/game/game.controller.ts +++ b/src/game/game.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + Get, Param, ParseIntPipe, Post, @@ -17,6 +18,9 @@ import { PositiveIntPipe } from '../common/pipes/positiveInt.pipe'; import { AuthGuard } from '@nestjs/passport'; import { DeleteGameInvitationParamDto } from './dto/delete-invitation-param.dto'; import { acceptGameParamDto } from './dto/accept-game-param.dto'; +import { gameMatchStartParamDto } from './dto/match-game-param.dto'; +import { GameType } from 'src/common/enum'; +import { gameMatchDeleteParamDto } from './dto/match-game-delete-param.dto'; @Controller('game') @ApiTags('game') @@ -86,4 +90,30 @@ export class GameController { deleteInvitationParamDto, ); } + + @Post('/match') + async gameMatchStart( + @GetUser() user: User, + @Param('gameType', ParseIntPipe, PositiveIntPipe) + gameType: GameType, + ) { + const gameMatchStartDto: gameMatchStartParamDto = { + userId: user.id, + gameType: gameType, + }; + await this.gameService.gameMatchStart(gameMatchStartDto); + } + + @Delete('/match') + async gameMatchCancel( + @GetUser() user: User, + @Param('gameType', ParseIntPipe, PositiveIntPipe) + gameType: GameType, + ) { + const gameDeleteMatchDto: gameMatchDeleteParamDto = { + userId: user.id, + gameType: gameType, + }; + await this.gameService.gameMatchCancel(gameDeleteMatchDto); + } } diff --git a/src/game/game.gateway.ts b/src/game/game.gateway.ts index 2d56fdb..3f5a620 100644 --- a/src/game/game.gateway.ts +++ b/src/game/game.gateway.ts @@ -16,6 +16,7 @@ import { GatewayCreateGameInvitationParamDto } from './dto/gateway-create-invita import { EVENT_GAME_INVITATION, EVENT_GAME_INVITATION_REPLY, + EVENT_GAME_MATCHED, EVENT_GAME_START, EVENT_MATCH_SCORE, EVENT_MATCH_STATUS, @@ -29,6 +30,7 @@ import { GameStatus, UserStatus } from '../common/enum'; import { EmitEventMatchScoreParamDto } from './dto/emit-event-match-score-param.dto'; import { EmitEventMatchStatusDto } from './dto/emit-event-match-status.dto'; import { CreateGameParamDto } from './dto/create-game-param.dto'; +import { EmitEventMatchmakingReplyDto } from './dto/emit-event-matchmaking-param.dto'; @WebSocketGateway({ namespace: 'game' }) @UseFilters(WsExceptionFilter) @@ -119,6 +121,21 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { }); } + async sendMatchmakingReply( + sendMatchmakingReplyDto: EmitEventMatchmakingReplyDto, + ) { + const targetUserId = sendMatchmakingReplyDto.targetUserId; + const targetUserChannelSocketId = + sendMatchmakingReplyDto.targetUserChannelSocketId; + const gameId = sendMatchmakingReplyDto.gameId; + + this.channelsGateway.server + .to(targetUserChannelSocketId) + .emit(EVENT_GAME_MATCHED, { + gameId: gameId, + }); + } + @SubscribeMessage('gameRequest') async prepareGame( @ConnectedSocket() client: Socket, diff --git a/src/game/game.service.ts b/src/game/game.service.ts index 6035dc1..658281b 100644 --- a/src/game/game.service.ts +++ b/src/game/game.service.ts @@ -7,15 +7,23 @@ import { GameGateway } from './game.gateway'; import { CreateInitialGameParamDto } from './dto/create-initial-game-param.dto'; import { GameInvitationRepository } from './game-invitation.repository'; import { DeleteGameInvitationParamDto } from './dto/delete-invitation-param.dto'; -import { GameStatus, UserStatus } from '../common/enum'; +import { GameStatus, GameType, UserStatus } from '../common/enum'; import { acceptGameParamDto } from './dto/accept-game-param.dto'; import { EmitEventInvitationReplyDto } from './dto/emit-event-invitation-reaponse.dto'; import { User } from '../users/entities/user.entity'; import * as moment from 'moment'; import { BlocksRepository } from '../users/blocks.repository'; +import { gameMatchStartParamDto } from './dto/match-game-param.dto'; +import { EmitEventMatchmakingReplyDto } from './dto/emit-event-matchmaking-param.dto'; +import { gameMatchDeleteParamDto } from './dto/match-game-delete-param.dto'; @Injectable() export class GameService { + private gameQueue: Record = { + LADDER: [], + NORMAL_MATCHING: [], + SPECIAL_MATCHING: [], + }; constructor( private readonly gameRepository: GameRepository, private readonly gameInvitationRepository: GameInvitationRepository, @@ -190,6 +198,101 @@ export class GameService { ); } + async gameMatchStart(gameMatchStartParamDto: gameMatchStartParamDto) { + const userId = gameMatchStartParamDto.userId; + const gameType = gameMatchStartParamDto.gameType; + + // 유저가 존재하는지 + const user = await this.checkUserExist(userId); + + if (user.status !== UserStatus.ONLINE || !user.channelSocketId) + throw new BadRequestException( + `유저 ${user.id} 는 OFFLINE 상태이거나 게임중입니다`, + ); + + if ( + gameType !== GameType.LADDER && + gameType !== GameType.NORMAL_MATCHING && + gameType !== GameType.SPECIAL_MATCHING + ) { + throw new BadRequestException(`지원하지 않는 게임 타입입니다`); + } + + const gameQueue = this.gameQueue[gameType]; + + gameQueue.push(user); + + if (gameQueue.length === 2) { + const player1 = gameQueue[0]; + const player2 = gameQueue[1]; + + if ( + player1.channelSocketId === null || + player2.channelSocketId === null + ) + throw new BadRequestException( + `유저 ${player1.id} 는 OFFLINE 상태입니다`, + ); + + const gameDto = new CreateInitialGameParamDto( + player1.id, + player2.id, + gameType, + GameStatus.WAITING, + ); + const game = await this.gameRepository.createGame(gameDto); + + const invitationReplyToPlayer1Dto: EmitEventMatchmakingReplyDto = { + targetUserId: player1.id, + targetUserChannelSocketId: player1.channelSocketId, + gameId: game.id, + }; + const invitationReplyToPlayer2Dto: EmitEventMatchmakingReplyDto = { + targetUserId: player2.id, + targetUserChannelSocketId: player2.channelSocketId, + gameId: game.id, + }; + + await this.gameGateway.sendMatchmakingReply( + invitationReplyToPlayer1Dto, + ); + await this.gameGateway.sendMatchmakingReply( + invitationReplyToPlayer2Dto, + ); + } + } + + async gameMatchCancel(gameMatchCancelParamDto: gameMatchDeleteParamDto) { + const userId = gameMatchCancelParamDto.userId; + const gameType = gameMatchCancelParamDto.gameType; + + // 유저가 존재하는지 + const user = await this.checkUserExist(userId); + + if (user.status !== UserStatus.ONLINE || !user.channelSocketId) + throw new BadRequestException( + `유저 ${user.id} 는 OFFLINE 상태이거나 게임중입니다`, + ); + + if ( + gameType !== GameType.LADDER && + gameType !== GameType.NORMAL_MATCHING && + gameType !== GameType.SPECIAL_MATCHING + ) { + throw new BadRequestException(`지원하지 않는 게임 타입입니다`); + } + + const gameQueue = this.gameQueue[gameType]; + + const index = gameQueue.indexOf(user); + if (index > -1) { + gameQueue.splice(index, 1); + } else if (index === -1) + throw new BadRequestException( + `유저 ${user.id} 는 매칭 큐에 존재하지 않습니다`, + ); + } + async deleteInvitationByInvitingUserId( deleteInvitationParamDto: DeleteGameInvitationParamDto, ) { @@ -216,7 +319,6 @@ export class GameService { `시간초과로 유효하지 않은 요청입니다`, ); } - await this.gameInvitationRepository.softDelete(invitationId); } From 7dfdb93dd031e05fe4f458c034fdb9c2b05978af Mon Sep 17 00:00:00 2001 From: cjho0316 Date: Thu, 21 Dec 2023 12:21:17 +0900 Subject: [PATCH 4/6] feat(game): implement channel matchmaking queue --- src/common/events.ts | 2 + .../dto/emit-event-invitation-reaponse.dto.ts | 6 + .../dto/emit-event-matchmaking-param.dto.ts | 5 + src/game/dto/match-game-delete-param.dto.ts | 6 + src/game/dto/match-game-param.dto.ts | 6 + src/game/game.controller.ts | 30 +++++ src/game/game.gateway.ts | 16 +++ src/game/game.service.ts | 115 +++++++++++++++++- 8 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/game/dto/emit-event-invitation-reaponse.dto.ts create mode 100644 src/game/dto/emit-event-matchmaking-param.dto.ts create mode 100644 src/game/dto/match-game-delete-param.dto.ts create mode 100644 src/game/dto/match-game-param.dto.ts diff --git a/src/common/events.ts b/src/common/events.ts index 8a076d2..5a88508 100644 --- a/src/common/events.ts +++ b/src/common/events.ts @@ -8,6 +8,8 @@ export const EVENT_GAME_INVITATION_REPLY = 'gameInvitationReply'; export const EVENT_SERVER_GAME_READY = 'serverGameReady'; +export const EVENT_GAME_MATCHED = 'gameMatched'; + export const EVENT_GAME_START = 'gameStart'; export const EVENT_MATCH_SCORE = 'matchScore'; diff --git a/src/game/dto/emit-event-invitation-reaponse.dto.ts b/src/game/dto/emit-event-invitation-reaponse.dto.ts new file mode 100644 index 0000000..dafeef5 --- /dev/null +++ b/src/game/dto/emit-event-invitation-reaponse.dto.ts @@ -0,0 +1,6 @@ +export class EmitEventInvitationReplyDto { + targetUserId: number; + targetUserChannelSocketId: string; + isAccepted: boolean; + gameId: number | null; +} diff --git a/src/game/dto/emit-event-matchmaking-param.dto.ts b/src/game/dto/emit-event-matchmaking-param.dto.ts new file mode 100644 index 0000000..f963adc --- /dev/null +++ b/src/game/dto/emit-event-matchmaking-param.dto.ts @@ -0,0 +1,5 @@ +export class EmitEventMatchmakingReplyDto { + targetUserId: number; + targetUserChannelSocketId: string; + gameId: number | null; +} diff --git a/src/game/dto/match-game-delete-param.dto.ts b/src/game/dto/match-game-delete-param.dto.ts new file mode 100644 index 0000000..b9f4f79 --- /dev/null +++ b/src/game/dto/match-game-delete-param.dto.ts @@ -0,0 +1,6 @@ +import { GameType } from 'src/common/enum'; + +export class gameMatchDeleteParamDto { + userId: number; + gameType: GameType; +} diff --git a/src/game/dto/match-game-param.dto.ts b/src/game/dto/match-game-param.dto.ts new file mode 100644 index 0000000..fcf0522 --- /dev/null +++ b/src/game/dto/match-game-param.dto.ts @@ -0,0 +1,6 @@ +import { GameType } from 'src/common/enum'; + +export class gameMatchStartParamDto { + userId: number; + gameType: GameType; +} diff --git a/src/game/game.controller.ts b/src/game/game.controller.ts index 4908837..497059b 100644 --- a/src/game/game.controller.ts +++ b/src/game/game.controller.ts @@ -17,6 +17,9 @@ import { PositiveIntPipe } from '../common/pipes/positiveInt.pipe'; import { AuthGuard } from '@nestjs/passport'; import { DeleteGameInvitationParamDto } from './dto/delete-invitation-param.dto'; import { acceptGameParamDto } from './dto/accept-game-param.dto'; +import { gameMatchStartParamDto } from './dto/match-game-param.dto'; +import { GameType } from 'src/common/enum'; +import { gameMatchDeleteParamDto } from './dto/match-game-delete-param.dto'; @Controller('game') @ApiTags('game') @@ -86,4 +89,31 @@ export class GameController { deleteInvitationParamDto, ); } + + @Post('/match') + async gameMatchStart( + @GetUser() user: User, + @Body('gameType') + gameType: GameType, + ) { + const gameMatchStartDto: gameMatchStartParamDto = { + userId: user.id, + gameType: gameType, + }; + console.log(gameMatchStartDto); + await this.gameService.gameMatchStart(gameMatchStartDto); + } + + @Delete('/match/:gameType') + async gameMatchCancel( + @GetUser() user: User, + @Param('gameType') + gameType: GameType, + ) { + const gameDeleteMatchDto: gameMatchDeleteParamDto = { + userId: user.id, + gameType: gameType, + }; + await this.gameService.gameMatchCancel(gameDeleteMatchDto); + } } diff --git a/src/game/game.gateway.ts b/src/game/game.gateway.ts index cd91842..518d192 100644 --- a/src/game/game.gateway.ts +++ b/src/game/game.gateway.ts @@ -20,6 +20,7 @@ import { EVENT_MATCH_SCORE, EVENT_MATCH_STATUS, EVENT_SERVER_GAME_READY, + EVENT_GAME_MATCHED, } from '../common/events'; import { ChannelsGateway } from '../channels/channels.gateway'; import { EmitEventInvitationReplyDto } from './dto/emit-event-invitation-reply.dto'; @@ -30,6 +31,7 @@ import { GameStatus, UserStatus } from '../common/enum'; import { EmitEventMatchStatusDto } from './dto/emit-event-match-status.dto'; import { EmitEventMatchScoreParamDto } from './dto/emit-event-match-score-param.dto'; import { EmitEventServerGameReadyParamDto } from './dto/emit-event-server-game-ready-param.dto'; +import { EmitEventMatchmakingReplyDto } from './dto/emit-event-matchmaking-param.dto'; @WebSocketGateway({ namespace: 'game' }) @UseFilters(WsExceptionFilter) @@ -119,6 +121,20 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { }); } + async sendMatchmakingReply( + sendMatchmakingReplyDto: EmitEventMatchmakingReplyDto, + ) { + const targetUserChannelSocketId = + sendMatchmakingReplyDto.targetUserChannelSocketId; + const gameId = sendMatchmakingReplyDto.gameId; + + this.channelsGateway.server + .to(targetUserChannelSocketId) + .emit(EVENT_GAME_MATCHED, { + gameId: gameId, + }); + } + @SubscribeMessage('gameRequest') async prepareGame( @ConnectedSocket() client: Socket, diff --git a/src/game/game.service.ts b/src/game/game.service.ts index 8b004ff..2679c10 100644 --- a/src/game/game.service.ts +++ b/src/game/game.service.ts @@ -7,15 +7,23 @@ import { GameGateway } from './game.gateway'; import { CreateInitialGameParamDto } from './dto/create-initial-game-param.dto'; import { GameInvitationRepository } from './game-invitation.repository'; import { DeleteGameInvitationParamDto } from './dto/delete-invitation-param.dto'; -import { GameStatus, UserStatus } from '../common/enum'; +import { GameStatus, GameType, UserStatus } from '../common/enum'; import { acceptGameParamDto } from './dto/accept-game-param.dto'; -import { EmitEventInvitationReplyDto } from './dto/emit-event-invitation-reply.dto'; +import { EmitEventInvitationReplyDto } from './dto/emit-event-invitation-reaponse.dto'; import { User } from '../users/entities/user.entity'; import * as moment from 'moment'; import { BlocksRepository } from '../users/blocks.repository'; +import { gameMatchStartParamDto } from './dto/match-game-param.dto'; +import { EmitEventMatchmakingReplyDto } from './dto/emit-event-matchmaking-param.dto'; +import { gameMatchDeleteParamDto } from './dto/match-game-delete-param.dto'; @Injectable() export class GameService { + private gameQueue: Record = { + LADDER: [], + NORMAL_MATCHING: [], + SPECIAL_MATCHING: [], + }; constructor( private readonly gameRepository: GameRepository, private readonly gameInvitationRepository: GameInvitationRepository, @@ -190,6 +198,108 @@ export class GameService { ); } + async gameMatchStart(gameMatchStartParamDto: gameMatchStartParamDto) { + const userId = gameMatchStartParamDto.userId; + const gameType = gameMatchStartParamDto.gameType; + + // 유저가 존재하는지 + const user = await this.checkUserExist(userId); + + if (user.status !== UserStatus.ONLINE || !user.channelSocketId) + throw new BadRequestException( + `유저 ${user.id} 는 OFFLINE 상태이거나 게임중입니다`, + ); + if ( + gameType !== GameType.LADDER && + gameType !== GameType.NORMAL_MATCHING && + gameType !== GameType.SPECIAL_MATCHING + ) { + throw new BadRequestException(`지원하지 않는 게임 타입입니다`); + } + + const gameQueue = this.gameQueue[gameType]; + + // 이미 큐에 존재하는지 + const index = gameQueue.indexOf(user); + if (index > -1) + throw new BadRequestException( + `유저 ${user.id} 는 이미 매칭 큐에 존재합니다`, + ); + + gameQueue.push(user); + + if (gameQueue.length === 2) { + const player1 = gameQueue[0]; + const player2 = gameQueue[1]; + + if ( + player1.channelSocketId === null || + player2.channelSocketId === null + ) + throw new BadRequestException( + `유저 ${player1.id} 는 OFFLINE 상태입니다`, + ); + + const gameDto = new CreateInitialGameParamDto( + player1.id, + player2.id, + gameType, + GameStatus.WAITING, + ); + const game = await this.gameRepository.createGame(gameDto); + + const invitationReplyToPlayer1Dto: EmitEventMatchmakingReplyDto = { + targetUserId: player1.id, + targetUserChannelSocketId: player1.channelSocketId, + gameId: game.id, + }; + const invitationReplyToPlayer2Dto: EmitEventMatchmakingReplyDto = { + targetUserId: player2.id, + targetUserChannelSocketId: player2.channelSocketId, + gameId: game.id, + }; + + await this.gameGateway.sendMatchmakingReply( + invitationReplyToPlayer1Dto, + ); + await this.gameGateway.sendMatchmakingReply( + invitationReplyToPlayer2Dto, + ); + gameQueue.splice(0, 2); + } + } + + async gameMatchCancel(gameMatchCancelParamDto: gameMatchDeleteParamDto) { + const userId = gameMatchCancelParamDto.userId; + const gameType = gameMatchCancelParamDto.gameType; + + // 유저가 존재하는지 + const user = await this.checkUserExist(userId); + + if (user.status !== UserStatus.ONLINE || !user.channelSocketId) + throw new BadRequestException( + `유저 ${user.id} 는 OFFLINE 상태이거나 게임중입니다`, + ); + + if ( + gameType !== GameType.LADDER && + gameType !== GameType.NORMAL_MATCHING && + gameType !== GameType.SPECIAL_MATCHING + ) { + throw new BadRequestException(`지원하지 않는 게임 타입입니다`); + } + + const gameQueue = this.gameQueue[gameType]; + + const index = gameQueue.indexOf(user); + if (index > -1) { + gameQueue.splice(index, 1); + } else if (index === -1) + throw new BadRequestException( + `유저 ${user.id} 는 매칭 큐에 존재하지 않습니다`, + ); + } + async deleteInvitationByInvitingUserId( deleteInvitationParamDto: DeleteGameInvitationParamDto, ) { @@ -216,7 +326,6 @@ export class GameService { `시간초과로 유효하지 않은 요청입니다`, ); } - await this.gameInvitationRepository.softDelete(invitationId); } From 35506f9772eb2be9c43310bb4ff42453564b2950 Mon Sep 17 00:00:00 2001 From: cjho0316 Date: Thu, 21 Dec 2023 15:40:17 +0900 Subject: [PATCH 5/6] fix(game): change game queing logic --- src/game/game.service.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/game/game.service.ts b/src/game/game.service.ts index 2679c10..bb9d4d9 100644 --- a/src/game/game.service.ts +++ b/src/game/game.service.ts @@ -228,16 +228,28 @@ export class GameService { gameQueue.push(user); - if (gameQueue.length === 2) { - const player1 = gameQueue[0]; - const player2 = gameQueue[1]; + if (gameQueue.length >= 2) { + console.log(gameQueue); + let player1 = gameQueue.shift(); + let player2 = gameQueue.shift(); + + // player1 또는 2가 OFFLINE이라면 큐에서 제거시키고 다시 매칭 + while (player1 && player1.channelSocketId === null) { + player1 = gameQueue.shift(); + } + + while (player2 && player2.channelSocketId === null) { + player2 = gameQueue.shift(); + } if ( + !player1 || + !player2 || player1.channelSocketId === null || player2.channelSocketId === null ) throw new BadRequestException( - `유저 ${player1.id} 는 OFFLINE 상태입니다`, + `게임 매칭 큐에 유저가 2명 이상이어야 합니다`, ); const gameDto = new CreateInitialGameParamDto( @@ -265,7 +277,6 @@ export class GameService { await this.gameGateway.sendMatchmakingReply( invitationReplyToPlayer2Dto, ); - gameQueue.splice(0, 2); } } From fe25b07afbf7873bfe7055711784bf55b0d49d3b Mon Sep 17 00:00:00 2001 From: cjho0316 Date: Thu, 21 Dec 2023 15:44:01 +0900 Subject: [PATCH 6/6] chore: erase console log --- src/game/game.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/game/game.service.ts b/src/game/game.service.ts index bb9d4d9..876ea72 100644 --- a/src/game/game.service.ts +++ b/src/game/game.service.ts @@ -229,7 +229,6 @@ export class GameService { gameQueue.push(user); if (gameQueue.length >= 2) { - console.log(gameQueue); let player1 = gameQueue.shift(); let player2 = gameQueue.shift();