diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index 564fd83..9e1fe12 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -1,7 +1,6 @@ import { InjectRedis } from '@liaoliaots/nestjs-redis'; import { BadRequestException, - ImATeapotException, Injectable, Logger, } from '@nestjs/common'; diff --git a/src/common/events.ts b/src/common/events.ts index 0e0cec1..8a076d2 100644 --- a/src/common/events.ts +++ b/src/common/events.ts @@ -6,4 +6,10 @@ export const EVENT_GAME_INVITATION = 'gameInvitation'; export const EVENT_GAME_INVITATION_REPLY = 'gameInvitationReply'; -export const EVENT_GAME_READY = 'gameReady'; +export const EVENT_SERVER_GAME_READY = 'serverGameReady'; + +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-initial-game-param.dto.ts similarity index 79% rename from src/game/dto/create-game-param.dto.ts rename to src/game/dto/create-initial-game-param.dto.ts index d6d5d54..9e56416 100644 --- a/src/game/dto/create-game-param.dto.ts +++ b/src/game/dto/create-initial-game-param.dto.ts @@ -1,6 +1,6 @@ import { GameStatus, GameType } from '../../common/enum'; -export class CreateGameParamDto { +export class CreateInitialGameParamDto { winnerId: number; loserId: number; gameType: GameType; @@ -10,13 +10,18 @@ export class CreateGameParamDto { ballSpeed: number; racketSize: number; - constructor(player1Id: number, player2Id: number, gameType: GameType) { + 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.WAITING; + this.gameStatus = gameStatus; if ( this.gameType === GameType.SPECIAL_INVITE || this.gameType === GameType.SPECIAL_MATCHING @@ -24,7 +29,7 @@ export class CreateGameParamDto { this.ballSpeed = this.getRandomNumber(1, 3); this.racketSize = this.getRandomNumber(1, 3); } else { - // Set to 1 for NORMAL + // Set 1 for LADDER and NORMAL this.ballSpeed = 1; this.racketSize = 1; } 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-reply.dto.ts similarity index 70% rename from src/game/dto/gateway-send-invitation-reaponse.dto.ts rename to src/game/dto/emit-event-invitation-reply.dto.ts index c5def15..dafeef5 100644 --- a/src/game/dto/gateway-send-invitation-reaponse.dto.ts +++ b/src/game/dto/emit-event-invitation-reply.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/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/game.dto.ts b/src/game/dto/game.dto.ts index 18bd9f3..51ae2be 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; + gameType: GameType; gameStatus: GameStatus; viewMap: ViewMapDto; + readyCnt: number; + gameInterrupted: boolean; - constructor(game: Game) { + constructor(game: Game, readonly maxScore = 7) { 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.gameType = game.gameType; this.gameStatus = game.gameStatus; 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..cd91842 100644 --- a/src/game/game.gateway.ts +++ b/src/game/game.gateway.ts @@ -16,13 +16,20 @@ 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, + EVENT_SERVER_GAME_READY, } 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-reply.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 { 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'; @WebSocketGateway({ namespace: 'game' }) @UseFilters(WsExceptionFilter) @@ -61,7 +68,18 @@ 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.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,251 @@ 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 player1Socket = this.userIdToClient.get(gameDto.player1Id); - const player2Socket = this.userIdToClient.get(gameDto.player2Id); - if (!player1Socket || !player2Socket) + this.userIdToGameId.set(user.id, game.id); + this.gameIdToGameDto.set(game.id, gameDto); + + const playerSockets = this.getPlayersSocket(gameDto); + + playerSockets.left.join(gameDto.getGameId().toString()); + playerSockets.right.join(gameDto.getGameId().toString()); + + // 4. INGAME으로 상태 바꾸기 + await this.usersRepository.update(user.id, { + status: UserStatus.INGAME, + }); + + // rival 정보 보내기 + const rivalId = + user.id === gameDto.playerRightId + ? gameDto.playerLeftId + : gameDto.playerRightId; + const rival = await this.usersRepository.findOne({ + where: { id: rivalId }, + }); + if (!rival) + throw WSBadRequestException(`상대 ${rivalId} 가 존재하지 않습니다`); + const eventServerGameReadyParamDto: EmitEventServerGameReadyParamDto = { + rivalNickname: rival.nickname, + rivalAvatar: rival.avatar, + myPosition: rivalId === gameDto.playerRightId ? 'LEFT' : 'RIGHT', + }; + + this.server + .to(client.id) + .emit(EVENT_SERVER_GAME_READY, eventServerGameReadyParamDto); + } + + @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 = await this.checkGameDto(data.gameId, user.id); + + if (data.keyStatus === 'down') { + if (user.id === gameDto.playerLeftId) + gameDto.viewMap.updateRacketLeft(data.keyName); + else gameDto.viewMap.updateRacketRight(data.keyName); + } + } + + @SubscribeMessage('clientGameReady') + async gaming( + @ConnectedSocket() client: Socket, + @MessageBody() data: { gameId: number }, + ) { + /* TODO: + * 1. ball racket update, emit + * 2. score update, emit -> restart + * 3. 둘 중 하나 끊겼는지 확인하고 승패 처리 -> gameEnd(game DB 저장, user DB 저장, userIdToGameId에서 gameId을 지우기) + * 4. max score인지 확인 -> gameEnd + * 5. init하기 + * */ + // auth guard + const user = await this.authService.getUserFromSocket(client); + if (!user) return client.disconnect; + + const gameDto = await this.checkGameDto(data.gameId, user.id); + + const playerSockets = this.getPlayersSocket(gameDto); + + gameDto.readyCnt++; + if (gameDto.bothReady()) { + gameDto.gameStatus = GameStatus.PLAYING; + this.server + .to(gameDto.getGameId().toString()) + .emit(EVENT_GAME_START); + } else return; + + this.gameLoop(gameDto); + } + + gameLoop(gameDto: GameDto) { + const playerSockets = this.getPlayersSocket(gameDto); + + 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(playerSockets.left.id) + .emit(EVENT_MATCH_STATUS, playerLeftMatchStatusDto); + this.server + .to(playerSockets.right.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(playerSockets.left.id) + .emit(EVENT_MATCH_SCORE, playerLeftMatchScoreDto); + this.server + .to(playerSockets.right.id) + .emit(EVENT_MATCH_SCORE, playerRightMatchScoreDto); + + if (gameDto.isOver()) { + clearInterval(intervalId); + await this.gameEnd(gameDto); + } else await this.gameRestart(gameDto); + } + if ( + gameDto.gameInterrupted || + !(await this.isSocketConnected(playerSockets.left)) || + !(await this.isSocketConnected(playerSockets.right)) + ) { + clearInterval(intervalId); + await this.gameEnd(gameDto); + } + }, 1000 / 60); + } + + async gameEnd(gameDto: GameDto) { + // TODO: ladderScore 계산하기 + // game DB update + + /* TODO: user db update + -> ladderMaxScore 비교 후 반영, Ladder전일 때는 winCount, loseCount도 update */ + // user DB update + + // gameDto 유저들 지워주기 + this.userIdToGameId.delete(gameDto.playerLeftId); + this.userIdToGameId.delete(gameDto.playerRightId); + // gameDto 지워주기 + this.gameIdToGameDto.delete(gameDto.getGameId()); + } + + async gameRestart(gameDto: GameDto) { + await gameDto.viewMap.init(); + } + + private getPlayersSocket(gameDto: GameDto) { + const playerLeftId = gameDto.playerLeftId; + const playerRightId = gameDto.playerRightId; + + const playerLeftSocket = this.userIdToClient.get(playerLeftId); + const playerRightSocket = this.userIdToClient.get(playerRightId); + if (!playerLeftSocket || !playerRightSocket) throw WSBadRequestException( `두 플레이어의 게임 소켓이 모두 필요합니다. 게임 불가`, ); - player1Socket.join(gameDto.getGameId().toString()); - player2Socket.join(gameDto.getGameId().toString()); + return { + left: playerLeftSocket, + right: playerRightSocket, + }; + } - // 프론트 무슨 데이터 필요한지 ? - this.server.to(player1Socket.id).emit(EVENT_GAME_READY, {}); - this.server.to(player2Socket.id).emit(EVENT_GAME_READY, {}); + private async isSocketConnected(client: Socket) { + const socket = (await this.server.fetchSockets()).find( + (s) => s.id === client.id, + ); + if (!socket) { + return null; + } + return socket; } - @SubscribeMessage('gameStart') - async gameStart(@ConnectedSocket() client: Socket) {} + private async checkGameDto( + receivedGameId: number, + userId: number, + ): Promise { + const gameId = this.userIdToGameId.get(userId); + if (!gameId) { + throw WSBadRequestException(``); + } + if (gameId !== receivedGameId) + throw WSBadRequestException( + `user id ${userId} 에게서 game id ${receivedGameId} 를 찾지 못했습니다`, + ); + + const gameDto = this.gameIdToGameDto.get(gameId); + if (!gameDto) { + throw WSBadRequestException( + `game id ${receivedGameId} 에게서 game 객체를 찾지 못했습니다`, + ); + } else { + if (gameDto.gameInterrupted) + throw WSBadRequestException( + `game id ${gameId} 는 비정상 종료된 게임입니다`, + ); + } + + return gameDto; + } } diff --git a/src/game/game.repository.ts b/src/game/game.repository.ts index 3c239a2..78580fa 100644 --- a/src/game/game.repository.ts +++ b/src/game/game.repository.ts @@ -3,13 +3,13 @@ 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 { CreateGameParamDto } from './dto/create-game-param.dto'; +import { CreateInitialGameParamDto } from './dto/create-initial-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..8b004ff 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-reply.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,