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..af223a0 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); } + /** 現在アクティブな爆弾一覧を返す */ + getActiveBombs(): ActiveBombSnapshot[] { + return this.lifecycleService.getActiveBombs(); + } + 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..db39eae 100644 --- a/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts +++ b/apps/server/src/domains/game/application/ports/gameUseCasePorts.ts @@ -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 { + getActiveBombs(): 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..34995d7 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 getActiveBombs(): ActiveBombSnapshot[] { + return this.bombStateStore.activeBombRegistry.getBombsSnapshot().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..a5317ae 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 getActiveBombs(): ActiveBombSnapshot[] { + return this.sessionRef.current?.getActiveBombs() ?? []; + } + 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..0d42465 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 getBombsSnapshot(): 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/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 845f4ff..7246ad6 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -7,6 +7,7 @@ import type { BombPlacedAckPayload, BombPlacedPayload, + HurricaneStatePayload, domain, GameStartPayload, GameResultPayload, @@ -20,6 +21,7 @@ UpdatePlayersPayload, } from "@repo/shared"; import type { + ActiveBombSnapshot, BombPlacementOutputPort, PlayerHitOutputPort, GameOutputPort, @@ -76,6 +78,10 @@ const { reliable } = common; const { roomManager, runtimeRegistry } = deps; const realtimeRoomSyncState = createRealtimeRoomSyncStateStore(); + const hurricaneSnapshotByRoomId = new Map< + RoomId, + Map + >(); const getConnectedSocketIdsInRoom = (roomId: RoomId): SocketId[] => { const room = roomManager.getRoomById(roomId); @@ -97,6 +103,15 @@ return gameManager.getRoomPlayers(); }; + const getActiveBombsInRoom = (roomId: RoomId): ActiveBombSnapshot[] => { + const gameManager = runtimeRegistry.getGameManagerByRoomId(roomId); + if (!gameManager) { + return []; + } + + return gameManager.getActiveBombs(); + }; + const getAoiWindowForViewer = ( viewer: domain.game.player.PlayerData, ): { minCol: number; maxCol: number; minRow: number; maxRow: number } => { @@ -177,6 +192,120 @@ }); }; + const syncVisibleBombsByViewer = ( + roomId: RoomId, + viewerId: SocketId, + viewer: domain.game.player.PlayerData, + bombs: ActiveBombSnapshot[], + ): void => { + updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = getAoiWindowForViewer(viewer); + const visibleBombIds = realtimeRoomSyncState.getVisibleBombIds(roomId, viewerId); + const nextVisibleBombIds = new Set(); + + bombs.forEach((bomb) => { + if (!isInViewerAoi(bomb, aoiWindow)) { + return; + } + + nextVisibleBombIds.add(bomb.bombId); + if (visibleBombIds.has(bomb.bombId)) { + return; + } + + if (viewerId === bomb.ownerPlayerId && !isBotPlayerId(bomb.ownerPlayerId)) { + return; + } + + const payload: BombPlacedPayload = { + bombId: bomb.bombId, + ownerTeamId: bomb.ownerTeamId, + x: bomb.x, + y: bomb.y, + explodeAtElapsedMs: bomb.explodeAtElapsedMs, + }; + reliable.emitToSocketById(viewerId, protocol.SocketEvents.BOMB_PLACED, payload); + }); + + visibleBombIds.clear(); + nextVisibleBombIds.forEach((bombId) => { + visibleBombIds.add(bombId); + }); + }; + + 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[] => { + updateViewerAoiCellCache(roomId, viewerId, viewer); + const aoiWindow = getAoiWindowForViewer(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 visibleIdsCache = realtimeRoomSyncState.getVisibleHurricaneIds( + roomId, + viewerId, + ); + visibleIdsCache.clear(); + hurricanes.forEach((hurricane) => { + visibleIdsCache.add(hurricane.id); + }); + }; + + 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; + }; + const emitReliableToRoom = ( roomId: RoomId, event: ReliableRoomEvent, @@ -199,6 +328,7 @@ players: UpdatePlayersPayload, ) => { const roomPlayers = getPlayersInRoom(roomId); + const activeBombs = getActiveBombsInRoom(roomId); if (roomPlayers.length === 0) { return; } @@ -213,6 +343,8 @@ return; } + syncVisibleBombsByViewer(roomId, viewerId, viewer, activeBombs); + updateViewerAoiCellCache(roomId, viewerId, viewer); const aoiWindow = getAoiWindowForViewer(viewer); @@ -265,16 +397,101 @@ roomId: RoomId, hurricanes: CurrentHurricanesPayload, ) => { - emitReliableToRoom(roomId, protocol.SocketEvents.CURRENT_HURRICANES, hurricanes); + replaceRoomHurricaneSnapshot(roomId, hurricanes); + + const roomPlayers = getPlayersInRoom(roomId); + if (roomPlayers.length === 0) { + return; + } + + 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; + } + + const visibleHurricanes = collectVisibleHurricanesByViewer( + roomId, + viewerId, + viewer, + hurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, visibleHurricanes); + + reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.CURRENT_HURRICANES, + visibleHurricanes, + ); + }); }, publishUpdateHurricanesToRoom: ( roomId: RoomId, hurricanes: UpdateHurricanesPayload, ) => { - emitReliableToRoom(roomId, protocol.SocketEvents.UPDATE_HURRICANES, hurricanes); + upsertRoomHurricaneSnapshot(roomId, hurricanes); + + const roomPlayers = getPlayersInRoom(roomId); + if (roomPlayers.length === 0) { + return; + } + + const roomPlayerById = new Map(roomPlayers.map((player) => [player.id, player])); + const recipientSocketIds = getConnectedSocketIdsInRoom(roomId); + const roomSnapshot = hurricaneSnapshotByRoomId.get(roomId); + + recipientSocketIds.forEach((viewerId) => { + const viewer = roomPlayerById.get(viewerId); + if (!viewer) { + return; + } + + const nextVisibleHurricanes = collectVisibleHurricanesByViewer( + roomId, + viewerId, + viewer, + roomSnapshot?.values() ?? [], + ); + const previousVisibleIds = new Set( + realtimeRoomSyncState.getVisibleHurricaneIds(roomId, viewerId), + ); + const hasMembershipChanged = hasChangedVisibleHurricaneIds( + previousVisibleIds, + nextVisibleHurricanes, + ); + + if (hasMembershipChanged) { + 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; + } + + reliable.emitToSocketById( + viewerId, + protocol.SocketEvents.UPDATE_HURRICANES, + visibleUpdateHurricanes, + ); + syncVisibleHurricaneIdsByViewer(roomId, viewerId, nextVisibleHurricanes); + }); }, publishGameEndToRoom: (roomId: RoomId) => { realtimeRoomSyncState.resetRoom(roomId); + hurricaneSnapshotByRoomId.delete(roomId); emitReliableToRoom(roomId, protocol.SocketEvents.GAME_END); }, publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { @@ -282,6 +499,7 @@ }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { realtimeRoomSyncState.resetRoom(roomId); + hurricaneSnapshotByRoomId.delete(roomId); emitReliableToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, publishCurrentPlayersToSocket: (players: CurrentPlayersPayload) => { 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;