diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index fc91ec8..8005556 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -6,6 +6,7 @@ import type { Socket } from "socket.io-client"; import { protocol } from "@repo/shared"; import type { + BombPlacedPayload, CurrentPlayersPayload, GameResultPayload, GameStartPayload, @@ -36,7 +37,10 @@ offGameEnd: (callback: () => void) => void; onGameResult: (callback: (payload: GameResultPayload) => void) => void; offGameResult: (callback: (payload: GameResultPayload) => void) => void; + onBombPlaced: (callback: (payload: BombPlacedPayload) => void) => void; + offBombPlaced: (callback: (payload: BombPlacedPayload) => void) => void; sendMove: (x: number, y: number) => void; + sendPlaceBomb: (payload: BombPlacedPayload) => void; readyForGame: () => void; }; @@ -96,10 +100,19 @@ offGameResult: (callback) => { offEvent(protocol.SocketEvents.GAME_RESULT, callback); }, + onBombPlaced: (callback) => { + onEvent(protocol.SocketEvents.BOMB_PLACED, callback); + }, + offBombPlaced: (callback) => { + offEvent(protocol.SocketEvents.BOMB_PLACED, callback); + }, sendMove: (x, y) => { const payload: MovePayload = { x, y }; emitEvent(protocol.SocketEvents.MOVE, payload); }, + sendPlaceBomb: (payload) => { + emitEvent(protocol.SocketEvents.PLACE_BOMB, payload); + }, readyForGame: () => { emitEvent(protocol.SocketEvents.READY_FOR_GAME); } diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 6bc8869..eb1f5a5 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -10,6 +10,7 @@ import { GameNetworkSync } from "./application/GameNetworkSync"; import { GameLoop } from "./application/GameLoop"; import { BombManager } from "./application/BombManager"; +import { createBombIdFromPayload } from "./application/BombManager"; import type { BombUpsertPayload } from "./application/BombManager"; import type { GamePlayers } from "./application/game.types"; @@ -39,7 +40,11 @@ public placeBomb(): string | null { if (this.isInputLocked) return null; if (!this.bombManager) return null; - return this.bombManager.placeBomb(); + const placed = this.bombManager.placeBomb(); + if (!placed) return null; + + socketManager.game.sendPlaceBomb(placed.payload); + return placed.bombId; } public upsertBomb(bombId: string, payload: BombUpsertPayload): void { @@ -96,6 +101,10 @@ gameMap: this.gameMap, onGameStart: this.setGameStart.bind(this), onGameEnd: this.lockInput.bind(this), + onBombPlaced: (payload) => { + const bombId = createBombIdFromPayload(payload); + this.upsertBomb(bombId, payload); + }, }); this.networkSync.bind(); diff --git a/apps/client/src/scenes/game/application/BombManager.ts b/apps/client/src/scenes/game/application/BombManager.ts index 0d1c8f4..b9caeb8 100644 --- a/apps/client/src/scenes/game/application/BombManager.ts +++ b/apps/client/src/scenes/game/application/BombManager.ts @@ -5,6 +5,7 @@ */ import type { Container } from "pixi.js"; import { config } from "@client/config"; +import type { BombPlacedPayload } from "@repo/shared"; import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { BombController } from "@client/scenes/game/entities/bomb/BombController"; import type { GamePlayers } from "./game.types"; @@ -13,11 +14,19 @@ export type ElapsedMsProvider = () => number; /** 爆弾の追加更新に使う入力データ型 */ -export type BombUpsertPayload = { - x: number; - y: number; - radiusGrid: number; - explodeAtElapsedMs: number; +export type BombUpsertPayload = BombPlacedPayload & { + radiusGrid?: number; +}; + +/** 爆弾設置時に返す結果型 */ +export type BombPlacementResult = { + bombId: string; + payload: BombPlacedPayload; +}; + +/** 爆弾ペイロードから一意キーを生成する */ +export const createBombIdFromPayload = (payload: BombPlacedPayload): string => { + return `${payload.x}:${payload.y}:${payload.explodeAtElapsedMs}`; }; type BombManagerOptions = { @@ -34,7 +43,6 @@ private myId: string; private getElapsedMs: ElapsedMsProvider; private bombs = new Map(); - private nextLocalBombId = 1; private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; constructor({ worldContainer, players, myId, getElapsedMs }: BombManagerOptions) { @@ -45,29 +53,30 @@ } /** 自プレイヤー位置に爆弾を設置し,生成IDを返す */ - public placeBomb(): string | null { + public placeBomb(): BombPlacementResult | null { const me = this.players[this.myId]; if (!me || !(me instanceof LocalPlayerController)) return null; const elapsedMs = this.getElapsedMs(); - const { BOMB_COOLDOWN_MS, BOMB_FUSE_MS, BOMB_RADIUS_GRID } = config.GAME_CONFIG; + const { BOMB_COOLDOWN_MS, BOMB_FUSE_MS } = config.GAME_CONFIG; if (elapsedMs - this.lastBombPlacedElapsedMs < BOMB_COOLDOWN_MS) { return null; } const position = me.getPosition(); - const payload: BombUpsertPayload = { + const payload: BombPlacedPayload = { x: position.x, y: position.y, - radiusGrid: BOMB_RADIUS_GRID, explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, }; - const bombId = `local-${this.nextLocalBombId}`; - this.nextLocalBombId += 1; + const bombId = createBombIdFromPayload(payload); this.upsertBomb(bombId, payload); this.lastBombPlacedElapsedMs = elapsedMs; - return bombId; + return { + bombId, + payload, + }; } /** 指定IDの爆弾を追加または更新する */ @@ -78,7 +87,12 @@ current.destroy(); } - const bomb = new BombController(payload); + const resolvedPayload = { + ...payload, + radiusGrid: payload.radiusGrid ?? config.GAME_CONFIG.BOMB_RADIUS_GRID, + }; + + const bomb = new BombController(resolvedPayload); this.bombs.set(bombId, bomb); this.worldContainer.addChild(bomb.getDisplayObject()); } diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index fc29a67..8cb19a4 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -5,6 +5,7 @@ */ import { Container } from "pixi.js"; import type { + BombPlacedPayload, CurrentPlayersPayload, GameStartPayload, NewPlayerPayload, @@ -26,6 +27,7 @@ gameMap: GameMapController; onGameStart: (startTime: number) => void; onGameEnd: () => void; + onBombPlaced: (payload: BombPlacedPayload) => void; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -36,6 +38,7 @@ private gameMap: GameMapController; private onGameStart: (startTime: number) => void; private onGameEnd: () => void; + private onBombPlaced: (payload: BombPlacedPayload) => void; private isBound = false; private debugLog = (message: string) => { @@ -96,13 +99,18 @@ this.onGameEnd(); }; - constructor({ worldContainer, players, myId, gameMap, onGameStart, onGameEnd }: GameNetworkSyncOptions) { + private handleBombPlaced = (payload: BombPlacedPayload) => { + this.onBombPlaced(payload); + }; + + constructor({ worldContainer, players, myId, gameMap, onGameStart, onGameEnd, onBombPlaced }: GameNetworkSyncOptions) { this.worldContainer = worldContainer; this.players = players; this.myId = myId; this.gameMap = gameMap; this.onGameStart = onGameStart; this.onGameEnd = onGameEnd; + this.onBombPlaced = onBombPlaced; } public bind() { @@ -115,6 +123,7 @@ socketManager.game.onRemovePlayer(this.handleRemovePlayer); socketManager.game.onUpdateMapCells(this.handleUpdateMapCells); socketManager.game.onGameEnd(this.handleGameEnd); + socketManager.game.onBombPlaced(this.handleBombPlaced); this.isBound = true; } @@ -129,6 +138,7 @@ socketManager.game.offRemovePlayer(this.handleRemovePlayer); socketManager.game.offUpdateMapCells(this.handleUpdateMapCells); socketManager.game.offGameEnd(this.handleGameEnd); + socketManager.game.offBombPlaced(this.handleBombPlaced); this.isBound = false; } diff --git a/apps/server/src/logging/contracts/payloadByScope.ts b/apps/server/src/logging/contracts/payloadByScope.ts index 8bb3fb5..f0ec8b1 100644 --- a/apps/server/src/logging/contracts/payloadByScope.ts +++ b/apps/server/src/logging/contracts/payloadByScope.ts @@ -46,13 +46,21 @@ socketId: string; }; +/** NetworkのPLACE_BOMB不正ペイロードログ契約 */ +type NetworkPlaceBombLogPayload = { + event: typeof protocol.SocketEvents.PLACE_BOMB; + result: typeof logResults.IGNORED_INVALID_PAYLOAD; + socketId: string; +}; + /** Networkスコープのログ契約ユニオン */ type NetworkLogPayload = | NetworkConnectLogPayload | NetworkDisconnectLogPayload | NetworkJoinRoomLogPayload | NetworkPingLogPayload - | NetworkMoveLogPayload; + | NetworkMoveLogPayload + | NetworkPlaceBombLogPayload; /** GameUseCaseのSTART_GAMEログ契約 */ type GameUseCaseStartGameLogPayload = { diff --git a/apps/server/src/network/handlers/game/registerGameHandlers.ts b/apps/server/src/network/handlers/game/registerGameHandlers.ts index b7bd305..97052ea 100644 --- a/apps/server/src/network/handlers/game/registerGameHandlers.ts +++ b/apps/server/src/network/handlers/game/registerGameHandlers.ts @@ -16,7 +16,7 @@ import { movePlayerUseCase } from "@server/domains/game/application/useCases/movePlayerUseCase"; import { pingUseCase } from "@server/domains/game/application/useCases/pingUseCase"; import { createCommonHandlerContext } from "@server/network/handlers/CommonHandler"; -import { isMovePayload, isPingPayload } from "@server/network/validation/socketPayloadValidators"; +import { isBombPlacedPayload, isMovePayload, isPingPayload } from "@server/network/validation/socketPayloadValidators"; import { createServerSocketOnBridge } from "@server/network/handlers/socketEventBridge"; import { createPayloadGuard } from "@server/network/handlers/payloadGuard"; import { createGameOutputAdapter } from "./createGameOutputAdapter"; @@ -25,6 +25,7 @@ const gamePayloadValidators = { [protocol.SocketEvents.PING]: isPingPayload, [protocol.SocketEvents.MOVE]: isMovePayload, + [protocol.SocketEvents.PLACE_BOMB]: isBombPlacedPayload, } as const; /** ゲームイベントの購読とユースケース呼び出しを設定する */ @@ -46,6 +47,10 @@ protocol.SocketEvents.MOVE, gamePayloadValidators[protocol.SocketEvents.MOVE] ); + const guardPlaceBombPayload = guardOnEvent( + protocol.SocketEvents.PLACE_BOMB, + gamePayloadValidators[protocol.SocketEvents.PLACE_BOMB] + ); // 遅延計測用のPINGを検証しPONGを返す onEvent(protocol.SocketEvents.PING, (clientTime) => { @@ -91,4 +96,18 @@ move: data, }); }); + + // 爆弾設置入力を検証し,所属ルームへ同期配信する + onEvent(protocol.SocketEvents.PLACE_BOMB, (data) => { + if (!guardPlaceBombPayload(data)) { + return; + } + + const roomId = roomManager.getRoomByPlayerId(socket.id)?.roomId; + if (!roomId) { + return; + } + + common.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, data); + }); }; diff --git a/apps/server/src/network/handlers/payloadGuard.ts b/apps/server/src/network/handlers/payloadGuard.ts index dc308c4..5bacded 100644 --- a/apps/server/src/network/handlers/payloadGuard.ts +++ b/apps/server/src/network/handlers/payloadGuard.ts @@ -10,7 +10,8 @@ type PayloadGuardEventName = | typeof protocol.SocketEvents.JOIN_ROOM | typeof protocol.SocketEvents.PING - | typeof protocol.SocketEvents.MOVE; + | typeof protocol.SocketEvents.MOVE + | typeof protocol.SocketEvents.PLACE_BOMB; type EventBoundPayloadGuard = (payload: unknown) => payload is TPayload; /** @@ -50,6 +51,14 @@ socketId, }); break; + + case protocol.SocketEvents.PLACE_BOMB: + logEvent(logScopes.NETWORK, { + event: protocol.SocketEvents.PLACE_BOMB, + result: logResults.IGNORED_INVALID_PAYLOAD, + socketId, + }); + break; } return false; diff --git a/apps/server/src/network/validation/socketPayloadValidators.ts b/apps/server/src/network/validation/socketPayloadValidators.ts index 0345b37..ceae7ba 100644 --- a/apps/server/src/network/validation/socketPayloadValidators.ts +++ b/apps/server/src/network/validation/socketPayloadValidators.ts @@ -2,7 +2,7 @@ * socketPayloadValidators * ソケット受信ペイロードの型ガードを提供する */ -import type { playerTypes, roomTypes } from "@repo/shared"; +import type { playerTypes, roomTypes, BombPlacedPayload } from "@repo/shared"; import type { PingPayload } from "@repo/shared"; const isFiniteNumber = (value: unknown): value is number => { @@ -28,6 +28,20 @@ return isFiniteNumber(candidate.x) && isFiniteNumber(candidate.y); }; +/** PLACE_BOMBイベントのペイロードが爆弾情報であるか判定する */ +export const isBombPlacedPayload = (value: unknown): value is BombPlacedPayload => { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + return ( + isFiniteNumber(candidate.x) + && isFiniteNumber(candidate.y) + && isFiniteNumber(candidate.explodeAtElapsedMs) + ); +}; + /** JOIN_ROOMイベントのペイロードが参加情報であるか判定する */ export const isJoinRoomPayload = (value: unknown): value is roomTypes.JoinRoomPayload => { if (typeof value !== "object" || value === null) {