diff --git a/apps/client/src/config/index.ts b/apps/client/src/config/index.ts index 87b1862..3a98026 100644 --- a/apps/client/src/config/index.ts +++ b/apps/client/src/config/index.ts @@ -43,6 +43,24 @@ MAP_BG_COLOR: 0x111111, MAP_GRID_COLOR: 0x333333, MAP_BORDER_COLOR: 0xff4444, + CLOCK_SYNC: { + ESTIMATOR: { + MAX_ACCEPTED_RTT_MS: 1000, + }, + SMOOTHER: { + OFFSET_ALPHA: 0.12, + RTT_ALPHA: 0.25, + MAX_ACCEPTED_OFFSET_JUMP_MS: 250, + }, + INTERVAL_POLICY: { + DEFAULT_INTERVAL_MS: 3000, + LOW_LATENCY_THRESHOLD_MS: 80, + MEDIUM_LATENCY_THRESHOLD_MS: 180, + LOW_LATENCY_INTERVAL_MS: 5000, + MEDIUM_LATENCY_INTERVAL_MS: 3000, + HIGH_LATENCY_INTERVAL_MS: 2000, + }, + }, } as const; const GAME_CONFIG = { diff --git a/apps/client/src/network/SocketManager.ts b/apps/client/src/network/SocketManager.ts index bb8162a..5a0143c 100644 --- a/apps/client/src/network/SocketManager.ts +++ b/apps/client/src/network/SocketManager.ts @@ -4,6 +4,10 @@ import { createTitleHandler, type TitleHandler } from "./handlers/TitleHandler"; import { createLobbyHandler, type LobbyHandler } from "./handlers/LobbyHandler"; import { createGameHandler, type GameHandler } from "./handlers/GameHandler"; +import { + createGameSyncHandler, + type GameSyncHandler, +} from "./handlers/GameSyncHandler"; export class SocketManager { public socket: Socket; @@ -11,6 +15,7 @@ public title: TitleHandler; public lobby: LobbyHandler; public game: GameHandler; + public gameSync: GameSyncHandler; constructor() { const serverUrl = import.meta.env.PROD @@ -26,6 +31,7 @@ this.title = createTitleHandler(this.socket); this.lobby = createLobbyHandler(this.socket); this.game = createGameHandler(this.socket); + this.gameSync = createGameSyncHandler(this.socket); } } diff --git a/apps/client/src/network/handlers/GameSyncHandler.ts b/apps/client/src/network/handlers/GameSyncHandler.ts new file mode 100644 index 0000000..72fdc3d --- /dev/null +++ b/apps/client/src/network/handlers/GameSyncHandler.ts @@ -0,0 +1,65 @@ +/** + * GameSyncHandler + * ゲーム中の時刻同期ソケット操作を提供する + * PING送信とPONG購読をゲーム操作APIから分離する + */ +import type { Socket } from "socket.io-client"; +import { contracts as protocol } from "@repo/shared"; +import type { + ClientToServerEventPayloadMap, + PongPayload, + ServerToClientEventPayloadMap, +} from "@repo/shared"; +import { createClientSocketEventBridge } from "./socketEventBridge"; + +/** 時刻同期向けソケット操作の契約 */ +export type GameSyncHandler = { + onPong: (callback: (payload: PongPayload) => void) => void; + offPong: (callback: (payload: PongPayload) => void) => void; + sendPing: (clientTime: number) => void; +}; + +/** ソケットインスタンスから時刻同期向けハンドラを生成する */ +export const createGameSyncHandler = (socket: Socket): GameSyncHandler => { + const { onEvent, offEvent, emitEvent } = createClientSocketEventBridge(socket); + type ReceiveEventName = Extract; + type SendEventName = Extract; + + const createSubscriptionPair = ( + event: TEvent, + ) => { + return { + on: ( + callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void, + ) => { + onEvent(event, callback); + }, + off: ( + callback: (payload: ServerToClientEventPayloadMap[TEvent]) => void, + ) => { + offEvent(event, callback); + }, + }; + }; + + const createPayloadSender = (event: TEvent) => { + return (payload: ClientToServerEventPayloadMap[TEvent]) => { + emitEvent(event, payload); + }; + }; + + const pongSubscription = createSubscriptionPair(protocol.SocketEvents.PONG); + const sendPingPayload = createPayloadSender(protocol.SocketEvents.PING); + + return { + onPong: (callback) => { + pongSubscription.on(callback); + }, + offPong: (callback) => { + pongSubscription.off(callback); + }, + sendPing: (clientTime) => { + sendPingPayload(clientTime); + }, + }; +}; diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index d9d2f1e..387c17c 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -30,6 +30,9 @@ type GameUiState, } from "./application/ui/GameUiStateSyncService"; import { preloadGameStartAssets } from "./application/assets/GameAssetPreloader"; +import { ClockSyncService } from "./application/time/ClockSyncService"; +import { SYSTEM_TIME_PROVIDER } from "./application/time/TimeProvider"; +import { ClockSyncLoop } from "./application/time/ClockSyncLoop"; /** GameManager の依存注入オプション型 */ export type GameManagerDependencies = { @@ -55,12 +58,16 @@ private myId: string; private container: HTMLDivElement; private sessionFacade: GameSessionFacade; + private gameActionSender: GameActionSender; private runtime: GameSceneRuntime; private gameEventFacade: GameEventFacade; private combatFacade: CombatLifecycleFacade; private lifecycleState: SceneLifecycleState; private uiStateSyncService: GameUiStateSyncService; private disposableRegistry: DisposableRegistry; + private readonly clockSyncService: ClockSyncService; + private readonly nowMsProvider = SYSTEM_TIME_PROVIDER.now; + private readonly clockSyncLoop: ClockSyncLoop; private localBombHitCount = 0; public getStartCountdownSec(): number { @@ -98,10 +105,22 @@ ) { this.container = container; // 明示的に代入 this.myId = myId; - this.sessionFacade = dependencies.sessionFacade ?? new GameSessionFacade(); + this.clockSyncService = new ClockSyncService(); + this.clockSyncLoop = new ClockSyncLoop({ + sendPing: (clientTime) => { + this.gameActionSender.sendPing(clientTime); + }, + getNextIntervalMs: () => this.clockSyncService.getRecommendedSyncIntervalMs(), + nowMsProvider: this.nowMsProvider, + }); + this.sessionFacade = + dependencies.sessionFacade ?? + new GameSessionFacade({ + nowMsProvider: () => this.clockSyncService.getSynchronizedNowMs(), + }); this.lifecycleState = dependencies.lifecycleState ?? new SceneLifecycleState(); - const gameActionSender = + this.gameActionSender = dependencies.gameActionSender ?? new SocketGameActionSender(); const moveSender = dependencies.moveSender ?? new SocketPlayerMoveSender(); const sceneFactories = dependencies.sceneFactories; @@ -122,7 +141,7 @@ myId: this.myId, acquireInputLock: this.lockInput.bind(this), onSendBombHitReport: (bombId) => { - gameActionSender.sendBombHitReport(bombId); + this.gameActionSender.sendBombHitReport(bombId); }, onLocalBombHitCountChanged: (count) => { this.localBombHitCount = count; @@ -135,9 +154,15 @@ players: this.players, myId: this.myId, sessionFacade: this.sessionFacade, - gameActionSender, + gameActionSender: this.gameActionSender, moveSender, getElapsedMs: () => this.sessionFacade.getElapsedMs(), + onPongReceived: (payload) => { + this.clockSyncService.updateFromPong(payload); + }, + onGameStartClockHint: (serverNowMs) => { + this.clockSyncService.seedFromServerNow(serverNowMs); + }, eventPorts: { onGameStarted: this.gameEventFacade.applyGameStarted.bind( this.gameEventFacade, @@ -177,6 +202,12 @@ lifecycleState: this.lifecycleState, app: this.app, }); + this.disposableRegistry.add(() => { + this.clockSyncLoop.dispose(); + }); + this.disposableRegistry.add(() => { + this.clockSyncService.reset(); + }); } /** @@ -196,6 +227,7 @@ return; } + this.clockSyncLoop.start(); this.uiStateSyncService.startTicker(); this.uiStateSyncService.emitIfChanged(true); } @@ -260,4 +292,5 @@ this.lifecycleState.markDestroyed(); this.disposableRegistry.disposeAll(); } + } diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 864d6c5..2e3132c 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -8,6 +8,7 @@ BombPlacedAckPayload, BombPlacedPayload, HurricaneHitPayload, + PongPayload, PlayerHitPayload, } from "@repo/shared"; import { AppearanceResolver } from "./AppearanceResolver"; @@ -30,6 +31,8 @@ onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; + onPongReceived: (payload: PongPayload) => void; + onGameStartClockHint: (serverNowMs: number) => void; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -57,6 +60,8 @@ onBombPlacementAcknowledged, onRemotePlayerHit, onRemoteHurricaneHit, + onPongReceived, + onGameStartClockHint, }: GameNetworkSyncOptions) { this.stateApplier = new GameNetworkStateApplier({ worldContainer, @@ -70,6 +75,8 @@ onBombPlacementAcknowledged, onRemotePlayerHit, onRemoteHurricaneHit, + onPongReceived, + onGameStartClockHint, onDebugLog: this.debugLog, }); diff --git a/apps/client/src/scenes/game/application/GameTimer.ts b/apps/client/src/scenes/game/application/GameTimer.ts index 5104d44..19b6f91 100644 --- a/apps/client/src/scenes/game/application/GameTimer.ts +++ b/apps/client/src/scenes/game/application/GameTimer.ts @@ -4,6 +4,7 @@ * 表示用の残り秒数取得を提供する */ import { config } from "@client/config"; +import { SYSTEM_TIME_PROVIDER } from "@client/scenes/game/application/time/TimeProvider"; /** 現在時刻ミリ秒を返す関数型 */ export type NowMsProvider = () => number; @@ -13,7 +14,7 @@ private gameStartTime: number | null = null; private nowMsProvider: NowMsProvider; - constructor(nowMsProvider: NowMsProvider = () => Date.now()) { + constructor(nowMsProvider: NowMsProvider = SYSTEM_TIME_PROVIDER.now) { this.nowMsProvider = nowMsProvider; } diff --git a/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts b/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts index 46b8089..9c98b64 100644 --- a/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts +++ b/apps/client/src/scenes/game/application/lifecycle/GameSessionFacade.ts @@ -6,12 +6,18 @@ import { GameTimer } from "@client/scenes/game/application/GameTimer"; import { InputGate, type JoystickInput } from "./InputGate"; +/** GameSessionFacade の初期化入力型 */ +export type GameSessionFacadeOptions = { + nowMsProvider?: () => number; +}; + /** ゲーム進行状態と入力可否の窓口を提供する */ export class GameSessionFacade { - private readonly timer = new GameTimer(); + private readonly timer: GameTimer; private readonly inputGate: InputGate; - constructor() { + constructor(options: GameSessionFacadeOptions = {}) { + this.timer = new GameTimer(options.nowMsProvider); this.inputGate = new InputGate({ isStartedProvider: () => this.timer.isStarted(), isPlayableTimeProvider: () => this.timer.getRemainingTime() > 0, diff --git a/apps/client/src/scenes/game/application/network/GameActionSender.ts b/apps/client/src/scenes/game/application/network/GameActionSender.ts index 8c22eb2..4a8cafe 100644 --- a/apps/client/src/scenes/game/application/network/GameActionSender.ts +++ b/apps/client/src/scenes/game/application/network/GameActionSender.ts @@ -11,6 +11,7 @@ readyForGame: () => void; sendPlaceBomb: (payload: PlaceBombPayload) => void; sendBombHitReport: (bombId: string) => void; + sendPing: (clientTime: number) => void; }; /** ソケット経由でゲーム中送信アクションを実行する実装 */ @@ -29,4 +30,9 @@ public sendBombHitReport(bombId: string): void { socketManager.game.sendBombHitReport({ bombId }); } -} \ No newline at end of file + + /** 時刻同期PINGをサーバーへ送信する */ + public sendPing(clientTime: number): void { + socketManager.gameSync.sendPing(clientTime); + } +} diff --git a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts index 87104b5..5334686 100644 --- a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts +++ b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts @@ -12,6 +12,7 @@ HurricaneHitPayload, NewPlayerPayload, PlayerHitPayload, + PongPayload, RemovePlayerPayload, UpdateHurricanesPayload, UpdateMapCellsPayload, @@ -38,6 +39,7 @@ bombPlacedAck: SocketSubscription; playerHit: SocketSubscription; hurricaneHit: SocketSubscription; + pong: SocketSubscription; }; /** 購読辞書生成に必要なハンドラ群 */ @@ -54,6 +56,7 @@ onBombPlacedAck: (payload: BombPlacedAckPayload) => void; onPlayerHit: (payload: PlayerHitPayload) => void; onHurricaneHit: (payload: HurricaneHitPayload) => void; + onPong: (payload: PongPayload) => void; }; type SubscriptionDefinition = { @@ -154,6 +157,13 @@ unbind: () => socketManager.game.offHurricaneHit(handlers.onHurricaneHit), }), }, + { + key: "pong", + create: (handlers) => ({ + bind: () => socketManager.gameSync.onPong(handlers.onPong), + unbind: () => socketManager.gameSync.offPong(handlers.onPong), + }), + }, ]; /** ソケット購読辞書を生成する */ diff --git a/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts b/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts index c2e3aa9..d2f1640 100644 --- a/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts +++ b/apps/client/src/scenes/game/application/network/adapters/GameNetworkEventAdapter.ts @@ -12,17 +12,14 @@ } from "@repo/shared"; /** ゲーム開始受信ペイロードから開始時刻を抽出する - * serverNow を用いてクライアントとサーバーの時計差を補正し, - * クライアント時計基準の開始時刻を返す + * サーバー時刻基準の開始時刻を返す */ export const toGameStartedAt = (payload: GameStartPayload): number | null => { - if (!payload || !payload.startTime) { + if (!payload || payload.startTime == null) { return null; } - // clockOffset > 0: サーバーがクライアントより進んでいる - const clockOffset = payload.serverNow - Date.now(); - return payload.startTime - clockOffset; + return payload.startTime; }; /** 爆弾設置受信ペイロードを内部ペイロードへ正規化する */ diff --git a/apps/client/src/scenes/game/application/network/handlers/ClockSyncEventApplier.ts b/apps/client/src/scenes/game/application/network/handlers/ClockSyncEventApplier.ts new file mode 100644 index 0000000..08a3c44 --- /dev/null +++ b/apps/client/src/scenes/game/application/network/handlers/ClockSyncEventApplier.ts @@ -0,0 +1,53 @@ +/** + * ClockSyncEventApplier + * 時刻同期関連イベントの反映を担当する + * ゲーム開始時刻同期とPONG処理を専用化する + */ +import type { GameStartPayload, PongPayload } from "@repo/shared"; +import { toGameStartedAt } from "@client/scenes/game/application/network/adapters/GameNetworkEventAdapter"; + +/** 時刻同期イベント反映の初期化入力 */ +export type ClockSyncEventApplierOptions = { + onGameStarted: (startTime: number) => void; + onGameStartClockHint: (serverNowMs: number) => void; + onPongReceived: (payload: PongPayload) => void; + onDebugLog?: (message: string) => void; +}; + +/** 時刻同期イベントの状態反映を担当する */ +export class ClockSyncEventApplier { + private readonly onGameStarted: (startTime: number) => void; + private readonly onGameStartClockHint: (serverNowMs: number) => void; + private readonly onPongReceived: (payload: PongPayload) => void; + private readonly onDebugLog: (message: string) => void; + + constructor({ + onGameStarted, + onGameStartClockHint, + onPongReceived, + onDebugLog, + }: ClockSyncEventApplierOptions) { + this.onGameStarted = onGameStarted; + this.onGameStartClockHint = onGameStartClockHint; + this.onPongReceived = onPongReceived; + this.onDebugLog = onDebugLog ?? (() => undefined); + } + + /** GAME_STARTイベントを反映する */ + public applyGameStart(payload: GameStartPayload): void { + this.onGameStartClockHint(payload.serverNow); + + const startTime = toGameStartedAt(payload); + if (startTime === null) { + return; + } + + this.onGameStarted(startTime); + this.onDebugLog(`[GameNetworkSync] ゲーム開始時刻同期完了: ${startTime}`); + } + + /** PONGイベントを反映する */ + public applyPong(payload: PongPayload): void { + this.onPongReceived(payload); + } +} 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 4deec45..908e72c 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -8,6 +8,7 @@ BombPlacedAckPayload, BombPlacedPayload, HurricaneHitPayload, + PongPayload, PlayerHitPayload, } from "@repo/shared"; import { domain } from "@repo/shared"; @@ -16,12 +17,12 @@ import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; import { toBombPlacementAcknowledgedPayload, - toGameStartedAt, toRemoteBombPlacedPayload, toRemoteHurricaneHitPayload, toRemotePlayerHitPayload, } from "@client/scenes/game/application/network/adapters/GameNetworkEventAdapter"; import { CombatSyncHandler } from "./CombatSyncHandler"; +import { ClockSyncEventApplier } from "./ClockSyncEventApplier"; import { HurricaneSyncHandler } from "./HurricaneSyncHandler"; import { MapSyncHandler } from "./MapSyncHandler"; import { PlayerSyncHandler } from "./PlayerSyncHandler"; @@ -40,6 +41,8 @@ onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; + onPongReceived: (payload: PongPayload) => void; + onGameStartClockHint: (serverNowMs: number) => void; onDebugLog?: (message: string) => void; }; @@ -48,10 +51,9 @@ private readonly playerSyncHandler: PlayerSyncHandler; private readonly mapSyncHandler: MapSyncHandler; private readonly combatSyncHandler: CombatSyncHandler; + private readonly clockSyncEventApplier: ClockSyncEventApplier; private readonly hurricaneSyncHandler: HurricaneSyncHandler; - private readonly onGameStarted: (startTime: number) => void; private readonly onGameEnded: () => void; - private readonly onDebugLog: (message: string) => void; private readonly receivedEventHandlers: ReceivedGameEventHandlers; constructor({ @@ -66,6 +68,8 @@ onBombPlacementAcknowledged, onRemotePlayerHit, onRemoteHurricaneHit, + onPongReceived, + onGameStartClockHint, onDebugLog, }: GameNetworkStateApplierOptions) { this.playerSyncHandler = new PlayerSyncHandler({ @@ -82,9 +86,13 @@ onRemotePlayerHit, onRemoteHurricaneHit, }); - this.onGameStarted = onGameStarted; + this.clockSyncEventApplier = new ClockSyncEventApplier({ + onGameStarted, + onGameStartClockHint, + onPongReceived, + onDebugLog, + }); this.onGameEnded = onGameEnded; - this.onDebugLog = onDebugLog ?? (() => undefined); this.receivedEventHandlers = this.createReceivedEventHandlers(); } @@ -139,15 +147,7 @@ this.playerSyncHandler.handleNewPlayer(payload); }, onReceivedGameStart: (payload) => { - const startTime = toGameStartedAt(payload); - if (startTime === null) { - return; - } - - this.onGameStarted(startTime); - this.onDebugLog( - `[GameNetworkSync] ゲーム開始時刻同期完了: ${startTime}`, - ); + this.clockSyncEventApplier.applyGameStart(payload); }, onReceivedUpdatePlayers: (payload) => { this.playerSyncHandler.handlePlayerUpdates(payload); @@ -177,6 +177,9 @@ onReceivedHurricaneHit: (payload) => { this.combatSyncHandler.handleReceivedHurricaneHit(payload); }, + onReceivedPong: (payload) => { + this.clockSyncEventApplier.applyPong(payload); + }, }; } } diff --git a/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts b/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts index f2c782e..8f4d080 100644 --- a/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts +++ b/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts @@ -11,6 +11,7 @@ HurricaneHitPayload, NewPlayerPayload, PlayerHitPayload, + PongPayload, RemovePlayerPayload, UpdateHurricanesPayload, UpdateMapCellsPayload, @@ -35,6 +36,7 @@ onReceivedBombPlacedAck: (payload: BombPlacedAckPayload) => void; onReceivedPlayerHit: (payload: PlayerHitPayload) => void; onReceivedHurricaneHit: (payload: HurricaneHitPayload) => void; + onReceivedPong: (payload: PongPayload) => void; }; /** 受信イベント購読の管理を担当する */ @@ -56,6 +58,7 @@ onBombPlacedAck: handlers.onReceivedBombPlacedAck, onPlayerHit: handlers.onReceivedPlayerHit, onHurricaneHit: handlers.onReceivedHurricaneHit, + onPong: handlers.onReceivedPong, }); } diff --git a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts index 5520abf..fa616d5 100644 --- a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts +++ b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts @@ -8,6 +8,7 @@ BombPlacedAckPayload, BombPlacedPayload, HurricaneHitPayload, + PongPayload, PlayerHitPayload, } from "@repo/shared"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; @@ -35,6 +36,8 @@ onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; onRemotePlayerHit: (payload: PlayerHitPayload) => void; onRemoteHurricaneHit: (payload: HurricaneHitPayload) => void; + onPongReceived: (payload: PongPayload) => void; + onGameStartClockHint: (serverNowMs: number) => void; }; /** BombManager 生成入力型 */ @@ -88,6 +91,8 @@ getJoystickInput: () => { x: number; y: number }; moveSender: MoveSender; eventPorts: GameSceneEventPorts; + onPongReceived: (payload: PongPayload) => void; + onGameStartClockHint: (serverNowMs: number) => void; factories?: GameSceneFactoryOptions; }; @@ -111,6 +116,8 @@ private readonly getJoystickInput: () => { x: number; y: number }; private readonly moveSender: MoveSender; private readonly eventPorts: GameSceneEventPorts; + private readonly onPongReceived: (payload: PongPayload) => void; + private readonly onGameStartClockHint: (serverNowMs: number) => void; private readonly createNetworkSync: ( options: CreateNetworkSyncOptions, ) => GameNetworkSync; @@ -130,6 +137,8 @@ getJoystickInput, moveSender, eventPorts, + onPongReceived, + onGameStartClockHint, factories, }: GameSceneOrchestratorOptions) { this.app = app; @@ -142,6 +151,8 @@ this.getJoystickInput = getJoystickInput; this.moveSender = moveSender; this.eventPorts = eventPorts; + this.onPongReceived = onPongReceived; + this.onGameStartClockHint = onGameStartClockHint; this.createNetworkSync = factories?.createNetworkSync ?? ((options) => new GameNetworkSync(options)); @@ -187,6 +198,8 @@ onBombPlacementAcknowledged: this.eventPorts.onBombPlacementAcknowledged, onRemotePlayerHit: this.eventPorts.onRemotePlayerHit, onRemoteHurricaneHit: this.eventPorts.onRemoteHurricaneHit, + onPongReceived: this.onPongReceived, + onGameStartClockHint: this.onGameStartClockHint, }); networkSync.bind(); return networkSync; diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 6fb417e..0aeb79c 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -21,6 +21,7 @@ import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; import type { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; import { config } from "@client/config"; +import type { PongPayload } from "@repo/shared"; type RuntimeLifecycleState = "created" | "initialized" | "destroyed"; @@ -34,6 +35,8 @@ moveSender: MoveSender; getElapsedMs: () => number; eventPorts: GameSceneEventPorts; + onPongReceived: (payload: PongPayload) => void; + onGameStartClockHint: (serverNowMs: number) => void; sceneFactories?: GameSceneFactoryOptions; }; @@ -48,6 +51,8 @@ private readonly moveSender: MoveSender; private readonly getElapsedMs: () => number; private readonly eventPorts: GameSceneEventPorts; + private readonly onPongReceived: (payload: PongPayload) => void; + private readonly onGameStartClockHint: (serverNowMs: number) => void; private readonly sceneFactories?: GameSceneFactoryOptions; private readonly playerRepository: PlayerRepository; private readonly disposableRegistry = new DisposableRegistry(); @@ -77,6 +82,8 @@ moveSender, getElapsedMs, eventPorts, + onPongReceived, + onGameStartClockHint, sceneFactories, }: GameSceneRuntimeOptions) { this.app = app; @@ -88,6 +95,8 @@ this.moveSender = moveSender; this.getElapsedMs = getElapsedMs; this.eventPorts = eventPorts; + this.onPongReceived = onPongReceived; + this.onGameStartClockHint = onGameStartClockHint; this.sceneFactories = sceneFactories; this.playerRepository = new PlayerRepository(this.players); @@ -116,6 +125,8 @@ getJoystickInput: () => this.joystickInput, moveSender: this.moveSender, eventPorts: this.eventPorts, + onPongReceived: this.onPongReceived, + onGameStartClockHint: this.onGameStartClockHint, sceneFactories: this.sceneFactories, }); diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts index c886e60..3f02d36 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts @@ -14,6 +14,7 @@ import type { GamePlayers } from "../game.types"; import type { MoveSender } from "../network/PlayerMoveSender"; import type { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; +import type { PongPayload } from "@repo/shared"; /** Runtime配線処理の入力型 */ export type GameSceneRuntimeWiringOptions = { @@ -27,6 +28,8 @@ getJoystickInput: () => { x: number; y: number }; moveSender: MoveSender; eventPorts: GameSceneEventPorts; + onPongReceived: (payload: PongPayload) => void; + onGameStartClockHint: (serverNowMs: number) => void; sceneFactories?: GameSceneFactoryOptions; }; @@ -51,6 +54,8 @@ getJoystickInput: this.options.getJoystickInput, moveSender: this.options.moveSender, eventPorts: this.options.eventPorts, + onPongReceived: this.options.onPongReceived, + onGameStartClockHint: this.options.onGameStartClockHint, factories: this.options.sceneFactories, }); diff --git a/apps/client/src/scenes/game/application/time/ClockSyncLoop.ts b/apps/client/src/scenes/game/application/time/ClockSyncLoop.ts new file mode 100644 index 0000000..27c56a7 --- /dev/null +++ b/apps/client/src/scenes/game/application/time/ClockSyncLoop.ts @@ -0,0 +1,66 @@ +/** + * ClockSyncLoop + * 時刻同期PING送信の定期実行を管理する + * 可変間隔スケジュールと停止処理を一元化する + */ +import { SYSTEM_TIME_PROVIDER } from "@client/scenes/game/application/time/TimeProvider"; + +/** 時刻同期ループ初期化入力型 */ +export type ClockSyncLoopOptions = { + sendPing: (clientTime: number) => void; + getNextIntervalMs: () => number; + nowMsProvider?: () => number; +}; + +/** 時刻同期PING送信の開始停止を管理する */ +export class ClockSyncLoop { + private readonly sendPing: (clientTime: number) => void; + private readonly getNextIntervalMs: () => number; + private readonly nowMsProvider: () => number; + private timeoutId: ReturnType | null = null; + + constructor({ + sendPing, + getNextIntervalMs, + nowMsProvider = SYSTEM_TIME_PROVIDER.now, + }: ClockSyncLoopOptions) { + this.sendPing = sendPing; + this.getNextIntervalMs = getNextIntervalMs; + this.nowMsProvider = nowMsProvider; + } + + /** 同期ループを開始する */ + public start(): void { + this.stop(); + this.executeOnce(); + } + + /** 同期ループを停止する */ + public stop(): void { + if (this.timeoutId === null) { + return; + } + + clearTimeout(this.timeoutId); + this.timeoutId = null; + } + + /** 同期ループを停止して破棄する */ + public dispose(): void { + this.stop(); + } + + /** 現在ループが動作中かを返す */ + public isRunning(): boolean { + return this.timeoutId !== null; + } + + private executeOnce(): void { + this.sendPing(this.nowMsProvider()); + + const intervalMs = this.getNextIntervalMs(); + this.timeoutId = setTimeout(() => { + this.executeOnce(); + }, intervalMs); + } +} diff --git a/apps/client/src/scenes/game/application/time/ClockSyncService.ts b/apps/client/src/scenes/game/application/time/ClockSyncService.ts new file mode 100644 index 0000000..bfe181f --- /dev/null +++ b/apps/client/src/scenes/game/application/time/ClockSyncService.ts @@ -0,0 +1,133 @@ +/** + * ClockSyncService + * サーバー時刻との差分を平滑化して管理する + * PING/PONGのRTTを使い,外れ値を除外して同期精度を安定化する + */ +import type { PongPayload } from "@repo/shared"; +import { config } from "@client/config"; +import { + SYSTEM_TIME_PROVIDER, + type TimeProvider, +} from "@client/scenes/game/application/time/TimeProvider"; +import { + PongSampleEstimator, + type PongSampleEstimatorConfig, +} from "@client/scenes/game/application/time/PongSampleEstimator"; +import { + OffsetSmoother, + type OffsetSmootherConfig, +} from "@client/scenes/game/application/time/OffsetSmoother"; +import { + SyncIntervalPolicy, + type SyncIntervalPolicyConfig, +} from "@client/scenes/game/application/time/SyncIntervalPolicy"; + +/** 時刻同期に利用する更新パラメータ */ +export type ClockSyncConfig = { + estimator: PongSampleEstimatorConfig; + smoother: OffsetSmootherConfig; + intervalPolicy: SyncIntervalPolicyConfig; +}; + +/** 時刻同期の既定パラメータ */ +export const DEFAULT_CLOCK_SYNC_CONFIG: ClockSyncConfig = { + estimator: { + maxAcceptedRttMs: + config.GAME_CONFIG.CLOCK_SYNC.ESTIMATOR.MAX_ACCEPTED_RTT_MS, + }, + smoother: { + offsetAlpha: config.GAME_CONFIG.CLOCK_SYNC.SMOOTHER.OFFSET_ALPHA, + rttAlpha: config.GAME_CONFIG.CLOCK_SYNC.SMOOTHER.RTT_ALPHA, + maxAcceptedOffsetJumpMs: + config.GAME_CONFIG.CLOCK_SYNC.SMOOTHER.MAX_ACCEPTED_OFFSET_JUMP_MS, + }, + intervalPolicy: { + defaultIntervalMs: + config.GAME_CONFIG.CLOCK_SYNC.INTERVAL_POLICY.DEFAULT_INTERVAL_MS, + lowLatencyThresholdMs: + config.GAME_CONFIG.CLOCK_SYNC.INTERVAL_POLICY.LOW_LATENCY_THRESHOLD_MS, + mediumLatencyThresholdMs: + config.GAME_CONFIG.CLOCK_SYNC.INTERVAL_POLICY + .MEDIUM_LATENCY_THRESHOLD_MS, + lowLatencyIntervalMs: + config.GAME_CONFIG.CLOCK_SYNC.INTERVAL_POLICY.LOW_LATENCY_INTERVAL_MS, + mediumLatencyIntervalMs: + config.GAME_CONFIG.CLOCK_SYNC.INTERVAL_POLICY + .MEDIUM_LATENCY_INTERVAL_MS, + highLatencyIntervalMs: + config.GAME_CONFIG.CLOCK_SYNC.INTERVAL_POLICY.HIGH_LATENCY_INTERVAL_MS, + }, +}; + +/** サーバー時刻との差分を平滑化して保持する */ +export class ClockSyncService { + private readonly nowProvider: TimeProvider["now"]; + private readonly estimator: PongSampleEstimator; + private readonly smoother: OffsetSmoother; + private readonly intervalPolicy: SyncIntervalPolicy; + + constructor( + config: Partial = {}, + nowProvider: TimeProvider["now"] = SYSTEM_TIME_PROVIDER.now, + ) { + const mergedConfig = { + estimator: { + ...DEFAULT_CLOCK_SYNC_CONFIG.estimator, + ...config.estimator, + }, + smoother: { + ...DEFAULT_CLOCK_SYNC_CONFIG.smoother, + ...config.smoother, + }, + intervalPolicy: { + ...DEFAULT_CLOCK_SYNC_CONFIG.intervalPolicy, + ...config.intervalPolicy, + }, + }; + this.nowProvider = nowProvider; + this.estimator = new PongSampleEstimator(mergedConfig.estimator); + this.smoother = new OffsetSmoother(mergedConfig.smoother); + this.intervalPolicy = new SyncIntervalPolicy(mergedConfig.intervalPolicy); + } + + /** 受信した serverNow をもとに差分を初期化する */ + public seedFromServerNow( + serverNowMs: number, + receivedAtMs = this.nowProvider(), + ): void { + this.smoother.seed(serverNowMs, receivedAtMs); + } + + /** PONGサンプルを取り込み,差分とRTTを更新する */ + public updateFromPong( + payload: PongPayload, + receivedAtMs = this.nowProvider(), + ): void { + const sample = this.estimator.estimate(payload, receivedAtMs); + if (!sample) { + return; + } + + this.smoother.applySample(sample); + } + + /** 平滑化済みの時刻差分ミリ秒を返す */ + public getClockOffsetMs(): number { + return this.smoother.getClockOffsetMs(); + } + + /** サーバー時刻基準へ補正した現在時刻ミリ秒を返す */ + public getSynchronizedNowMs(): number { + return this.nowProvider() + this.getClockOffsetMs(); + } + + /** RTT状況に応じた次回同期推奨間隔ミリ秒を返す */ + public getRecommendedSyncIntervalMs(): number { + return this.intervalPolicy.getIntervalMs(this.smoother.getSmoothedRttMs()); + } + + /** 内部状態を初期化する */ + public reset(): void { + this.smoother.reset(); + } +} diff --git a/apps/client/src/scenes/game/application/time/OffsetSmoother.ts b/apps/client/src/scenes/game/application/time/OffsetSmoother.ts new file mode 100644 index 0000000..c58c8c3 --- /dev/null +++ b/apps/client/src/scenes/game/application/time/OffsetSmoother.ts @@ -0,0 +1,90 @@ +/** + * OffsetSmoother + * 時計差分とRTTの平滑化を管理する + * 外れ値除外とEWMA更新を集中管理する + */ +import type { PongSample } from "@client/scenes/game/application/time/PongSampleEstimator"; + +/** 平滑化処理の設定値 */ +export type OffsetSmootherConfig = { + offsetAlpha: number; + rttAlpha: number; + maxAcceptedOffsetJumpMs: number; +}; + +/** 平滑化処理の既定設定 */ +export const DEFAULT_OFFSET_SMOOTHER_CONFIG: OffsetSmootherConfig = { + offsetAlpha: 0.12, + rttAlpha: 0.25, + maxAcceptedOffsetJumpMs: 250, +}; + +/** 時計差分とRTTの平滑化状態を保持する */ +export class OffsetSmoother { + private readonly config: OffsetSmootherConfig; + private smoothedOffsetMs: number | null = null; + private smoothedRttMs: number | null = null; + + constructor(config: Partial = {}) { + this.config = { + ...DEFAULT_OFFSET_SMOOTHER_CONFIG, + ...config, + }; + } + + /** serverNowからoffset初期値を設定する */ + public seed(serverNowMs: number, receivedAtMs: number): void { + this.smoothedOffsetMs = serverNowMs - receivedAtMs; + } + + /** 推定サンプルを取り込み平滑化状態を更新する */ + public applySample(sample: PongSample): void { + this.smoothedRttMs = this.smoothValue( + this.smoothedRttMs, + sample.rttMs, + this.config.rttAlpha, + ); + + if ( + this.smoothedOffsetMs !== null && + Math.abs(sample.offsetMs - this.smoothedOffsetMs) > + this.config.maxAcceptedOffsetJumpMs + ) { + return; + } + + this.smoothedOffsetMs = this.smoothValue( + this.smoothedOffsetMs, + sample.offsetMs, + this.config.offsetAlpha, + ); + } + + /** 平滑化済みoffsetを返す */ + public getClockOffsetMs(): number { + return this.smoothedOffsetMs ?? 0; + } + + /** 平滑化済みRTTを返す */ + public getSmoothedRttMs(): number | null { + return this.smoothedRttMs; + } + + /** 内部平滑化状態を初期化する */ + public reset(): void { + this.smoothedOffsetMs = null; + this.smoothedRttMs = null; + } + + private smoothValue( + current: number | null, + measured: number, + alpha: number, + ): number { + if (current === null) { + return measured; + } + + return current * (1 - alpha) + measured * alpha; + } +} diff --git a/apps/client/src/scenes/game/application/time/PongSampleEstimator.ts b/apps/client/src/scenes/game/application/time/PongSampleEstimator.ts new file mode 100644 index 0000000..28388e7 --- /dev/null +++ b/apps/client/src/scenes/game/application/time/PongSampleEstimator.ts @@ -0,0 +1,54 @@ +/** + * PongSampleEstimator + * PONGペイロードからRTTと時計差分を推定する + * 不正値や過大RTTサンプルを除外して品質を担保する + */ +import type { PongPayload } from "@repo/shared"; + +/** PONGサンプル推定結果の型 */ +export type PongSample = { + rttMs: number; + offsetMs: number; +}; + +/** PONGサンプル推定の設定値 */ +export type PongSampleEstimatorConfig = { + maxAcceptedRttMs: number; +}; + +/** PONGサンプル推定の既定設定 */ +export const DEFAULT_PONG_SAMPLE_ESTIMATOR_CONFIG: PongSampleEstimatorConfig = { + maxAcceptedRttMs: 1000, +}; + +/** PONGから同期サンプルを推定する */ +export class PongSampleEstimator { + private readonly config: PongSampleEstimatorConfig; + + constructor(config: Partial = {}) { + this.config = { + ...DEFAULT_PONG_SAMPLE_ESTIMATOR_CONFIG, + ...config, + }; + } + + /** PONG受信情報からRTTとoffsetを推定して返す */ + public estimate( + payload: PongPayload, + receivedAtMs: number, + ): PongSample | null { + const measuredRttMs = receivedAtMs - payload.clientTime; + if (measuredRttMs < 0 || measuredRttMs > this.config.maxAcceptedRttMs) { + return null; + } + + const estimatedOneWayMs = measuredRttMs / 2; + const measuredOffsetMs = + payload.serverTime - (payload.clientTime + estimatedOneWayMs); + + return { + rttMs: measuredRttMs, + offsetMs: measuredOffsetMs, + }; + } +} diff --git a/apps/client/src/scenes/game/application/time/SyncIntervalPolicy.ts b/apps/client/src/scenes/game/application/time/SyncIntervalPolicy.ts new file mode 100644 index 0000000..e14936d --- /dev/null +++ b/apps/client/src/scenes/game/application/time/SyncIntervalPolicy.ts @@ -0,0 +1,54 @@ +/** + * SyncIntervalPolicy + * RTTに応じて時刻同期間隔を決定する + * 通信品質に追従する送信間隔ポリシーを提供する + */ + +/** 同期間隔判定の設定値 */ +export type SyncIntervalPolicyConfig = { + defaultIntervalMs: number; + lowLatencyThresholdMs: number; + mediumLatencyThresholdMs: number; + lowLatencyIntervalMs: number; + mediumLatencyIntervalMs: number; + highLatencyIntervalMs: number; +}; + +/** 同期間隔判定の既定設定 */ +export const DEFAULT_SYNC_INTERVAL_POLICY_CONFIG: SyncIntervalPolicyConfig = { + defaultIntervalMs: 3000, + lowLatencyThresholdMs: 80, + mediumLatencyThresholdMs: 180, + lowLatencyIntervalMs: 5000, + mediumLatencyIntervalMs: 3000, + highLatencyIntervalMs: 2000, +}; + +/** RTTに応じた同期間隔を判定する */ +export class SyncIntervalPolicy { + private readonly config: SyncIntervalPolicyConfig; + + constructor(config: Partial = {}) { + this.config = { + ...DEFAULT_SYNC_INTERVAL_POLICY_CONFIG, + ...config, + }; + } + + /** RTTに応じた推奨同期間隔を返す */ + public getIntervalMs(smoothedRttMs: number | null): number { + if (smoothedRttMs === null) { + return this.config.defaultIntervalMs; + } + + if (smoothedRttMs <= this.config.lowLatencyThresholdMs) { + return this.config.lowLatencyIntervalMs; + } + + if (smoothedRttMs <= this.config.mediumLatencyThresholdMs) { + return this.config.mediumLatencyIntervalMs; + } + + return this.config.highLatencyIntervalMs; + } +}