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 2e5f871..c2dd86b 100644 --- a/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts +++ b/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts @@ -3,7 +3,7 @@ * マップ同期イベントの受信処理を担当する * セル更新データをマップへ適用する */ -import type { UpdateMapCellsPayload } from "@repo/shared"; +import type { domain } from "@repo/shared"; import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; /** MapSyncHandler の初期化入力 */ @@ -20,7 +20,7 @@ } /** マップセル更新を適用する */ - public handleUpdateMapCells = (updates: UpdateMapCellsPayload): void => { + 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/entities/map/MapStore.ts b/apps/server/src/domains/game/entities/map/MapStore.ts index bf09972..191417a 100644 --- a/apps/server/src/domains/game/entities/map/MapStore.ts +++ b/apps/server/src/domains/game/entities/map/MapStore.ts @@ -5,7 +5,6 @@ import { domain } from "@repo/shared"; import { createInitialGridColors } from "./mapGrid.js"; import { paintCellIfChanged } from "./mapPainting.js"; -import { drainPendingUpdates } from "./mapUpdates.js"; /** ルーム内マップの塗り状態と更新差分を管理するストア */ export class MapStore { @@ -34,9 +33,12 @@ /** * 溜まっている差分を取得し,キューをクリアする(ループ送信時に使用) + * 参照をそのまま返却し新しい空配列で差し替えることでコピーを回避する */ public getAndClearUpdates(): domain.game.gridMap.CellUpdate[] { - return drainPendingUpdates(this.pendingUpdates); + const updates = this.pendingUpdates; + this.pendingUpdates = []; + return updates; } /** 現在のマップ塗り状態をスナップショットとして返す */ 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 deleted file mode 100644 index 77ce63a..0000000 --- a/apps/server/src/domains/game/entities/map/mapUpdates.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * mapUpdates - * マップ差分キューの取り出しとクリア処理を提供する - */ -import { domain } from "@repo/shared"; - -/** 差分キューを配列として返却し,キューを空にする */ -export const drainPendingUpdates = ( - pendingUpdates: domain.game.gridMap.CellUpdate[] -): domain.game.gridMap.CellUpdate[] => { - const updates = [...pendingUpdates]; - pendingUpdates.length = 0; - return updates; -}; diff --git a/apps/server/src/domains/game/loop/GameLoop.ts b/apps/server/src/domains/game/loop/GameLoop.ts index e2471d1..a9c21ad 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"; @@ -42,6 +47,9 @@ onBotBombHit?: (targetPlayerId: string, bombId: string) => void; }; +/** プレイヤーのグリッド位置キャッシュを含むエントリ */ +type PlayerGridCacheEntry = PlayerGridEntry & { player: Player }; + /** ルーム内ゲーム進行を定周期で実行するループ管理クラス */ export class GameLoop { private loopId: NodeJS.Timeout | null = null; @@ -246,14 +254,24 @@ ): domain.game.tick.TickData["playerUpdates"] { const changedPlayers: domain.game.tick.TickData["playerUpdates"] = []; + // 全プレイヤーのグリッド位置を1度だけ計算してキャッシュする + const gridEntries: PlayerGridCacheEntry[] = []; this.players.forEach((player) => { - activePlayerIds.add(player.id); - const gridIndex = getPlayerGridIndex(player); - if (gridIndex !== null) { - this.mapStore.paintCell(gridIndex, player.teamId); - } + gridEntries.push({ + playerId: player.id, + gridIndex: getPlayerGridIndex(player), + teamId: player.teamId, + player, + }); + }); - // 送信用のプレイヤーデータを構築 + // 競合判定を経てマップを塗る + this.paintUncontestedCells(gridEntries); + + // プレイヤー差分を収集する + for (const { playerId, player } of gridEntries) { + activePlayerIds.add(playerId); + const playerData: domain.game.tick.PlayerPositionUpdate = { id: player.id, x: player.x, @@ -270,11 +288,22 @@ changedPlayers.push(playerData); this.lastSentPlayers.set(player.id, playerData); } - }); + } return changedPlayers; } + /** 競合判定を行い,単一チームが占有するセルのみを塗る */ + private paintUncontestedCells(gridEntries: PlayerGridCacheEntry[]): void { + const cellTeamMap = resolveUncontestedCells(gridEntries); + + for (const { gridIndex, player } of gridEntries) { + if (gridIndex !== null && isCellPaintable(cellTeamMap, gridIndex)) { + this.mapStore.paintCell(gridIndex, player.teamId); + } + } + } + private cleanupInactivePlayerSnapshots(activePlayerIds: Set): void { Array.from(this.lastSentPlayers.keys()).forEach((playerId) => { if (!activePlayerIds.has(playerId)) { 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) => { diff --git a/packages/shared/src/domains/game/gridMap/gridMap.type.ts b/packages/shared/src/domains/game/gridMap/gridMap.type.ts index c59a2da..327c6cd 100644 --- a/packages/shared/src/domains/game/gridMap/gridMap.type.ts +++ b/packages/shared/src/domains/game/gridMap/gridMap.type.ts @@ -14,3 +14,10 @@ index: number; teamId: number; } + +/** + * teamId別にグループ化したマップ差分更新 + * キーは teamId(文字列),値は塗り替え対象セルの index 配列 + * 帯域最適化のためキー名の重複を排除した形式で送受信する + */ +export type GroupedCellUpdates = Record; diff --git a/packages/shared/src/domains/game/gridMap/groupedCellUpdates.ts b/packages/shared/src/domains/game/gridMap/groupedCellUpdates.ts new file mode 100644 index 0000000..758cd87 --- /dev/null +++ b/packages/shared/src/domains/game/gridMap/groupedCellUpdates.ts @@ -0,0 +1,43 @@ +/** + * groupedCellUpdates + * CellUpdate配列とGroupedCellUpdatesの相互変換ユーティリティを提供する + * サーバー送信時のグループ化とクライアント受信時の展開を担う + */ +import type { CellUpdate, GroupedCellUpdates } from "./gridMap.type"; + +/** CellUpdate 配列を teamId 別にグループ化する(送信時) */ +export const groupCellUpdates = ( + updates: CellUpdate[], +): GroupedCellUpdates => { + const grouped: GroupedCellUpdates = {}; + + for (const { index, teamId } of updates) { + const key = String(teamId); + const list = grouped[key]; + + if (list) { + list.push(index); + } else { + grouped[key] = [index]; + } + } + + return grouped; +}; + +/** GroupedCellUpdates を CellUpdate 配列へ展開する(受信時) */ +export const ungroupCellUpdates = ( + grouped: GroupedCellUpdates, +): CellUpdate[] => { + const updates: CellUpdate[] = []; + + for (const [teamIdStr, indices] of Object.entries(grouped)) { + const teamId = Number(teamIdStr); + + for (const index of indices) { + updates.push({ index, teamId }); + } + } + + return updates; +}; diff --git a/packages/shared/src/domains/game/gridMap/index.ts b/packages/shared/src/domains/game/gridMap/index.ts index cef8bbc..af9db0a 100644 --- a/packages/shared/src/domains/game/gridMap/index.ts +++ b/packages/shared/src/domains/game/gridMap/index.ts @@ -5,6 +5,8 @@ */ /** グリッドマップ関連の型を再公開する */ -export type { MapState, CellUpdate } from "./gridMap.type"; +export type { MapState, CellUpdate, GroupedCellUpdates } from "./gridMap.type"; /** グリッド座標変換ロジックを再公開する */ export { getGridIndexFromPosition } from "./gridMap.logic"; +/** CellUpdate配列とGroupedCellUpdatesの相互変換を再公開する */ +export { groupCellUpdates, ungroupCellUpdates } from "./groupedCellUpdates"; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index 7090983..80752ad 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -4,7 +4,7 @@ * プレイヤー差分,マップ差分,開始終了系の契約を集約する */ import type { PlayerPositionUpdate } from "../../domains/game/tick/tick.type"; -import type { CellUpdate } from "../../domains/game/gridMap/gridMap.type"; +import type { GroupedCellUpdates } from "../../domains/game/gridMap/gridMap.type"; import type { MovePayload as PlayerMovePayload, PlayerData, @@ -52,8 +52,8 @@ /** current-players イベントで送受信するプレイヤー一覧 */ export type CurrentPlayersPayload = PlayerSnapshotPayload; -/** update-map-cells イベントで送受信するマップ差分配列 */ -export type UpdateMapCellsPayload = CellUpdate[]; +/** update-map-cells イベントで送受信するグループ化マップ差分 */ +export type UpdateMapCellsPayload = GroupedCellUpdates; /** * new-player イベントで送受信するプレイヤー情報