diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index 3d0bd9c..93ac68e 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -19,6 +19,7 @@ NewPlayerPayload, PlaceBombPayload, PlayerHitPayload, + PongPayload, RemovePlayerPayload, UpdateHurricanesPayload, UpdateMapCellsPayload, @@ -67,9 +68,12 @@ offPlayerHit: (callback: (payload: PlayerHitPayload) => void) => void; onHurricaneHit: (callback: (payload: HurricaneHitPayload) => void) => void; offHurricaneHit: (callback: (payload: HurricaneHitPayload) => void) => void; + onPong: (callback: (payload: PongPayload) => void) => void; + offPong: (callback: (payload: PongPayload) => void) => void; sendMove: (x: number, y: number) => void; sendPlaceBomb: (payload: PlaceBombPayload) => void; sendBombHitReport: (payload: BombHitReportPayload) => void; + sendPing: (clientTime: number) => void; readyForGame: () => void; }; @@ -145,6 +149,7 @@ const hurricaneHitSubscription = createSubscriptionPair( protocol.SocketEvents.HURRICANE_HIT, ); + const pongSubscription = createSubscriptionPair(protocol.SocketEvents.PONG); const sendMovePayload = createPayloadSender(protocol.SocketEvents.MOVE); const sendPlaceBombPayload = createPayloadSender( protocol.SocketEvents.PLACE_BOMB, @@ -152,6 +157,7 @@ const sendBombHitReportPayload = createPayloadSender( protocol.SocketEvents.BOMB_HIT_REPORT, ); + const sendPingPayload = createPayloadSender(protocol.SocketEvents.PING); const sendReadyForGame = createVoidSender( protocol.SocketEvents.READY_FOR_GAME, ); @@ -238,6 +244,12 @@ offHurricaneHit: (callback) => { hurricaneHitSubscription.off(callback); }, + onPong: (callback) => { + pongSubscription.on(callback); + }, + offPong: (callback) => { + pongSubscription.off(callback); + }, sendMove: (x, y) => { const payload: MovePayload = { x, y }; sendMovePayload(payload); @@ -248,6 +260,9 @@ sendBombHitReport: (payload) => { sendBombHitReportPayload(payload); }, + sendPing: (clientTime) => { + sendPingPayload(clientTime); + }, readyForGame: () => { sendReadyForGame(); }, diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index d9d2f1e..97a932c 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -30,6 +30,7 @@ type GameUiState, } from "./application/ui/GameUiStateSyncService"; import { preloadGameStartAssets } from "./application/assets/GameAssetPreloader"; +import { ClockSyncService } from "./application/time/ClockSyncService"; /** GameManager の依存注入オプション型 */ export type GameManagerDependencies = { @@ -55,12 +56,15 @@ 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 clockSyncTimeoutId: ReturnType | null = null; private localBombHitCount = 0; public getStartCountdownSec(): number { @@ -98,10 +102,15 @@ ) { this.container = container; // 明示的に代入 this.myId = myId; - this.sessionFacade = dependencies.sessionFacade ?? new GameSessionFacade(); + this.clockSyncService = new ClockSyncService(); + 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 +131,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 +144,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 +192,12 @@ lifecycleState: this.lifecycleState, app: this.app, }); + this.disposableRegistry.add(() => { + this.stopClockSyncLoop(); + }); + this.disposableRegistry.add(() => { + this.clockSyncService.reset(); + }); } /** @@ -196,6 +217,7 @@ return; } + this.startClockSyncLoop(); this.uiStateSyncService.startTicker(); this.uiStateSyncService.emitIfChanged(true); } @@ -260,4 +282,28 @@ this.lifecycleState.markDestroyed(); this.disposableRegistry.disposeAll(); } + + /** RTT状況に応じて可変間隔でPING送信を継続する */ + private startClockSyncLoop(): void { + this.stopClockSyncLoop(); + + const executeSync = () => { + this.gameActionSender.sendPing(Date.now()); + + const nextIntervalMs = this.clockSyncService.getRecommendedSyncIntervalMs(); + this.clockSyncTimeoutId = setTimeout(executeSync, nextIntervalMs); + }; + + executeSync(); + } + + /** 進行中のPING送信ループを停止する */ + private stopClockSyncLoop(): void { + if (this.clockSyncTimeoutId === null) { + return; + } + + clearTimeout(this.clockSyncTimeoutId); + this.clockSyncTimeoutId = null; + } } 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/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..5d02df0 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.game.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..c89f5a5 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.game.onPong(handlers.onPong), + unbind: () => socketManager.game.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/GameNetworkStateApplier.ts b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts index 4deec45..69a74d6 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"; @@ -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; }; @@ -51,6 +54,8 @@ private readonly hurricaneSyncHandler: HurricaneSyncHandler; private readonly onGameStarted: (startTime: number) => void; private readonly onGameEnded: () => void; + private readonly onPongReceived: (payload: PongPayload) => void; + private readonly onGameStartClockHint: (serverNowMs: number) => void; private readonly onDebugLog: (message: string) => void; private readonly receivedEventHandlers: ReceivedGameEventHandlers; @@ -66,6 +71,8 @@ onBombPlacementAcknowledged, onRemotePlayerHit, onRemoteHurricaneHit, + onPongReceived, + onGameStartClockHint, onDebugLog, }: GameNetworkStateApplierOptions) { this.playerSyncHandler = new PlayerSyncHandler({ @@ -84,6 +91,8 @@ }); this.onGameStarted = onGameStarted; this.onGameEnded = onGameEnded; + this.onPongReceived = onPongReceived; + this.onGameStartClockHint = onGameStartClockHint; this.onDebugLog = onDebugLog ?? (() => undefined); this.receivedEventHandlers = this.createReceivedEventHandlers(); } @@ -139,6 +148,8 @@ this.playerSyncHandler.handleNewPlayer(payload); }, onReceivedGameStart: (payload) => { + this.onGameStartClockHint(payload.serverNow); + const startTime = toGameStartedAt(payload); if (startTime === null) { return; @@ -177,6 +188,9 @@ onReceivedHurricaneHit: (payload) => { this.combatSyncHandler.handleReceivedHurricaneHit(payload); }, + onReceivedPong: (payload) => { + this.onPongReceived(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/ClockSyncService.ts b/apps/client/src/scenes/game/application/time/ClockSyncService.ts new file mode 100644 index 0000000..48daf13 --- /dev/null +++ b/apps/client/src/scenes/game/application/time/ClockSyncService.ts @@ -0,0 +1,118 @@ +/** + * ClockSyncService + * サーバー時刻との差分を平滑化して管理する + * PING/PONGのRTTを使い,外れ値を除外して同期精度を安定化する + */ +import type { PongPayload } from "@repo/shared"; + +/** 時刻同期に利用する更新パラメータ */ +export type ClockSyncConfig = { + offsetAlpha: number; + rttAlpha: number; + maxAcceptedRttMs: number; + maxAcceptedOffsetJumpMs: number; +}; + +/** 時刻同期の既定パラメータ */ +export const DEFAULT_CLOCK_SYNC_CONFIG: ClockSyncConfig = { + offsetAlpha: 0.12, + rttAlpha: 0.25, + maxAcceptedRttMs: 1000, + maxAcceptedOffsetJumpMs: 250, +}; + +/** サーバー時刻との差分を平滑化して保持する */ +export class ClockSyncService { + private readonly config: ClockSyncConfig; + private smoothedOffsetMs: number | null = null; + private smoothedRttMs: number | null = null; + + constructor(config: Partial = {}) { + this.config = { + ...DEFAULT_CLOCK_SYNC_CONFIG, + ...config, + }; + } + + /** 受信した serverNow をもとに差分を初期化する */ + public seedFromServerNow(serverNowMs: number, receivedAtMs = Date.now()): void { + this.smoothedOffsetMs = serverNowMs - receivedAtMs; + } + + /** PONGサンプルを取り込み,差分とRTTを更新する */ + public updateFromPong(payload: PongPayload, receivedAtMs = Date.now()): void { + const measuredRttMs = receivedAtMs - payload.clientTime; + if (measuredRttMs < 0 || measuredRttMs > this.config.maxAcceptedRttMs) { + return; + } + + const estimatedOneWayMs = measuredRttMs / 2; + const measuredOffsetMs = + payload.serverTime - (payload.clientTime + estimatedOneWayMs); + + this.smoothedRttMs = this.smoothValue( + this.smoothedRttMs, + measuredRttMs, + this.config.rttAlpha, + ); + + if ( + this.smoothedOffsetMs !== null && + Math.abs(measuredOffsetMs - this.smoothedOffsetMs) > + this.config.maxAcceptedOffsetJumpMs + ) { + return; + } + + this.smoothedOffsetMs = this.smoothValue( + this.smoothedOffsetMs, + measuredOffsetMs, + this.config.offsetAlpha, + ); + } + + /** 平滑化済みの時刻差分ミリ秒を返す */ + public getClockOffsetMs(): number { + return this.smoothedOffsetMs ?? 0; + } + + /** サーバー時刻基準へ補正した現在時刻ミリ秒を返す */ + public getSynchronizedNowMs(): number { + return Date.now() + this.getClockOffsetMs(); + } + + /** RTT状況に応じた次回同期推奨間隔ミリ秒を返す */ + public getRecommendedSyncIntervalMs(): number { + if (this.smoothedRttMs === null) { + return 3000; + } + + if (this.smoothedRttMs <= 80) { + return 5000; + } + + if (this.smoothedRttMs <= 180) { + return 3000; + } + + return 2000; + } + + /** 内部状態を初期化する */ + 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; + } +}