diff --git a/apps/server/src/network/adapters/realtimeRoomSyncState.ts b/apps/server/src/network/adapters/realtimeRoomSyncState.ts index d712228..ef7b6b2 100644 --- a/apps/server/src/network/adapters/realtimeRoomSyncState.ts +++ b/apps/server/src/network/adapters/realtimeRoomSyncState.ts @@ -5,33 +5,117 @@ import type { domain } from "@repo/shared"; type RoomId = domain.room.Room["roomId"]; +type SocketId = string; + +/** ソケットごとのAOI中心セル座標 */ +export type SocketAoiCell = { + col: number; + row: number; +}; /** ルーム単位のプレイヤー送信座標キャッシュの構造 */ export type RoomPlayerPositionCache = Map; +/** ルーム単位のソケット別プレイヤー送信座標キャッシュの構造 */ +type RoomPlayerPositionCacheBySocketId = Map; + +/** ルーム単位のソケット別AOI中心セルキャッシュの構造 */ +type RoomAoiCellCache = Map; + +/** ルーム単位のソケット別可視IDキャッシュの構造 */ +type RoomVisibleIdsCache = Map>; + /** 高頻度同期向けのルーム状態ストア操作契約 */ export type RealtimeRoomSyncStateStore = { - getPlayerPositionCache: (roomId: RoomId) => RoomPlayerPositionCache; + getPlayerPositionCache: ( + roomId: RoomId, + socketId: SocketId, + ) => RoomPlayerPositionCache; + getLastAoiCell: (roomId: RoomId, socketId: SocketId) => SocketAoiCell | undefined; + setLastAoiCell: (roomId: RoomId, socketId: SocketId, cell: SocketAoiCell) => void; + getVisiblePlayerIds: (roomId: RoomId, socketId: SocketId) => Set; + getVisibleBombIds: (roomId: RoomId, socketId: SocketId) => Set; + getVisibleHurricaneIds: (roomId: RoomId, socketId: SocketId) => Set; resetRoom: (roomId: RoomId) => void; }; /** 高頻度同期向けのルーム状態ストアを生成する */ export const createRealtimeRoomSyncStateStore = (): RealtimeRoomSyncStateStore => { - const playerPositionCacheByRoomId = new Map(); + const playerPositionCacheByRoomId = new Map< + RoomId, + RoomPlayerPositionCacheBySocketId + >(); + const aoiCellCacheByRoomId = new Map(); + const visiblePlayerIdsByRoomId = new Map(); + const visibleBombIdsByRoomId = new Map(); + const visibleHurricaneIdsByRoomId = new Map(); + + const getOrCreateSocketScopedCache = ( + roomCache: Map>, + roomId: RoomId, + socketId: SocketId, + createValue: () => T, + ): T => { + const bySocketId = roomCache.get(roomId) ?? new Map(); + roomCache.set(roomId, bySocketId); + + const existing = bySocketId.get(socketId); + if (existing) { + return existing; + } + + const created = createValue(); + bySocketId.set(socketId, created); + return created; + }; return { - getPlayerPositionCache: (roomId) => { - const existing = playerPositionCacheByRoomId.get(roomId); - if (existing) { - return existing; - } - - const created: RoomPlayerPositionCache = new Map(); - playerPositionCacheByRoomId.set(roomId, created); - return created; + getPlayerPositionCache: (roomId, socketId) => { + return getOrCreateSocketScopedCache( + playerPositionCacheByRoomId, + roomId, + socketId, + () => new Map(), + ); + }, + getLastAoiCell: (roomId, socketId) => { + return aoiCellCacheByRoomId.get(roomId)?.get(socketId); + }, + setLastAoiCell: (roomId, socketId, cell) => { + const aoiCellCache = aoiCellCacheByRoomId.get(roomId) ?? new Map(); + aoiCellCache.set(socketId, cell); + aoiCellCacheByRoomId.set(roomId, aoiCellCache); + }, + getVisiblePlayerIds: (roomId, socketId) => { + return getOrCreateSocketScopedCache( + visiblePlayerIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + }, + getVisibleBombIds: (roomId, socketId) => { + return getOrCreateSocketScopedCache( + visibleBombIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + }, + getVisibleHurricaneIds: (roomId, socketId) => { + return getOrCreateSocketScopedCache( + visibleHurricaneIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); }, resetRoom: (roomId) => { playerPositionCacheByRoomId.delete(roomId); + aoiCellCacheByRoomId.delete(roomId); + visiblePlayerIdsByRoomId.delete(roomId); + visibleBombIdsByRoomId.delete(roomId); + visibleHurricaneIdsByRoomId.delete(roomId); }, }; }; diff --git a/apps/server/src/network/handlers/createOutputAdapters.ts b/apps/server/src/network/handlers/createOutputAdapters.ts index abd3bd5..bf5690a 100644 --- a/apps/server/src/network/handlers/createOutputAdapters.ts +++ b/apps/server/src/network/handlers/createOutputAdapters.ts @@ -15,6 +15,10 @@ createRoomOutputAdapter, type RoomOutputAdapter, } from "./room/createRoomOutputAdapter"; +import type { + FindGameByRoomPort, + FindRoomByIdPort, +} from "@server/domains/room/application/ports/roomUseCasePorts"; /** 接続単位で利用するゲームとルームの出力アダプタ集合 */ export type SocketOutputAdapters = { @@ -22,6 +26,11 @@ room: RoomOutputAdapter; }; +type GameOutputAdapterDeps = { + roomManager: FindRoomByIdPort; + runtimeRegistry: FindGameByRoomPort; +}; + /** 切断処理で利用するゲームとルームの出力アダプタ集合 */ type DisconnectOutputAdapters = { game: GameDisconnectOutputAdapter; @@ -32,11 +41,12 @@ export const createSocketOutputAdapters = ( io: Server, socket: Socket, + deps: GameOutputAdapterDeps, ): SocketOutputAdapters => { const common = createCommonHandlerContext(io, socket); return { - game: createGameOutputAdapter(common), + game: createGameOutputAdapter(common, deps), room: createRoomOutputAdapter(common), }; }; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index dd718e4..845f4ff 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -32,8 +32,14 @@ import { createRealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; +import { config } from "@server/config"; +import type { + FindGameByRoomPort, + FindRoomByIdPort, +} from "@server/domains/room/application/ports/roomUseCasePorts"; type RoomId = domain.room.Room["roomId"]; +type SocketId = string; type ReliableRoomEvent = | typeof protocol.SocketEvents.UPDATE_PLAYERS @@ -62,10 +68,115 @@ /** 共通送信コンテキストからゲーム出力アダプターを生成する */ export const createGameOutputAdapter = ( common: CommonHandlerContext, + deps: { + roomManager: FindRoomByIdPort; + runtimeRegistry: FindGameByRoomPort; + }, ): GameOutputAdapter => { const { reliable } = common; + const { roomManager, runtimeRegistry } = deps; const realtimeRoomSyncState = createRealtimeRoomSyncStateStore(); + const getConnectedSocketIdsInRoom = (roomId: RoomId): SocketId[] => { + const room = roomManager.getRoomById(roomId); + if (!room) { + return []; + } + + return room.players + .map((player) => player.id) + .filter((playerId) => !isBotPlayerId(playerId)); + }; + + const getPlayersInRoom = (roomId: RoomId): domain.game.player.PlayerData[] => { + const gameManager = runtimeRegistry.getGameManagerByRoomId(roomId); + if (!gameManager) { + return []; + } + + return gameManager.getRoomPlayers(); + }; + + const getAoiWindowForViewer = ( + viewer: domain.game.player.PlayerData, + ): { minCol: number; maxCol: number; minRow: number; maxRow: number } => { + const centerCell = domainNs.game.aoi.resolveAoiCellFromPosition( + viewer.x, + viewer.y, + config.GAME_CONFIG.AOI_CELL_SIZE, + ); + + return domainNs.game.aoi.resolveAoiWindowFromCell( + centerCell, + config.GAME_CONFIG.AOI_WINDOW_COLS, + config.GAME_CONFIG.AOI_WINDOW_ROWS, + ); + }; + + const isInViewerAoi = ( + target: { x: number; y: number }, + aoiWindow: { minCol: number; maxCol: number; minRow: number; maxRow: number }, + ): boolean => { + return domainNs.game.aoi.isPositionInAoiWindow( + target.x, + target.y, + aoiWindow, + config.GAME_CONFIG.AOI_CELL_SIZE, + ); + }; + + const updateViewerAoiCellCache = ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ): void => { + const nextCell = domainNs.game.aoi.resolveAoiCellFromPosition( + viewer.x, + viewer.y, + config.GAME_CONFIG.AOI_CELL_SIZE, + ); + const previousCell = realtimeRoomSyncState.getLastAoiCell(roomId, viewerId); + + if (previousCell && domainNs.game.aoi.isSameAoiCell(previousCell, nextCell)) { + return; + } + + realtimeRoomSyncState.setLastAoiCell(roomId, viewerId, nextCell); + }; + + const syncVisiblePlayersByViewer = ( + roomId: RoomId, + viewerId: SocketId, + visiblePlayers: domain.game.player.PlayerData[], + ): void => { + const visibleIdsCache = realtimeRoomSyncState.getVisiblePlayerIds(roomId, viewerId); + const viewerPositionCache = realtimeRoomSyncState.getPlayerPositionCache( + roomId, + viewerId, + ); + const nextVisibleIds = new Set(visiblePlayers.map((player) => player.id)); + + visiblePlayers.forEach((player) => { + if (!visibleIdsCache.has(player.id)) { + reliable.emitToSocketById(viewerId, protocol.SocketEvents.NEW_PLAYER, player); + } + }); + + visibleIdsCache.forEach((playerId) => { + if (nextVisibleIds.has(playerId)) { + return; + } + + viewerPositionCache.delete(playerId); + reliable.emitToSocketById(viewerId, protocol.SocketEvents.REMOVE_PLAYER, playerId); + }); + + visibleIdsCache.clear(); + nextVisibleIds.forEach((playerId) => { + visibleIdsCache.add(playerId); + }); + }; + const emitReliableToRoom = ( roomId: RoomId, event: ReliableRoomEvent, @@ -87,18 +198,62 @@ roomId: RoomId, players: UpdatePlayersPayload, ) => { - const quantizedPlayers = quantizeUpdatePlayersPayload(players); - const changedPlayers = collectChangedUpdatePlayersPayload( - quantizedPlayers, - realtimeRoomSyncState.getPlayerPositionCache(roomId), - ); - - if (changedPlayers.length === 0) { + const roomPlayers = getPlayersInRoom(roomId); + if (roomPlayers.length === 0) { return; } - emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_PLAYERS, changedPlayers); + const quantizedPlayers = quantizeUpdatePlayersPayload(players); + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(roomId); + + recipientSocketIds.forEach((viewerId) => { + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = getAoiWindowForViewer(viewer); + + const visibleSnapshotPlayers = roomPlayers.filter((player) => { + if (player.id === viewerId) { + return false; + } + + return isInViewerAoi(player, aoiWindow); + }); + syncVisiblePlayersByViewer(roomId, viewerId, visibleSnapshotPlayers); + + const visibleDeltaPlayers = quantizedPlayers.filter((player) => { + if (player.id === viewerId) { + return false; + } + + return isInViewerAoi(player, aoiWindow); + }); + + const viewerPositionCache = realtimeRoomSyncState.getPlayerPositionCache( + roomId, + viewerId, + ); + const changedPlayers = collectChangedUpdatePlayersPayload( + visibleDeltaPlayers, + viewerPositionCache, + ); + + if (changedPlayers.length === 0) { + return; + } + + reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.UPDATE_PLAYERS, + changedPlayers, + ); + }); }, + publishMapCellUpdatesToRoom: ( roomId: RoomId, cellUpdates: domainNs.game.gridMap.CellUpdate[], @@ -140,17 +295,32 @@ ownerSocketId: string, payload: BombPlacedPayload, ) => { - if (isBotPlayerId(ownerSocketId)) { - reliable.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); + const roomPlayers = getPlayersInRoom(roomId); + if (roomPlayers.length === 0) { return; } - reliable.emitToRoomExceptSocket( - roomId, - ownerSocketId, - protocol.SocketEvents.BOMB_PLACED, - payload, - ); + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(roomId); + + recipientSocketIds.forEach((viewerId) => { + if (viewerId === ownerSocketId && !isBotPlayerId(ownerSocketId)) { + return; + } + + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = getAoiWindowForViewer(viewer); + if (!isInViewerAoi(payload, aoiWindow)) { + return; + } + + reliable.emitToSocketById(viewerId, protocol.SocketEvents.BOMB_PLACED, payload); + }); }, publishBombPlacedAckToSocket: ( socketId: string, diff --git a/apps/server/src/network/handlers/registration/createConnectionRegistrationContext.ts b/apps/server/src/network/handlers/registration/createConnectionRegistrationContext.ts index e9a17e3..8a29625 100644 --- a/apps/server/src/network/handlers/registration/createConnectionRegistrationContext.ts +++ b/apps/server/src/network/handlers/registration/createConnectionRegistrationContext.ts @@ -35,6 +35,9 @@ return { deps, - socketOutputAdapters: createSocketOutputAdapters(deps.io, deps.socket), + socketOutputAdapters: createSocketOutputAdapters(deps.io, deps.socket, { + roomManager: deps.roomManager, + runtimeRegistry: deps.runtimeRegistry, + }), }; }; diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index f3670ce..2664d96 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -14,6 +14,12 @@ /** AOI同期で利用する1セルのグリッド幅 */ const AOI_CELL_SIZE = 3 as const; +/** AOI同期で利用する横方向の窓サイズ(AOIセル数) */ +const AOI_WINDOW_COLS = 5 as const; + +/** AOI同期で利用する縦方向の窓サイズ(AOIセル数) */ +const AOI_WINDOW_ROWS = 3 as const; + /** フィールドサイズ種別ごとのAOIセル数と推奨人数レンジ */ const FIELD_PRESETS = { SMALL: { @@ -70,6 +76,8 @@ // AOI設定(クライアント/サーバー契約) AOI_CELL_SIZE, + AOI_WINDOW_COLS, + AOI_WINDOW_ROWS, FIELD_PRESETS, DEFAULT_FIELD_PRESET, diff --git a/packages/shared/src/domains/game/aoi/aoi.logic.ts b/packages/shared/src/domains/game/aoi/aoi.logic.ts new file mode 100644 index 0000000..06f9620 --- /dev/null +++ b/packages/shared/src/domains/game/aoi/aoi.logic.ts @@ -0,0 +1,71 @@ +/** + * aoi.logic + * AOIセル座標とAOI窓の計算ロジックを提供する + * クライアントとサーバーの可視範囲判定で共通利用する + */ +import { GAME_CONFIG } from "../../../config/gameConfig"; + +/** AOIセル座標を表す型 */ +export type AoiCell = { + col: number; + row: number; +}; + +/** AOI窓の境界を表す型 */ +export type AoiWindow = { + minCol: number; + maxCol: number; + minRow: number; + maxRow: number; +}; + +/** 座標からAOIセル座標を解決する */ +export const resolveAoiCellFromPosition = ( + x: number, + y: number, + aoiCellSize: number = GAME_CONFIG.AOI_CELL_SIZE, +): AoiCell => { + return { + col: Math.floor(x / aoiCellSize), + row: Math.floor(y / aoiCellSize), + }; +}; + +/** AOIセル座標からAOI窓を生成する */ +export const resolveAoiWindowFromCell = ( + centerCell: AoiCell, + windowCols: number = GAME_CONFIG.AOI_WINDOW_COLS, + windowRows: number = GAME_CONFIG.AOI_WINDOW_ROWS, +): AoiWindow => { + const halfCols = Math.floor(windowCols / 2); + const halfRows = Math.floor(windowRows / 2); + + return { + minCol: centerCell.col - halfCols, + maxCol: centerCell.col + halfCols, + minRow: centerCell.row - halfRows, + maxRow: centerCell.row + halfRows, + }; +}; + +/** 座標をAOIセルへ変換して指定AOI窓に含まれるか判定する */ +export const isPositionInAoiWindow = ( + x: number, + y: number, + window: AoiWindow, + aoiCellSize: number = GAME_CONFIG.AOI_CELL_SIZE, +): boolean => { + const cell = resolveAoiCellFromPosition(x, y, aoiCellSize); + + return ( + cell.col >= window.minCol + && cell.col <= window.maxCol + && cell.row >= window.minRow + && cell.row <= window.maxRow + ); +}; + +/** AOIセル座標が同一か判定する */ +export const isSameAoiCell = (left: AoiCell, right: AoiCell): boolean => { + return left.col === right.col && left.row === right.row; +}; diff --git a/packages/shared/src/domains/game/aoi/index.ts b/packages/shared/src/domains/game/aoi/index.ts new file mode 100644 index 0000000..4528931 --- /dev/null +++ b/packages/shared/src/domains/game/aoi/index.ts @@ -0,0 +1,15 @@ +/** + * index + * AOIサブドメインの公開要素を集約して再公開する + * 可視範囲計算で利用する型と関数を束ねる + */ + +/** AOI関連の型を再公開する */ +export type { AoiCell, AoiWindow } from "./aoi.logic"; +/** AOI関連の計算関数を再公開する */ +export { + resolveAoiCellFromPosition, + resolveAoiWindowFromCell, + isPositionInAoiWindow, + isSameAoiCell, +} from "./aoi.logic"; diff --git a/packages/shared/src/domains/game/index.ts b/packages/shared/src/domains/game/index.ts index 69ae808..d593337 100644 --- a/packages/shared/src/domains/game/index.ts +++ b/packages/shared/src/domains/game/index.ts @@ -12,3 +12,5 @@ export * as gridMap from "./gridMap"; /** 爆弾当たり判定サブドメインを再公開する */ export * as bombHit from "./bombHit"; +/** AOIサブドメインを再公開する */ +export * as aoi from "./aoi";