diff --git a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts index 1df634d..f38e05c 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -9,6 +9,7 @@ BombPlacedPayload, PlayerDeadPayload, } from "@repo/shared"; +import { domain } from "@repo/shared"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; @@ -105,7 +106,8 @@ this.playerSyncHandler.handleRemovePlayer(payload); }, onReceivedUpdateMapCells: (payload) => { - this.mapSyncHandler.handleUpdateMapCells(payload); + const updates = domain.game.gridMap.ungroupCellUpdates(payload); + this.mapSyncHandler.handleUpdateMapCells(updates); }, onReceivedGameEnd: () => { this.onGameEnded(); diff --git a/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts index 3762863..c2dd86b 100644 --- a/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts +++ b/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts @@ -1,10 +1,9 @@ /** * MapSyncHandler * マップ同期イベントの受信処理を担当する - * グループ化セル更新データを展開してマップへ適用する + * セル更新データをマップへ適用する */ -import type { UpdateMapCellsPayload } from "@repo/shared"; -import { domain } from "@repo/shared"; +import type { domain } from "@repo/shared"; import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; /** MapSyncHandler の初期化入力 */ @@ -20,9 +19,8 @@ this.gameMap = gameMap; } - /** グループ化マップセル更新を展開して適用する */ - public handleUpdateMapCells = (grouped: UpdateMapCellsPayload): void => { - const updates = domain.game.gridMap.ungroupCellUpdates(grouped); + /** マップセル更新を適用する */ + public handleUpdateMapCells = (updates: domain.game.gridMap.CellUpdate[]): void => { this.gameMap.updateCells(updates); }; } \ No newline at end of file diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 2c08386..c071fea 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -14,7 +14,6 @@ GameStartPayload, PongPayload, RemovePlayerPayload, - UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; import type { GameSessionCallbacks } from "../services/GameRoomSession"; @@ -55,7 +54,7 @@ ): void; publishMapCellUpdatesToRoom( roomId: domain.room.Room["roomId"], - cellUpdates: UpdateMapCellsPayload, + cellUpdates: domain.game.gridMap.CellUpdate[], ): void; publishGameEndToRoom(roomId: domain.room.Room["roomId"]): void; publishGameResultToRoom( diff --git a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts index 96de2f4..c800c3c 100644 --- a/apps/server/src/domains/game/application/useCases/startGameUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/startGameUseCase.ts @@ -14,7 +14,6 @@ logScopes, } from "@server/logging/index"; import { createBotBombActionHandler } from "../services/bot/index.js"; -import { domain } from "@repo/shared"; const excludeRecipientFromPlayerUpdates = < TPlayerUpdate extends { id: string }, @@ -84,10 +83,7 @@ } if (tickData.cellUpdates.length > 0) { - output.publishMapCellUpdatesToRoom( - roomId, - domain.game.gridMap.groupCellUpdates(tickData.cellUpdates), - ); + output.publishMapCellUpdatesToRoom(roomId, tickData.cellUpdates); } }, onGameEnd: (resultPayload) => { diff --git a/apps/server/src/domains/game/entities/map/MapStore.ts b/apps/server/src/domains/game/entities/map/MapStore.ts index bf09972..d02fffd 100644 --- a/apps/server/src/domains/game/entities/map/MapStore.ts +++ b/apps/server/src/domains/game/entities/map/MapStore.ts @@ -5,14 +5,14 @@ import { domain } from "@repo/shared"; import { createInitialGridColors } from "./mapGrid.js"; import { paintCellIfChanged } from "./mapPainting.js"; -import { drainPendingUpdates } from "./mapUpdates.js"; +import { swapPendingUpdates } from "./mapUpdates.js"; /** ルーム内マップの塗り状態と更新差分を管理するストア */ export class MapStore { // 全マスの現在の色(teamId)を保持 private gridColors: number[]; // 次回の送信ループで送る差分リスト - private pendingUpdates: domain.game.gridMap.CellUpdate[]; + public pendingUpdates: domain.game.gridMap.CellUpdate[]; constructor() { // 初期状態は -1 (無色) などで初期化 @@ -36,7 +36,7 @@ * 溜まっている差分を取得し,キューをクリアする(ループ送信時に使用) */ public getAndClearUpdates(): domain.game.gridMap.CellUpdate[] { - return drainPendingUpdates(this.pendingUpdates); + return swapPendingUpdates(this); } /** 現在のマップ塗り状態をスナップショットとして返す */ diff --git a/apps/server/src/domains/game/entities/map/mapContestResolver.ts b/apps/server/src/domains/game/entities/map/mapContestResolver.ts new file mode 100644 index 0000000..bc77662 --- /dev/null +++ b/apps/server/src/domains/game/entities/map/mapContestResolver.ts @@ -0,0 +1,47 @@ +/** + * mapContestResolver + * 同一セルに複数チームが重なった場合の競合判定を提供する + * 純粋関数として切り出し,単体テストを容易にする + */ + +/** 同一セルに異なるチームが存在していることを示すセンチネル値 */ +export const CONTESTED_CELL = -2; + +/** プレイヤーごとのグリッド位置情報 */ +export type PlayerGridEntry = { + playerId: string; + gridIndex: number | null; + teamId: number; +}; + +/** + * 各セルの占有チームを判定し,競合セルは CONTESTED_CELL でマークする + * @returns セルインデックス → チームID(競合時は CONTESTED_CELL)のマップ + */ +export const resolveUncontestedCells = ( + entries: PlayerGridEntry[], +): Map => { + const cellTeamMap = new Map(); + + for (const { gridIndex, teamId } of entries) { + if (gridIndex === null) continue; + + const existing = cellTeamMap.get(gridIndex); + if (existing === undefined) { + cellTeamMap.set(gridIndex, teamId); + } else if (existing !== CONTESTED_CELL && existing !== teamId) { + cellTeamMap.set(gridIndex, CONTESTED_CELL); + } + } + + return cellTeamMap; +}; + +/** 指定セルが塗り可能(競合でない)かを判定する */ +export const isCellPaintable = ( + cellTeamMap: Map, + gridIndex: number, +): boolean => { + const ownerTeamId = cellTeamMap.get(gridIndex); + return ownerTeamId !== undefined && ownerTeamId !== CONTESTED_CELL; +}; diff --git a/apps/server/src/domains/game/entities/map/mapUpdates.ts b/apps/server/src/domains/game/entities/map/mapUpdates.ts index 77ce63a..4446d55 100644 --- a/apps/server/src/domains/game/entities/map/mapUpdates.ts +++ b/apps/server/src/domains/game/entities/map/mapUpdates.ts @@ -4,11 +4,14 @@ */ import { domain } from "@repo/shared"; -/** 差分キューを配列として返却し,キューを空にする */ -export const drainPendingUpdates = ( - pendingUpdates: domain.game.gridMap.CellUpdate[] +/** + * 差分キューの参照をそのまま返却し,呼び出し元の配列を新しい空配列で差し替える + * スプレッドコピーを避けてゼロコピーで返却する + */ +export const swapPendingUpdates = ( + owner: { pendingUpdates: domain.game.gridMap.CellUpdate[] }, ): domain.game.gridMap.CellUpdate[] => { - const updates = [...pendingUpdates]; - pendingUpdates.length = 0; + const updates = owner.pendingUpdates; + owner.pendingUpdates = []; return updates; }; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index d3f3d7b..bb3c464 100644 --- a/apps/server/src/domains/game/loop/GameLoop.ts +++ b/apps/server/src/domains/game/loop/GameLoop.ts @@ -5,6 +5,11 @@ import { Player } from "../entities/player/Player.js"; import { MapStore } from "../entities/map/MapStore"; import { getPlayerGridIndex } from "../entities/player/playerPosition.js"; +import { + resolveUncontestedCells, + isCellPaintable, + type PlayerGridEntry, +} from "../entities/map/mapContestResolver.js"; import { config } from "@server/config"; import { domain } from "@repo/shared"; import type { PlaceBombPayload } from "@repo/shared"; @@ -246,32 +251,26 @@ ): domain.game.tick.TickData["playerUpdates"] { const changedPlayers: domain.game.tick.TickData["playerUpdates"] = []; - // パス1: 各セルにどのチームが存在するか集計する - // 値が -1 のセルは複数チームが競合していることを示す - const cellTeamMap = new Map(); - const CONTESTED = -1; - + // 全プレイヤーのグリッド位置を1度だけ計算してキャッシュする + const gridEntries: (PlayerGridEntry & { player: Player })[] = []; this.players.forEach((player) => { - const gridIndex = getPlayerGridIndex(player); - if (gridIndex === null) return; - - const existing = cellTeamMap.get(gridIndex); - if (existing === undefined) { - cellTeamMap.set(gridIndex, player.teamId); - } else if (existing !== CONTESTED && existing !== player.teamId) { - cellTeamMap.set(gridIndex, CONTESTED); - } + gridEntries.push({ + playerId: player.id, + gridIndex: getPlayerGridIndex(player), + teamId: player.teamId, + player, + }); }); - // パス2: 競合のないセルのみ塗り,プレイヤー差分を収集する - this.players.forEach((player) => { - activePlayerIds.add(player.id); - const gridIndex = getPlayerGridIndex(player); - if (gridIndex !== null) { - const ownerTeamId = cellTeamMap.get(gridIndex); - if (ownerTeamId !== undefined && ownerTeamId !== CONTESTED) { - this.mapStore.paintCell(gridIndex, player.teamId); - } + // 競合判定: 同一セルに異なるチームがいるかを解決する + const cellTeamMap = resolveUncontestedCells(gridEntries); + + // 競合のないセルのみ塗り,プレイヤー差分を収集する + for (const { playerId, gridIndex, player } of gridEntries) { + activePlayerIds.add(playerId); + + if (gridIndex !== null && isCellPaintable(cellTeamMap, gridIndex)) { + this.mapStore.paintCell(gridIndex, player.teamId); } // 送信用のプレイヤーデータを構築 @@ -291,7 +290,7 @@ changedPlayers.push(playerData); this.lastSentPlayers.set(player.id, playerData); } - }); + } return changedPlayers; } diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index f897f63..c8f85b3 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -3,7 +3,7 @@ * ゲーム系ユースケースから利用する送信関数群を生成する */ import { Server } from "socket.io"; -import { contracts as protocol } from "@repo/shared"; +import { contracts as protocol, domain as domainNs } from "@repo/shared"; import type { BombPlacedAckPayload, BombPlacedPayload, @@ -14,7 +14,6 @@ PongPayload, CurrentPlayersPayload, RemovePlayerPayload, - UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; import type { @@ -64,12 +63,13 @@ }, publishMapCellUpdatesToRoom: ( roomId: RoomId, - cellUpdates: UpdateMapCellsPayload, + cellUpdates: domainNs.game.gridMap.CellUpdate[], ) => { + const grouped = domainNs.game.gridMap.groupCellUpdates(cellUpdates); common.emitToRoom( roomId, protocol.SocketEvents.UPDATE_MAP_CELLS, - cellUpdates, + grouped, ); }, publishGameEndToRoom: (roomId: RoomId) => {