diff --git a/apps/server/src/common/syncDelta.ts b/apps/server/src/common/syncDelta.ts new file mode 100644 index 0000000..545bd64 --- /dev/null +++ b/apps/server/src/common/syncDelta.ts @@ -0,0 +1,44 @@ +/** + * syncDelta + * ID単位の前回同期スナップショットと比較して差分抽出する共通関数を提供する + */ + +/** ID単位で抽出した差分1件の構造 */ +export type SyncDeltaEntry = { + item: TItem; + snapshot: TSnapshot; +}; + +/** 差分抽出時に必要な比較ルール */ +export type SyncDeltaOptions = { + selectId: (item: TItem) => string; + toSnapshot: (item: TItem) => TSnapshot; + isSameSnapshot: (left: TSnapshot, right: TSnapshot) => boolean; +}; + +/** ID別の前回同期状態を参照して差分のみ抽出する */ +export const collectSyncDeltaEntries = ( + items: TItem[], + lastSnapshotById: Map, + options: SyncDeltaOptions, +): SyncDeltaEntry[] => { + const changedEntries: SyncDeltaEntry[] = []; + + items.forEach((item) => { + const id = options.selectId(item); + const nextSnapshot = options.toSnapshot(item); + const lastSnapshot = lastSnapshotById.get(id); + + if (lastSnapshot && options.isSameSnapshot(lastSnapshot, nextSnapshot)) { + return; + } + + lastSnapshotById.set(id, nextSnapshot); + changedEntries.push({ + item, + snapshot: nextSnapshot, + }); + }); + + return changedEntries; +}; diff --git a/apps/server/src/domains/game/loop/HurricaneSystem.ts b/apps/server/src/domains/game/loop/HurricaneSystem.ts index dd7977b..b97d518 100644 --- a/apps/server/src/domains/game/loop/HurricaneSystem.ts +++ b/apps/server/src/domains/game/loop/HurricaneSystem.ts @@ -4,6 +4,7 @@ * GameLoop からハリケーン専用責務を分離する */ import { config } from "@server/config"; +import { collectSyncDeltaEntries } from "@server/common/syncDelta"; import { domain, type HurricaneStatePayload } from "@repo/shared"; import { Player } from "../entities/player/Player.js"; @@ -26,9 +27,6 @@ rotationRad: number; }; -const HURRICANE_POSITION_SYNC_SCALE = 10; -const HURRICANE_ROTATION_SYNC_SCALE = 4; - const quantizeValue = (value: number, scale: number): number => { return Math.round(value * scale) / scale; }; @@ -37,10 +35,22 @@ state: HurricaneState, ): HurricaneSyncSnapshot => { return { - x: quantizeValue(state.x, HURRICANE_POSITION_SYNC_SCALE), - y: quantizeValue(state.y, HURRICANE_POSITION_SYNC_SCALE), - radius: quantizeValue(state.radius, HURRICANE_POSITION_SYNC_SCALE), - rotationRad: quantizeValue(state.rotationRad, HURRICANE_ROTATION_SYNC_SCALE), + x: quantizeValue( + state.x, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, + ), + y: quantizeValue( + state.y, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, + ), + radius: quantizeValue( + state.radius, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_POSITION_QUANTIZE_SCALE, + ), + rotationRad: quantizeValue( + state.rotationRad, + config.GAME_CONFIG.NETWORK_SYNC.HURRICANE_ROTATION_QUANTIZE_SCALE, + ), }; }; @@ -120,27 +130,25 @@ /** 同期配信用のハリケーン状態配列を返す */ public getUpdatePayload(): HurricaneStatePayload[] { - const changedPayloads: HurricaneStatePayload[] = []; - - this.hurricanes.forEach((hurricane) => { - const nextSnapshot = toHurricaneSyncSnapshot(hurricane); - const lastSnapshot = this.lastSentSnapshotByHurricaneId.get(hurricane.id); - - if (lastSnapshot && isSameHurricaneSyncSnapshot(lastSnapshot, nextSnapshot)) { - return; - } - - this.lastSentSnapshotByHurricaneId.set(hurricane.id, nextSnapshot); - changedPayloads.push({ - id: hurricane.id, - x: nextSnapshot.x, - y: nextSnapshot.y, - radius: nextSnapshot.radius, - rotationRad: nextSnapshot.rotationRad, - }); + return collectSyncDeltaEntries( + this.hurricanes, + this.lastSentSnapshotByHurricaneId, + { + selectId: (hurricane) => hurricane.id, + toSnapshot: (hurricane) => toHurricaneSyncSnapshot(hurricane), + isSameSnapshot: (left, right) => + isSameHurricaneSyncSnapshot(left, right), + }, + ).map((entry) => { + const { item, snapshot } = entry; + return { + id: item.id, + x: snapshot.x, + y: snapshot.y, + radius: snapshot.radius, + rotationRad: snapshot.rotationRad, + }; }); - - return changedPayloads; } /** クールダウン付きで被弾プレイヤーID配列を返す */ diff --git a/apps/server/src/network/adapters/gamePayloadSanitizers.ts b/apps/server/src/network/adapters/gamePayloadSanitizers.ts index fbd4adb..dd188fb 100644 --- a/apps/server/src/network/adapters/gamePayloadSanitizers.ts +++ b/apps/server/src/network/adapters/gamePayloadSanitizers.ts @@ -1,17 +1,18 @@ /** * gamePayloadSanitizers - * ゲーム関連の送信ペイロードを境界で正規化する + * ゲーム関連の送信ペイロードを正規化し,同期差分へ変換する */ import { domain } from "@repo/shared"; import type { UpdatePlayersPayload } from "@repo/shared"; +import { collectSyncDeltaEntries } from "@server/common/syncDelta"; type QuantizedPosition = { x: number; y: number; }; -/** UPDATE_PLAYERS の送信値を座標差分のみへ正規化する */ -export const sanitizeUpdatePlayersPayload = ( +/** UPDATE_PLAYERS の送信値を座標量子化した配列へ正規化する */ +export const quantizeUpdatePlayersPayload = ( players: UpdatePlayersPayload ): UpdatePlayersPayload => { return players.map(({ id, x, y }) => { @@ -24,27 +25,22 @@ }); }; -/** UPDATE_PLAYERS の量子化済み差分から前回送信済みの同値座標を除外する */ -export const filterUnchangedUpdatePlayersPayload = ( +/** UPDATE_PLAYERS の量子化済み配列から同値座標を除外して差分のみ返す */ +export const collectChangedUpdatePlayersPayload = ( players: UpdatePlayersPayload, lastSentPositionByPlayerId: Map ): UpdatePlayersPayload => { - const changedPlayers: UpdatePlayersPayload = []; - - players.forEach((player) => { - const lastSentPosition = lastSentPositionByPlayerId.get(player.id); - const nextPosition = { x: player.x, y: player.y }; - - if ( - lastSentPosition - && domain.game.player.isSameMovePayload(lastSentPosition, nextPosition) - ) { - return; - } - - lastSentPositionByPlayerId.set(player.id, nextPosition); - changedPlayers.push(player); - }); - - return changedPlayers; + return collectSyncDeltaEntries(players, lastSentPositionByPlayerId, { + selectId: (player) => player.id, + toSnapshot: (player) => ({ x: player.x, y: player.y }), + isSameSnapshot: (left, right) => + domain.game.player.isSameMovePayload(left, right), + }).map((entry) => entry.item); }; + +/** 既存呼び出し互換のため残す UPDATE_PLAYERS 量子化関数 */ +export const sanitizeUpdatePlayersPayload = quantizeUpdatePlayersPayload; + +/** 既存呼び出し互換のため残す UPDATE_PLAYERS 同値除外関数 */ +export const filterUnchangedUpdatePlayersPayload = + collectChangedUpdatePlayersPayload; diff --git a/apps/server/src/network/adapters/realtimeRoomSyncState.ts b/apps/server/src/network/adapters/realtimeRoomSyncState.ts new file mode 100644 index 0000000..d712228 --- /dev/null +++ b/apps/server/src/network/adapters/realtimeRoomSyncState.ts @@ -0,0 +1,37 @@ +/** + * realtimeRoomSyncState + * ルーム単位で高頻度同期の前回送信状態を保持するストアを提供する + */ +import type { domain } from "@repo/shared"; + +type RoomId = domain.room.Room["roomId"]; + +/** ルーム単位のプレイヤー送信座標キャッシュの構造 */ +export type RoomPlayerPositionCache = Map; + +/** 高頻度同期向けのルーム状態ストア操作契約 */ +export type RealtimeRoomSyncStateStore = { + getPlayerPositionCache: (roomId: RoomId) => RoomPlayerPositionCache; + resetRoom: (roomId: RoomId) => void; +}; + +/** 高頻度同期向けのルーム状態ストアを生成する */ +export const createRealtimeRoomSyncStateStore = (): RealtimeRoomSyncStateStore => { + const playerPositionCacheByRoomId = new Map(); + + return { + getPlayerPositionCache: (roomId) => { + const existing = playerPositionCacheByRoomId.get(roomId); + if (existing) { + return existing; + } + + const created: RoomPlayerPositionCache = new Map(); + playerPositionCacheByRoomId.set(roomId, created); + return created; + }, + resetRoom: (roomId) => { + playerPositionCacheByRoomId.delete(roomId); + }, + }; +}; diff --git a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts index 4c2dce9..c1a3d03 100644 --- a/apps/server/src/network/handlers/game/createGameOutputAdapter.ts +++ b/apps/server/src/network/handlers/game/createGameOutputAdapter.ts @@ -25,9 +25,10 @@ } from "@server/domains/game/application/ports/gameUseCasePorts"; import { isBotPlayerId } from "@server/domains/game/application/services/bot/index.js"; import { - filterUnchangedUpdatePlayersPayload, - sanitizeUpdatePlayersPayload, + 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"; @@ -52,27 +53,7 @@ common: CommonHandlerContext, ): GameOutputAdapter => { const { reliable, realtime } = common; - const lastSentPlayerPositionsByRoomId = new Map< - RoomId, - Map - >(); - - const getLastSentPlayerPositions = ( - roomId: RoomId, - ): Map => { - const existing = lastSentPlayerPositionsByRoomId.get(roomId); - if (existing) { - return existing; - } - - const created = new Map(); - lastSentPlayerPositionsByRoomId.set(roomId, created); - return created; - }; - - const clearRoomRealtimeCaches = (roomId: RoomId): void => { - lastSentPlayerPositionsByRoomId.delete(roomId); - }; + const realtimeRoomSyncState = createRealtimeRoomSyncStateStore(); return { publishPongToSocket: (payload: PongPayload) => { @@ -82,10 +63,10 @@ roomId: RoomId, players: UpdatePlayersPayload, ) => { - const sanitizedPlayers = sanitizeUpdatePlayersPayload(players); - const changedPlayers = filterUnchangedUpdatePlayersPayload( - sanitizedPlayers, - getLastSentPlayerPositions(roomId), + const quantizedPlayers = quantizeUpdatePlayersPayload(players); + const changedPlayers = collectChangedUpdatePlayersPayload( + quantizedPlayers, + realtimeRoomSyncState.getPlayerPositionCache(roomId), ); if (changedPlayers.length === 0) { @@ -120,14 +101,14 @@ ); }, publishGameEndToRoom: (roomId: RoomId) => { - clearRoomRealtimeCaches(roomId); + realtimeRoomSyncState.resetRoom(roomId); reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_END); }, publishGameResultToRoom: (roomId: RoomId, payload: GameResultPayload) => { reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_RESULT, payload); }, publishGameStartToRoom: (roomId: RoomId, payload: GameStartPayload) => { - clearRoomRealtimeCaches(roomId); + realtimeRoomSyncState.resetRoom(roomId); reliable.emitToRoom(roomId, protocol.SocketEvents.GAME_START, payload); }, publishCurrentPlayersToSocket: (players: CurrentPlayersPayload) => { diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index b16fa5e..ee75165 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -7,6 +7,8 @@ const NETWORK_SYNC_CONFIG = { PLAYER_POSITION_UPDATE_MS: 50, POSITION_QUANTIZE_SCALE: 100, + HURRICANE_POSITION_QUANTIZE_SCALE: 10, + HURRICANE_ROTATION_QUANTIZE_SCALE: 4, } as const; /** ゲーム全体で利用する共有設定値 */ @@ -19,6 +21,8 @@ NETWORK_SYNC: NETWORK_SYNC_CONFIG, PLAYER_POSITION_UPDATE_MS: NETWORK_SYNC_CONFIG.PLAYER_POSITION_UPDATE_MS, // 後方互換のため維持 POSITION_QUANTIZE_SCALE: NETWORK_SYNC_CONFIG.POSITION_QUANTIZE_SCALE, // 後方互換のため維持 + HURRICANE_POSITION_QUANTIZE_SCALE: NETWORK_SYNC_CONFIG.HURRICANE_POSITION_QUANTIZE_SCALE, // 後方互換のため維持 + HURRICANE_ROTATION_QUANTIZE_SCALE: NETWORK_SYNC_CONFIG.HURRICANE_ROTATION_QUANTIZE_SCALE, // 後方互換のため維持 // グリッド(マス)設定(クライアント/サーバー契約) GRID_COLS: 40, // 横のマス数(グリッド単位) diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index e5ab7db..8339b07 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -24,6 +24,8 @@ CurrentPlayersPayload, UpdateMapCellsPayload, HurricaneStatePayload, + HurricaneSnapshotPayload, + HurricaneDeltaPayload, UpdateHurricanesPayload, NewPlayerPayload, RemovePlayerPayload, diff --git a/packages/shared/src/protocol/payloads/gamePayloads.ts b/packages/shared/src/protocol/payloads/gamePayloads.ts index afb03bd..f44645e 100644 --- a/packages/shared/src/protocol/payloads/gamePayloads.ts +++ b/packages/shared/src/protocol/payloads/gamePayloads.ts @@ -78,7 +78,13 @@ }; /** update-hurricanes イベントで送受信するハリケーン状態配列 */ -export type UpdateHurricanesPayload = HurricaneStatePayload[]; +export type HurricaneSnapshotPayload = HurricaneStatePayload[]; + +/** update-hurricanes イベントで送受信するハリケーン差分配列 */ +export type HurricaneDeltaPayload = HurricaneStatePayload[]; + +/** update-hurricanes イベントで送受信するハリケーン差分配列(互換名) */ +export type UpdateHurricanesPayload = HurricaneDeltaPayload; /** * new-player イベントで送受信するプレイヤー情報