diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 387c17c..c8e0db4 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -7,7 +7,10 @@ import { GameEventFacade } from "./application/GameEventFacade"; import { SceneLifecycleState } from "./application/lifecycle/SceneLifecycleState"; import { GameSessionFacade } from "./application/lifecycle/GameSessionFacade"; -import { CombatLifecycleFacade } from "./application/combat/CombatLifecycleFacade"; +import { + CombatLifecycleFacade, + type CombatLifecycleFacadeOptions, +} from "./application/combat/CombatLifecycleFacade"; import { DisposableRegistry } from "./application/lifecycle/DisposableRegistry"; import { registerGameManagerDisposers } from "./application/lifecycle/registerGameManagerDisposers"; import { type GameSceneFactoryOptions } from "./application/orchestrators/GameSceneOrchestrator"; @@ -39,6 +42,7 @@ sessionFacade?: GameSessionFacade; lifecycleState?: SceneLifecycleState; gameActionSender?: GameActionSender; + playerMoveSender?: MoveSender; moveSender?: MoveSender; sceneFactories?: GameSceneFactoryOptions; }; @@ -60,6 +64,7 @@ private sessionFacade: GameSessionFacade; private gameActionSender: GameActionSender; private runtime: GameSceneRuntime; + private playerMoveSender: MoveSender; private gameEventFacade: GameEventFacade; private combatFacade: CombatLifecycleFacade; private lifecycleState: SceneLifecycleState; @@ -122,7 +127,10 @@ dependencies.lifecycleState ?? new SceneLifecycleState(); this.gameActionSender = dependencies.gameActionSender ?? new SocketGameActionSender(); - const moveSender = dependencies.moveSender ?? new SocketPlayerMoveSender(); + this.playerMoveSender = + dependencies.playerMoveSender + ?? dependencies.moveSender + ?? new SocketPlayerMoveSender(); const sceneFactories = dependencies.sceneFactories; this.app = new Application(); this.worldContainer = new Container(); @@ -136,18 +144,9 @@ }, getBombManager: () => this.runtime.getBombManager(), }); - this.combatFacade = new CombatLifecycleFacade({ - players: this.players, - myId: this.myId, - acquireInputLock: this.lockInput.bind(this), - onSendBombHitReport: (bombId) => { - this.gameActionSender.sendBombHitReport(bombId); - }, - onLocalBombHitCountChanged: (count) => { - this.localBombHitCount = count; - this.uiStateSyncService.emitIfChanged(); - }, - }); + this.combatFacade = new CombatLifecycleFacade( + this.createCombatLifecycleCallbacks(), + ); this.runtime = new GameSceneRuntime({ app: this.app, worldContainer: this.worldContainer, @@ -155,7 +154,7 @@ myId: this.myId, sessionFacade: this.sessionFacade, gameActionSender: this.gameActionSender, - moveSender, + moveSender: this.playerMoveSender, getElapsedMs: () => this.sessionFacade.getElapsedMs(), onPongReceived: (payload) => { this.clockSyncService.updateFromPong(payload); @@ -252,6 +251,25 @@ return this.uiStateSyncService.subscribe(listener); } + /** 被弾ライフサイクルのコールバック群を組み立てる */ + private createCombatLifecycleCallbacks(): CombatLifecycleFacadeOptions { + return { + players: this.players, + myId: this.myId, + acquireInputLock: this.lockInput.bind(this), + onSendBombHitReport: (bombId) => { + this.gameActionSender.sendBombHitReport(bombId); + }, + onLocalBombHitCountChanged: (count) => { + this.localBombHitCount = count; + this.uiStateSyncService.emitIfChanged(); + }, + onLocalRespawnCompleted: (position) => { + this.playerMoveSender.sendMove(position.x, position.y, { force: true }); + }, + }; + } + /** HUD状態購読を登録し,解除関数を返す */ public subscribeHudState( listener: (state: GameHudState) => void, diff --git a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts index 4b0c005..2bead93 100644 --- a/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts +++ b/apps/client/src/scenes/game/application/combat/CombatLifecycleFacade.ts @@ -12,6 +12,12 @@ import { RespawnManager } from "./RespawnManager"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; +/** リスポーン完了時に通知するプレイヤー座標型 */ +export type RespawnPosition = { + x: number; + y: number; +}; + /** CombatLifecycleFacade の初期化入力 */ export type CombatLifecycleFacadeOptions = { players: GamePlayers; @@ -19,6 +25,7 @@ acquireInputLock: () => () => void; onSendBombHitReport: (bombId: string) => void; onLocalBombHitCountChanged: (count: number) => void; + onLocalRespawnCompleted?: (position: RespawnPosition) => void; }; type NetworkDamageSource = "bomb" | "hurricane"; @@ -28,6 +35,7 @@ private readonly myId: string; private readonly onSendBombHitReport: (bombId: string) => void; private readonly onLocalBombHitCountChanged: (count: number) => void; + private readonly onLocalRespawnCompleted?: (position: RespawnPosition) => void; private readonly bombHitOrchestrator: BombHitOrchestrator; private readonly playerHitPolicy: PlayerHitPolicy; private readonly playerHitEffectOrchestrator: PlayerHitEffectOrchestrator; @@ -40,10 +48,12 @@ acquireInputLock, onSendBombHitReport, onLocalBombHitCountChanged, + onLocalRespawnCompleted, }: CombatLifecycleFacadeOptions) { this.myId = myId; this.onSendBombHitReport = onSendBombHitReport; this.onLocalBombHitCountChanged = onLocalBombHitCountChanged; + this.onLocalRespawnCompleted = onLocalRespawnCompleted; this.bombHitOrchestrator = new BombHitOrchestrator({ players, myId, @@ -65,6 +75,12 @@ if (playerId !== this.myId) return; this.localBombHitCount = 0; this.onLocalBombHitCountChanged(this.localBombHitCount); + const localPlayer = players[playerId]; + if (!localPlayer) { + return; + } + + this.onLocalRespawnCompleted?.(localPlayer.getPosition()); }, }); } 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 17b2e50..2fe73cd 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -160,7 +160,7 @@ this.mapSyncHandler.handleUpdateMapCells(updates); }, onReceivedCurrentHurricanes: (payload) => { - this.hurricaneSyncHandler.handleUpdateHurricanes(payload); + this.hurricaneSyncHandler.handleCurrentHurricanes(payload); }, onReceivedUpdateHurricanes: (payload) => { this.hurricaneSyncHandler.handleUpdateHurricanes(payload); diff --git a/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts index ab58a78..0196319 100644 --- a/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts +++ b/apps/client/src/scenes/game/application/network/handlers/HurricaneSyncHandler.ts @@ -4,7 +4,10 @@ * 受信配列を描画コントローラーへ橋渡しする */ import { Container } from "pixi.js"; -import type { UpdateHurricanesPayload } from "@repo/shared"; +import type { + CurrentHurricanesPayload, + UpdateHurricanesPayload, +} from "@repo/shared"; import { HurricaneOverlayController } from "@client/scenes/game/entities/hurricane/HurricaneOverlayController"; /** HurricaneSyncHandler の初期化入力 */ @@ -20,6 +23,13 @@ this.overlayController = new HurricaneOverlayController(worldContainer); } + /** current-hurricanes を描画へ置換反映する */ + public handleCurrentHurricanes = ( + payload: CurrentHurricanesPayload, + ): void => { + this.overlayController.replaceAll(payload); + }; + /** ハリケーン状態配列を描画へ反映する */ public handleUpdateHurricanes = (payload: UpdateHurricanesPayload): void => { this.overlayController.applyUpdates(payload); diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index 2b96c85..d883146 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -98,7 +98,7 @@ public applyPlacedBombFromOthers(payload: BombPlacedPayload): void { this.upsertBomb( payload.bombId, - this.bombPlacementService.createRenderPayload(payload, payload.ownerSocketId), + this.bombPlacementService.createRenderPayload(payload), ); } diff --git a/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts b/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts index 098ff3b..9da62a6 100644 --- a/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts +++ b/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts @@ -98,11 +98,14 @@ /** 指定オーナー情報から描画ペイロードを生成する */ public createRenderPayload( - payload: { x: number; y: number; explodeAtElapsedMs: number }, - ownerSocketId: string, + payload: { + x: number; + y: number; + explodeAtElapsedMs: number; + ownerTeamId: number; + }, ): BombRenderPayload { - const ownerTeamId = this.resolveTeamIdBySocketId(ownerSocketId); - return this.toRenderPayload(payload, ownerTeamId); + return this.toRenderPayload(payload, payload.ownerTeamId); } private toRenderPayload( diff --git a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts index 887ea68..383005a 100644 --- a/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts +++ b/apps/client/src/scenes/game/entities/hurricane/HurricaneOverlayController.ts @@ -48,6 +48,23 @@ }); } + /** 受信状態で全体を置換し,未含有IDを描画から除去する */ + public replaceAll(states: UpdateHurricanesPayload): void { + const nextIds = new Set(states.map((state) => state.id)); + + this.displayById.forEach((display, id) => { + if (nextIds.has(id)) { + return; + } + + this.layer.removeChild(display.container); + display.container.destroy({ children: true }); + this.displayById.delete(id); + }); + + this.applyUpdates(states); + } + /** 描画リソースを破棄する */ public destroy(): void { this.displayById.forEach((display) => { diff --git a/apps/server/src/domains/game/GameManager.ts b/apps/server/src/domains/game/GameManager.ts index 1061248..dc3ae97 100644 --- a/apps/server/src/domains/game/GameManager.ts +++ b/apps/server/src/domains/game/GameManager.ts @@ -10,6 +10,7 @@ import { GameRoomSession } from "./application/services/GameRoomSession"; import type { GameSessionCallbacks } from "./application/services/GameRoomSession"; import type { ActiveBombRegistration } from "./application/ports/gameUseCasePorts"; +import type { ActiveBombSnapshot } from "./application/ports/gameUseCasePorts"; import type { GameFieldConfig } from "./application/ports/gameUseCasePorts"; import { GameSessionLifecycleService } from "./application/services/GameSessionLifecycleService"; import { GamePlayerOperationService } from "./application/services/GamePlayerOperationService"; @@ -103,6 +104,11 @@ return this.lifecycleService.issueServerBombId(); } + /** 指定プレイヤーのチームIDを返す */ + getPlayerTeamId(playerId: string): number { + return this.lifecycleService.getPlayerTeamId(playerId); + } + /** 設置済み爆弾をアクティブレジストリに登録する */ registerActiveBomb(registration: ActiveBombRegistration): void { this.lifecycleService.registerActiveBomb(registration); @@ -113,6 +119,11 @@ this.lifecycleService.recordBombHitForOwner(bombId); } + /** 現在アクティブな爆弾一覧を返す */ + getActiveBombSnapshots(): ActiveBombSnapshot[] { + return this.lifecycleService.getActiveBombSnapshots(); + } + dispose(): void { this.lifecycleService.dispose(); } diff --git a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts index 8a924fe..e053f6f 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -99,7 +99,7 @@ export interface BombPlacementOutputPort { publishBombPlacedToOthersInRoom( roomId: domain.room.Room["roomId"], - ownerSocketId: string, + excludedSocketId: string, payload: BombPlacedPayload, ): void; publishBombPlacedAckToSocket( @@ -144,6 +144,7 @@ shouldBroadcastBombPlaced(dedupeKey: string, nowMs: number): boolean; issueServerBombId(): string; registerActiveBomb(registration: ActiveBombRegistration): void; + getPlayerTeamId(playerId: string): number; } /** registerActiveBomb に渡す爆弾登録情報 */ @@ -155,6 +156,21 @@ explodeAtElapsedMs: number; }; +/** アクティブ爆弾参照で返す爆弾スナップショット型 */ +export type ActiveBombSnapshot = { + bombId: string; + ownerPlayerId: string; + ownerTeamId: number; + x: number; + y: number; + explodeAtElapsedMs: number; +}; + +/** アクティブ爆弾一覧を参照する入力ポート */ +export interface ActiveBombQueryPort { + getActiveBombSnapshots(): ActiveBombSnapshot[]; +} + /** 被弾報告ユースケースが利用する重複排除入力ポート */ export interface BombHitReportValidationPort { shouldBroadcastBombHitReport(dedupeKey: string, nowMs: number): boolean; diff --git a/apps/server/src/domains/game/application/services/GameRoomSession.ts b/apps/server/src/domains/game/application/services/GameRoomSession.ts index 1958875..a6ae809 100644 --- a/apps/server/src/domains/game/application/services/GameRoomSession.ts +++ b/apps/server/src/domains/game/application/services/GameRoomSession.ts @@ -9,7 +9,10 @@ logScopes, } from "@server/logging/index"; import type { domain, GameResultPayload, PlaceBombPayload } from "@repo/shared"; -import type { ActiveBombRegistration } from "../ports/gameUseCasePorts"; +import type { + ActiveBombRegistration, + ActiveBombSnapshot, +} from "../ports/gameUseCasePorts"; import { config } from "@server/config"; import { GameLoop, type GameLoopCallbacks } from "../../loop/GameLoop"; import { Player } from "../../entities/player/Player.js"; @@ -211,6 +214,12 @@ return this.bombStateStore.issueServerBombId(); } + /** 指定プレイヤーのチームIDを返す,存在しない場合は UNKNOWN_TEAM_ID を返す */ + public getPlayerTeamId(playerId: string): number { + const player = this.players.get(playerId); + return player?.teamId ?? -1; + } + /** 設置済み爆弾をアクティブレジストリに登録する */ public registerActiveBomb(registration: ActiveBombRegistration): void { const player = this.players.get(registration.ownerPlayerId); @@ -239,6 +248,20 @@ } } + /** 現在アクティブな爆弾一覧を返す */ + public getActiveBombSnapshots(): ActiveBombSnapshot[] { + return this.bombStateStore.activeBombRegistry.getActiveBombSnapshots().map((bomb) => { + return { + bombId: bomb.bombId, + ownerPlayerId: bomb.ownerPlayerId, + ownerTeamId: bomb.ownerTeamId, + x: bomb.x, + y: bomb.y, + explodeAtElapsedMs: bomb.explodeAtElapsedMs, + }; + }); + } + public dispose(): void { if (this.startDelayTimer) { clearTimeout(this.startDelayTimer); diff --git a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts index dbbc2f2..7536e13 100644 --- a/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts +++ b/apps/server/src/domains/game/application/services/GameSessionLifecycleService.ts @@ -4,6 +4,7 @@ */ import { config } from "@server/config"; import type { + ActiveBombSnapshot, ActiveBombRegistration, GameFieldConfig, } from "../ports/gameUseCasePorts"; @@ -68,6 +69,11 @@ return session.issueServerBombId(); } + /** 指定プレイヤーのチームIDを返す,未参加時は UNKNOWN_TEAM_ID を返す */ + public getPlayerTeamId(playerId: string): number { + return this.sessionRef.current?.getPlayerTeamId(playerId) ?? -1; + } + /** 設置済み爆弾をアクティブレジストリに登録する */ public registerActiveBomb(registration: ActiveBombRegistration): void { this.sessionRef.current?.registerActiveBomb(registration); @@ -78,6 +84,11 @@ this.sessionRef.current?.recordBombHitForOwner(bombId); } + /** 現在アクティブな爆弾一覧を返す */ + public getActiveBombSnapshots(): ActiveBombSnapshot[] { + return this.sessionRef.current?.getActiveBombSnapshots() ?? []; + } + public startRoomSession( playerIds: string[], playerNamesById: Record, diff --git a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts index 073de20..fc0f33f 100644 --- a/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts +++ b/apps/server/src/domains/game/application/useCases/placeBombUseCase.ts @@ -33,6 +33,7 @@ } const bombId = bombStore.issueServerBombId(); + const ownerTeamId = bombStore.getPlayerTeamId(input.socketId); bombStore.registerActiveBomb({ bombId, @@ -48,7 +49,7 @@ createBombPlacedPayload({ payload: input.payload, bombId, - ownerSocketId: input.socketId, + ownerTeamId, }) ); diff --git a/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts index a4c38f2..e9fc150 100644 --- a/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts +++ b/apps/server/src/domains/game/entities/bomb/ActiveBombRegistry.ts @@ -41,4 +41,9 @@ public clear(): void { this.bombs.clear(); } + + /** 現在アクティブな爆弾のスナップショットを返す */ + public getActiveBombSnapshots(): ActiveBomb[] { + return Array.from(this.bombs.values()); + } } diff --git a/apps/server/src/domains/game/entities/bomb/bombPlacement.ts b/apps/server/src/domains/game/entities/bomb/bombPlacement.ts index 00c4604..2ec33b5 100644 --- a/apps/server/src/domains/game/entities/bomb/bombPlacement.ts +++ b/apps/server/src/domains/game/entities/bomb/bombPlacement.ts @@ -12,19 +12,18 @@ type CreateBombPlacedPayloadParams = { payload: PlaceBombPayload; bombId: string; - ownerSocketId: string; + ownerTeamId: number; }; /** 爆弾確定通知で他プレイヤーへ配信するペイロードを生成する */ export const createBombPlacedPayload = ({ payload, bombId, - ownerSocketId, + ownerTeamId, }: CreateBombPlacedPayloadParams): BombPlacedPayload => { - // 設置者の識別情報はサーバー確定の socketId を利用する return { bombId, - ownerSocketId, + ownerTeamId, x: payload.x, y: payload.y, explodeAtElapsedMs: payload.explodeAtElapsedMs, diff --git a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts index 3de357d..8c27e69 100644 --- a/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts +++ b/apps/server/src/domains/room/application/ports/roomUseCasePorts.ts @@ -4,6 +4,7 @@ */ import { domain } from "@repo/shared"; import type { + ActiveBombQueryPort, BombHitReportValidationPort, BombHitStatsPort, BombPlacementPort, @@ -19,6 +20,7 @@ & ReadyForGamePort & MovePlayerPort & BombPlacementPort + & ActiveBombQueryPort & BombHitReportValidationPort & BombHitStatsPort & DisconnectPlayerPort; diff --git a/apps/server/src/network/adapters/realtimeRoomSyncState.ts b/apps/server/src/network/adapters/realtimeRoomSyncState.ts index d712228..bb6fb40 100644 --- a/apps/server/src/network/adapters/realtimeRoomSyncState.ts +++ b/apps/server/src/network/adapters/realtimeRoomSyncState.ts @@ -5,33 +5,171 @@ 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; + getVisiblePlayerIdsSnapshot: (roomId: RoomId, socketId: SocketId) => Set; + getVisibleBombIdsSnapshot: (roomId: RoomId, socketId: SocketId) => Set; + getVisibleHurricaneIdsSnapshot: (roomId: RoomId, socketId: SocketId) => Set; + replaceVisiblePlayerIds: ( + roomId: RoomId, + socketId: SocketId, + nextIds: Iterable, + ) => void; + replaceVisibleBombIds: ( + roomId: RoomId, + socketId: SocketId, + nextIds: Iterable, + ) => void; + replaceVisibleHurricaneIds: ( + roomId: RoomId, + socketId: SocketId, + nextIds: Iterable, + ) => void; 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; + 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); + }, + getVisiblePlayerIdsSnapshot: (roomId, socketId) => { + const cache = getOrCreateSocketScopedCache( + visiblePlayerIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + return new Set(cache); + }, + getVisibleBombIdsSnapshot: (roomId, socketId) => { + const cache = getOrCreateSocketScopedCache( + visibleBombIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + return new Set(cache); + }, + getVisibleHurricaneIdsSnapshot: (roomId, socketId) => { + const cache = getOrCreateSocketScopedCache( + visibleHurricaneIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + return new Set(cache); + }, + replaceVisiblePlayerIds: (roomId, socketId, nextIds) => { + const cache = getOrCreateSocketScopedCache( + visiblePlayerIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + cache.clear(); + for (const nextId of nextIds) { + cache.add(nextId); } - - const created: RoomPlayerPositionCache = new Map(); - playerPositionCacheByRoomId.set(roomId, created); - return created; + }, + replaceVisibleBombIds: (roomId, socketId, nextIds) => { + const cache = getOrCreateSocketScopedCache( + visibleBombIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + cache.clear(); + for (const nextId of nextIds) { + cache.add(nextId); + } + }, + replaceVisibleHurricaneIds: (roomId, socketId, nextIds) => { + const cache = getOrCreateSocketScopedCache( + visibleHurricaneIdsByRoomId, + roomId, + socketId, + () => new Set(), + ); + cache.clear(); + for (const nextId of nextIds) { + cache.add(nextId); + } }, 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/aoi/aoiVisibility.ts b/apps/server/src/network/handlers/game/aoi/aoiVisibility.ts new file mode 100644 index 0000000..c6c1897 --- /dev/null +++ b/apps/server/src/network/handlers/game/aoi/aoiVisibility.ts @@ -0,0 +1,55 @@ +/** + * aoiVisibility + * ゲーム送信で利用するAOI可視判定ロジックを提供する + * 受信者視点のAOI窓計算と対象可視判定を共通化する + */ +import { domain } from "@repo/shared"; +import { config } from "@server/config"; + +/** AOI窓の境界型 */ +export type AoiWindow = domain.game.aoi.AoiWindow; + +/** グリッド座標を持つ可視判定対象型 */ +export type AoiTarget = { + x: number; + y: number; +}; + +/** 受信者座標からAOI窓を解決する */ +export const resolveViewerAoiWindow = ( + viewer: AoiTarget, +): AoiWindow => { + const centerCell = domain.game.aoi.resolveAoiCellFromPosition( + viewer.x, + viewer.y, + config.GAME_CONFIG.AOI_CELL_SIZE, + ); + + return domain.game.aoi.resolveAoiWindowFromCell( + centerCell, + config.GAME_CONFIG.AOI_WINDOW_COLS, + config.GAME_CONFIG.AOI_WINDOW_ROWS, + ); +}; + +/** 対象座標がAOI窓に含まれるか判定する */ +export const isTargetInAoiWindow = ( + target: AoiTarget, + aoiWindow: AoiWindow, +): boolean => { + return domain.game.aoi.isPositionInAoiWindow( + target.x, + target.y, + aoiWindow, + config.GAME_CONFIG.AOI_CELL_SIZE, + ); +}; + +/** 受信者座標からAOIセルを解決する */ +export const resolveViewerAoiCell = (viewer: AoiTarget): domain.game.aoi.AoiCell => { + return domain.game.aoi.resolveAoiCellFromPosition( + viewer.x, + viewer.y, + config.GAME_CONFIG.AOI_CELL_SIZE, + ); +}; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index dd718e4..f9a3bce 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -13,27 +13,25 @@ HurricaneHitPayload, PlayerHitPayload, PongPayload, - CurrentHurricanesPayload, CurrentPlayersPayload, RemovePlayerPayload, - UpdateHurricanesPayload, - UpdatePlayersPayload, } from "@repo/shared"; import type { BombPlacementOutputPort, PlayerHitOutputPort, GameOutputPort, } from "@server/domains/game/application/ports/gameUseCasePorts"; -import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; -import { - collectChangedUpdatePlayersPayload, - quantizeUpdatePlayersPayload, -} from "@server/network/adapters/gamePayloadSanitizers"; import { createRealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; import { createEmitToRoom } from "@server/network/adapters/socketEmitters"; import type { CommonHandlerContext } from "../CommonHandler"; +import { resolveViewerAoiCell } from "./aoi/aoiVisibility"; +import type { RuntimeResolverDeps } from "./runtime/gameRuntimeResolvers"; +import { createBombSyncService } from "./services/bombSyncService"; +import { createHurricaneSyncService } from "./services/hurricaneSyncService"; +import { createPlayerSyncService } from "./services/playerSyncService"; type RoomId = domain.room.Room["roomId"]; +type SocketId = string; type ReliableRoomEvent = | typeof protocol.SocketEvents.UPDATE_PLAYERS @@ -62,10 +60,46 @@ /** 共通送信コンテキストからゲーム出力アダプターを生成する */ export const createGameOutputAdapter = ( common: CommonHandlerContext, + deps: RuntimeResolverDeps, ): GameOutputAdapter => { const { reliable } = common; const realtimeRoomSyncState = createRealtimeRoomSyncStateStore(); + const updateViewerAoiCellCache = ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ): void => { + const nextCell = resolveViewerAoiCell(viewer); + const previousCell = realtimeRoomSyncState.getLastAoiCell(roomId, viewerId); + + if (previousCell && domainNs.game.aoi.isSameAoiCell(previousCell, nextCell)) { + return; + } + + realtimeRoomSyncState.setLastAoiCell(roomId, viewerId, nextCell); + }; + + const bombSyncService = createBombSyncService({ + reliable, + runtimeDeps: deps, + realtimeRoomSyncState, + updateViewerAoiCellCache, + }); + const playerSyncService = createPlayerSyncService({ + reliable, + runtimeDeps: deps, + realtimeRoomSyncState, + bombSyncService, + updateViewerAoiCellCache, + }); + const hurricaneSyncService = createHurricaneSyncService({ + reliable, + runtimeDeps: deps, + realtimeRoomSyncState, + updateViewerAoiCellCache, + }); + const emitReliableToRoom = ( roomId: RoomId, event: ReliableRoomEvent, @@ -83,22 +117,10 @@ publishPongToSocket: (payload: PongPayload) => { reliable.emitToSocket(protocol.SocketEvents.PONG, payload); }, - publishUpdatePlayersToRoom: ( - roomId: RoomId, - players: UpdatePlayersPayload, - ) => { - const quantizedPlayers = quantizeUpdatePlayersPayload(players); - const changedPlayers = collectChangedUpdatePlayersPayload( - quantizedPlayers, - realtimeRoomSyncState.getPlayerPositionCache(roomId), - ); - - if (changedPlayers.length === 0) { - return; - } - - emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_PLAYERS, changedPlayers); + publishUpdatePlayersToRoom: (roomId, players) => { + playerSyncService.publishUpdatePlayersToRoom(roomId, players); }, + publishMapCellUpdatesToRoom: ( roomId: RoomId, cellUpdates: domainNs.game.gridMap.CellUpdate[], @@ -106,20 +128,15 @@ const grouped = domainNs.game.gridMap.groupCellUpdates(cellUpdates); emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_MAP_CELLS, grouped); }, - publishCurrentHurricanesToRoom: ( - roomId: RoomId, - hurricanes: CurrentHurricanesPayload, - ) => { - emitReliableToRoom(roomId, protocol.SocketEvents.CURRENT_HURRICANES, hurricanes); + publishCurrentHurricanesToRoom: (roomId, hurricanes) => { + hurricaneSyncService.publishCurrentHurricanesToRoom(roomId, hurricanes); }, - publishUpdateHurricanesToRoom: ( - roomId: RoomId, - hurricanes: UpdateHurricanesPayload, - ) => { - emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_HURRICANES, hurricanes); + publishUpdateHurricanesToRoom: (roomId, hurricanes) => { + hurricaneSyncService.publishUpdateHurricanesToRoom(roomId, hurricanes); }, publishGameEndToRoom: (roomId: RoomId) => { realtimeRoomSyncState.resetRoom(roomId); + hurricaneSyncService.clearRoomSnapshot(roomId); emitReliableToRoom(roomId, protocol.SocketEvents.GAME_END); }, publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { @@ -127,6 +144,7 @@ }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { realtimeRoomSyncState.resetRoom(roomId); + hurricaneSyncService.clearRoomSnapshot(roomId); emitReliableToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, publishCurrentPlayersToSocket: (players: CurrentPlayersPayload) => { @@ -137,18 +155,12 @@ }, publishBombPlacedToOthersInRoom: ( roomId: RoomId, - ownerSocketId: string, + excludedSocketId: string, payload: BombPlacedPayload, ) => { - if (isBotPlayerId(ownerSocketId)) { - reliable.emitToRoom(roomId, protocol.SocketEvents.BOMB_PLACED, payload); - return; - } - - reliable.emitToRoomExceptSocket( + bombSyncService.publishBombPlacedToOthersInRoom( roomId, - ownerSocketId, - protocol.SocketEvents.BOMB_PLACED, + excludedSocketId, payload, ); }, diff --git a/apps/server/src/network/handlers/game/runtime/gameRuntimeResolvers.ts b/apps/server/src/network/handlers/game/runtime/gameRuntimeResolvers.ts new file mode 100644 index 0000000..65df557 --- /dev/null +++ b/apps/server/src/network/handlers/game/runtime/gameRuntimeResolvers.ts @@ -0,0 +1,59 @@ +/** + * gameRuntimeResolvers + * ゲーム送信処理で利用するルーム/ランタイム参照処理を提供する + * 受信者ソケット一覧,プレイヤー一覧,アクティブ爆弾一覧の解決を集約する + */ +import type { domain } from "@repo/shared"; +import type { ActiveBombSnapshot } from "@server/domains/game/application/ports/gameUseCasePorts"; +import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; +import type { + FindGameByRoomPort, + FindRoomByIdPort, +} from "@server/domains/room/application/ports/roomUseCasePorts"; + +/** ランタイム参照処理で利用する依存型 */ +export type RuntimeResolverDeps = { + roomManager: FindRoomByIdPort; + runtimeRegistry: FindGameByRoomPort; +}; + +/** ルームの接続済み受信者ソケットID一覧を返す */ +export const getConnectedSocketIdsInRoom = ( + deps: RuntimeResolverDeps, + roomId: domain.room.Room["roomId"], +): string[] => { + const room = deps.roomManager.getRoomById(roomId); + if (!room) { + return []; + } + + return room.players + .map((player) => player.id) + .filter((playerId) => !isBotPlayerId(playerId)); +}; + +/** ルーム内プレイヤー一覧を返す */ +export const getRoomPlayers = ( + deps: RuntimeResolverDeps, + roomId: domain.room.Room["roomId"], +): domain.game.player.PlayerData[] => { + const gameManager = deps.runtimeRegistry.getGameManagerByRoomId(roomId); + if (!gameManager) { + return []; + } + + return gameManager.getRoomPlayers(); +}; + +/** ルーム内アクティブ爆弾スナップショット一覧を返す */ +export const getActiveBombSnapshotsInRoom = ( + deps: RuntimeResolverDeps, + roomId: domain.room.Room["roomId"], +): ActiveBombSnapshot[] => { + const gameManager = deps.runtimeRegistry.getGameManagerByRoomId(roomId); + if (!gameManager) { + return []; + } + + return gameManager.getActiveBombSnapshots(); +}; diff --git a/apps/server/src/network/handlers/game/services/bombSyncService.ts b/apps/server/src/network/handlers/game/services/bombSyncService.ts new file mode 100644 index 0000000..d48e79e --- /dev/null +++ b/apps/server/src/network/handlers/game/services/bombSyncService.ts @@ -0,0 +1,127 @@ +/** + * bombSyncService + * 爆弾のAOI同期と設置通知送信を提供する + * 可視集合キャッシュ更新とAOI内配信を集約する + */ +import { contracts as protocol, domain } from "@repo/shared"; +import type { BombPlacedPayload } from "@repo/shared"; +import type { ActiveBombSnapshot } from "@server/domains/game/application/ports/gameUseCasePorts"; +import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; +import type { RealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; +import type { ReliableEmitters } from "../../CommonHandler"; +import { + isTargetInAoiWindow, + resolveViewerAoiWindow, + type AoiWindow, +} from "../aoi/aoiVisibility"; +import { + type RuntimeResolverDeps, +} from "../runtime/gameRuntimeResolvers"; +import { forEachRoomViewer } from "./roomViewerSyncContext"; + +type RoomId = domain.room.Room["roomId"]; +type SocketId = string; + +/** 爆弾同期サービスが提供する操作契約 */ +export type BombSyncService = { + syncVisibleBombsByViewer: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + bombs: ActiveBombSnapshot[], + ) => void; + publishBombPlacedToOthersInRoom: ( + roomId: RoomId, + excludedSocketId: string, + payload: BombPlacedPayload, + ) => void; +}; + +/** 爆弾同期サービス生成時の依存集合 */ +export type CreateBombSyncServiceDeps = { + reliable: ReliableEmitters; + runtimeDeps: RuntimeResolverDeps; + realtimeRoomSyncState: RealtimeRoomSyncStateStore; + updateViewerAoiCellCache: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ) => void; +}; + +/** 爆弾のAOI同期サービスを生成する */ +export const createBombSyncService = ( + deps: CreateBombSyncServiceDeps, +): BombSyncService => { + const isInViewerAoi = ( + target: { x: number; y: number }, + aoiWindow: AoiWindow, + ): boolean => { + return isTargetInAoiWindow(target, aoiWindow); + }; + + return { + syncVisibleBombsByViewer: (roomId, viewerId, viewer, bombs) => { + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(viewer); + const previousVisibleBombIds = deps.realtimeRoomSyncState.getVisibleBombIdsSnapshot( + roomId, + viewerId, + ); + const nextVisibleBombIds = new Set(); + + bombs.forEach((bomb) => { + if (!isInViewerAoi(bomb, aoiWindow)) { + return; + } + + nextVisibleBombIds.add(bomb.bombId); + if (previousVisibleBombIds.has(bomb.bombId)) { + return; + } + + if (viewerId === bomb.ownerPlayerId && !isBotPlayerId(bomb.ownerPlayerId)) { + return; + } + + const syncPayload: BombPlacedPayload = { + bombId: bomb.bombId, + ownerTeamId: bomb.ownerTeamId, + x: bomb.x, + y: bomb.y, + explodeAtElapsedMs: bomb.explodeAtElapsedMs, + }; + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.BOMB_PLACED, + syncPayload, + ); + }); + + deps.realtimeRoomSyncState.replaceVisibleBombIds( + roomId, + viewerId, + nextVisibleBombIds, + ); + }, + publishBombPlacedToOthersInRoom: (roomId, excludedSocketId, payload) => { + forEachRoomViewer({ + runtimeDeps: deps.runtimeDeps, + roomId, + run: ({ viewerId, viewer }) => { + if (viewerId === excludedSocketId && !isBotPlayerId(excludedSocketId)) { + return; + } + + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(viewer); + if (!isInViewerAoi(payload, aoiWindow)) { + return; + } + + deps.reliable.emitToSocketById(viewerId, protocol.SocketEvents.BOMB_PLACED, payload); + }, + }); + }, + }; +}; diff --git a/apps/server/src/network/handlers/game/services/hurricaneSyncService.ts b/apps/server/src/network/handlers/game/services/hurricaneSyncService.ts new file mode 100644 index 0000000..4ce432b --- /dev/null +++ b/apps/server/src/network/handlers/game/services/hurricaneSyncService.ts @@ -0,0 +1,210 @@ +/** + * hurricaneSyncService + * ハリケーンのAOI同期送信を提供する + * 受信者ごとの可視集合差分とルーム内最新スナップショットを管理する + */ +import { contracts as protocol, domain } from "@repo/shared"; +import type { + CurrentHurricanesPayload, + HurricaneStatePayload, + UpdateHurricanesPayload, +} from "@repo/shared"; +import type { RealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; +import type { ReliableEmitters } from "../../CommonHandler"; +import { isTargetInAoiWindow, resolveViewerAoiWindow, type AoiWindow } from "../aoi/aoiVisibility"; +import { + type RuntimeResolverDeps, +} from "../runtime/gameRuntimeResolvers"; +import { forEachRoomViewer } from "./roomViewerSyncContext"; + +type RoomId = domain.room.Room["roomId"]; +type SocketId = string; + +/** ハリケーン同期サービスが提供する操作契約 */ +export type HurricaneSyncService = { + publishCurrentHurricanesToRoom: ( + roomId: RoomId, + hurricanes: CurrentHurricanesPayload, + ) => void; + publishUpdateHurricanesToRoom: ( + roomId: RoomId, + hurricanes: UpdateHurricanesPayload, + ) => void; + clearRoomSnapshot: (roomId: RoomId) => void; +}; + +/** ハリケーン同期サービス生成時の依存集合 */ +export type CreateHurricaneSyncServiceDeps = { + reliable: ReliableEmitters; + runtimeDeps: RuntimeResolverDeps; + realtimeRoomSyncState: RealtimeRoomSyncStateStore; + updateViewerAoiCellCache: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ) => void; +}; + +/** ハリケーンAOI同期サービスを生成する */ +export const createHurricaneSyncService = ( + deps: CreateHurricaneSyncServiceDeps, +): HurricaneSyncService => { + const hurricaneSnapshotByRoomId = new Map>(); + + const isInViewerAoi = ( + target: { x: number; y: number }, + aoiWindow: AoiWindow, + ): boolean => { + return isTargetInAoiWindow(target, aoiWindow); + }; + + const replaceRoomHurricaneSnapshot = ( + roomId: RoomId, + hurricanes: HurricaneStatePayload[], + ): void => { + const snapshotMap = new Map(); + hurricanes.forEach((hurricane) => { + snapshotMap.set(hurricane.id, hurricane); + }); + hurricaneSnapshotByRoomId.set(roomId, snapshotMap); + }; + + const upsertRoomHurricaneSnapshot = ( + roomId: RoomId, + hurricanes: HurricaneStatePayload[], + ): void => { + const snapshotMap = hurricaneSnapshotByRoomId.get(roomId) ?? new Map(); + hurricanes.forEach((hurricane) => { + snapshotMap.set(hurricane.id, hurricane); + }); + hurricaneSnapshotByRoomId.set(roomId, snapshotMap); + }; + + const collectVisibleHurricanesByViewer = ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + hurricanes: Iterable, + ): HurricaneStatePayload[] => { + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(viewer); + const visibleHurricanes: HurricaneStatePayload[] = []; + + for (const hurricane of hurricanes) { + if (isInViewerAoi(hurricane, aoiWindow)) { + visibleHurricanes.push(hurricane); + } + } + + return visibleHurricanes; + }; + + const syncVisibleHurricaneIdsByViewer = ( + roomId: RoomId, + viewerId: SocketId, + hurricanes: HurricaneStatePayload[], + ): void => { + const nextVisibleIds = hurricanes.map((hurricane) => hurricane.id); + deps.realtimeRoomSyncState.replaceVisibleHurricaneIds( + roomId, + viewerId, + nextVisibleIds, + ); + }; + + const hasChangedVisibleHurricaneIds = ( + previousVisibleIds: Set, + nextVisibleHurricanes: HurricaneStatePayload[], + ): boolean => { + if (previousVisibleIds.size !== nextVisibleHurricanes.length) { + return true; + } + + for (const hurricane of nextVisibleHurricanes) { + if (!previousVisibleIds.has(hurricane.id)) { + return true; + } + } + + return false; + }; + + return { + publishCurrentHurricanesToRoom: (roomId, hurricanes) => { + replaceRoomHurricaneSnapshot(roomId, hurricanes); + + forEachRoomViewer({ + runtimeDeps: deps.runtimeDeps, + roomId, + run: ({ viewerId, viewer }) => { + const visibleHurricanes = collectVisibleHurricanesByViewer( + roomId, + viewerId, + viewer, + hurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, visibleHurricanes); + + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.CURRENT_HURRICANES, + visibleHurricanes, + ); + }, + }); + }, + publishUpdateHurricanesToRoom: (roomId, hurricanes) => { + upsertRoomHurricaneSnapshot(roomId, hurricanes); + + const roomSnapshot = hurricaneSnapshotByRoomId.get(roomId); + + forEachRoomViewer({ + runtimeDeps: deps.runtimeDeps, + roomId, + run: ({ viewerId, viewer }) => { + const nextVisibleHurricanes = collectVisibleHurricanesByViewer( + roomId, + viewerId, + viewer, + roomSnapshot?.values() ?? [], + ); + const previousVisibleIds = new Set( + deps.realtimeRoomSyncState.getVisibleHurricaneIdsSnapshot(roomId, viewerId), + ); + const hasMembershipChanged = hasChangedVisibleHurricaneIds( + previousVisibleIds, + nextVisibleHurricanes, + ); + + if (hasMembershipChanged) { + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.CURRENT_HURRICANES, + nextVisibleHurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, nextVisibleHurricanes); + return; + } + + const nextVisibleIdSet = new Set(nextVisibleHurricanes.map((hurricane) => hurricane.id)); + const visibleUpdateHurricanes = hurricanes.filter((hurricane) => { + return nextVisibleIdSet.has(hurricane.id); + }); + if (visibleUpdateHurricanes.length === 0) { + return; + } + + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.UPDATE_HURRICANES, + visibleUpdateHurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, nextVisibleHurricanes); + }, + }); + }, + clearRoomSnapshot: (roomId) => { + hurricaneSnapshotByRoomId.delete(roomId); + }, + }; +}; diff --git a/apps/server/src/network/handlers/game/services/playerSyncService.ts b/apps/server/src/network/handlers/game/services/playerSyncService.ts new file mode 100644 index 0000000..71b5b4f --- /dev/null +++ b/apps/server/src/network/handlers/game/services/playerSyncService.ts @@ -0,0 +1,144 @@ +/** + * playerSyncService + * プレイヤー差分同期と可視プレイヤー管理を提供する + * AOI可視判定と前回送信位置キャッシュを利用して更新量を最小化する + */ +import { contracts as protocol, domain } from "@repo/shared"; +import type { UpdatePlayersPayload } from "@repo/shared"; +import { collectChangedUpdatePlayersPayload, quantizeUpdatePlayersPayload } from "@server/network/adapters/gamePayloadSanitizers"; +import type { RealtimeRoomSyncStateStore } from "@server/network/adapters/realtimeRoomSyncState"; +import type { ReliableEmitters } from "../../CommonHandler"; +import { isTargetInAoiWindow, resolveViewerAoiWindow, type AoiWindow } from "../aoi/aoiVisibility"; +import { + getActiveBombSnapshotsInRoom, + type RuntimeResolverDeps, +} from "../runtime/gameRuntimeResolvers"; +import type { BombSyncService } from "./bombSyncService"; +import { forEachRoomViewer } from "./roomViewerSyncContext"; + +type RoomId = domain.room.Room["roomId"]; +type SocketId = string; + +/** プレイヤー同期サービスが提供する操作契約 */ +export type PlayerSyncService = { + publishUpdatePlayersToRoom: ( + roomId: RoomId, + players: UpdatePlayersPayload, + ) => void; +}; + +/** プレイヤー同期サービス生成時の依存集合 */ +export type CreatePlayerSyncServiceDeps = { + reliable: ReliableEmitters; + runtimeDeps: RuntimeResolverDeps; + realtimeRoomSyncState: RealtimeRoomSyncStateStore; + bombSyncService: Pick; + updateViewerAoiCellCache: ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + ) => void; +}; + +/** プレイヤー差分同期サービスを生成する */ +export const createPlayerSyncService = ( + deps: CreatePlayerSyncServiceDeps, +): PlayerSyncService => { + const isInViewerAoi = ( + target: { x: number; y: number }, + aoiWindow: AoiWindow, + ): boolean => { + return isTargetInAoiWindow(target, aoiWindow); + }; + + const syncVisiblePlayersByViewer = ( + roomId: RoomId, + viewerId: SocketId, + visiblePlayers: domain.game.player.PlayerData[], + ): void => { + const previousVisibleIds = deps.realtimeRoomSyncState.getVisiblePlayerIdsSnapshot( + roomId, + viewerId, + ); + const viewerPositionCache = deps.realtimeRoomSyncState.getPlayerPositionCache( + roomId, + viewerId, + ); + const nextVisibleIds = new Set(visiblePlayers.map((player) => player.id)); + + visiblePlayers.forEach((player) => { + if (!previousVisibleIds.has(player.id)) { + deps.reliable.emitToSocketById(viewerId, protocol.SocketEvents.NEW_PLAYER, player); + } + }); + + previousVisibleIds.forEach((playerId) => { + if (nextVisibleIds.has(playerId)) { + return; + } + + viewerPositionCache.delete(playerId); + deps.reliable.emitToSocketById(viewerId, protocol.SocketEvents.REMOVE_PLAYER, playerId); + }); + + deps.realtimeRoomSyncState.replaceVisiblePlayerIds(roomId, viewerId, nextVisibleIds); + }; + + return { + publishUpdatePlayersToRoom: (roomId, players) => { + const activeBombs = getActiveBombSnapshotsInRoom(deps.runtimeDeps, roomId); + const quantizedPlayers = quantizeUpdatePlayersPayload(players); + forEachRoomViewer({ + runtimeDeps: deps.runtimeDeps, + roomId, + run: ({ viewerId, viewer, roomPlayers }) => { + deps.bombSyncService.syncVisibleBombsByViewer( + roomId, + viewerId, + viewer, + activeBombs, + ); + + deps.updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = resolveViewerAoiWindow(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 = deps.realtimeRoomSyncState.getPlayerPositionCache( + roomId, + viewerId, + ); + const changedPlayers = collectChangedUpdatePlayersPayload( + visibleDeltaPlayers, + viewerPositionCache, + ); + + if (changedPlayers.length === 0) { + return; + } + + deps.reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.UPDATE_PLAYERS, + changedPlayers, + ); + }, + }); + }, + }; +}; diff --git a/apps/server/src/network/handlers/game/services/roomViewerSyncContext.ts b/apps/server/src/network/handlers/game/services/roomViewerSyncContext.ts new file mode 100644 index 0000000..1c2a7eb --- /dev/null +++ b/apps/server/src/network/handlers/game/services/roomViewerSyncContext.ts @@ -0,0 +1,54 @@ +/** + * roomViewerSyncContext + * ルーム内の受信者走査で使う共通コンテキストを提供する + * SyncService間で重複する受信者解決処理を集約する + */ +import type { domain } from "@repo/shared"; +import { + getConnectedSocketIdsInRoom, + getRoomPlayers, + type RuntimeResolverDeps, +} from "../runtime/gameRuntimeResolvers"; + +type RoomId = domain.room.Room["roomId"]; + +type ViewerId = string; + +/** ルーム受信者走査の実行入力 */ +export type ForEachRoomViewerParams = { + runtimeDeps: RuntimeResolverDeps; + roomId: RoomId; + run: (params: { + viewerId: ViewerId; + viewer: domain.game.player.PlayerData; + roomPlayers: domain.game.player.PlayerData[]; + }) => void; +}; + +/** ルーム内の接続済み受信者を列挙し,各受信者ごとに処理を実行する */ +export const forEachRoomViewer = ({ + runtimeDeps, + roomId, + run, +}: ForEachRoomViewerParams): void => { + const roomPlayers = getRoomPlayers(runtimeDeps, roomId); + if (roomPlayers.length === 0) { + return; + } + + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(runtimeDeps, roomId); + + recipientSocketIds.forEach((viewerId) => { + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + run({ + viewerId, + viewer, + roomPlayers, + }); + }); +}; 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"; diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index a7f2cc9..b36357c 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -133,10 +133,11 @@ explodeAtElapsedMs: number; }; -/** bomb-placed イベントで送受信する他プレイヤー向け爆弾確定情報,設置者識別は ownerSocketId で扱う */ +/** bomb-placed イベントで送受信する他プレイヤー向け爆弾確定情報 */ export type BombPlacedPayload = { bombId: string; - ownerSocketId: string; + /** 設置者のチームID,受信側で色解決に利用する */ + ownerTeamId: number; x: number; y: number; explodeAtElapsedMs: number;