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/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts index 93ac68e..3d0bd9c 100644 --- a/apps/client/src/network/handlers/GameHandler.ts +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -19,7 +19,6 @@ NewPlayerPayload, PlaceBombPayload, PlayerHitPayload, - PongPayload, RemovePlayerPayload, UpdateHurricanesPayload, UpdateMapCellsPayload, @@ -68,12 +67,9 @@ 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; }; @@ -149,7 +145,6 @@ 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, @@ -157,7 +152,6 @@ const sendBombHitReportPayload = createPayloadSender( protocol.SocketEvents.BOMB_HIT_REPORT, ); - const sendPingPayload = createPayloadSender(protocol.SocketEvents.PING); const sendReadyForGame = createVoidSender( protocol.SocketEvents.READY_FOR_GAME, ); @@ -244,12 +238,6 @@ 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); @@ -260,9 +248,6 @@ sendBombHitReport: (payload) => { sendBombHitReportPayload(payload); }, - sendPing: (clientTime) => { - sendPingPayload(clientTime); - }, readyForGame: () => { sendReadyForGame(); }, 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 97a932c..387c17c 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -31,6 +31,8 @@ } 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 = { @@ -64,7 +66,8 @@ private uiStateSyncService: GameUiStateSyncService; private disposableRegistry: DisposableRegistry; private readonly clockSyncService: ClockSyncService; - private clockSyncTimeoutId: ReturnType | null = null; + private readonly nowMsProvider = SYSTEM_TIME_PROVIDER.now; + private readonly clockSyncLoop: ClockSyncLoop; private localBombHitCount = 0; public getStartCountdownSec(): number { @@ -103,6 +106,13 @@ this.container = container; // 明示的に代入 this.myId = myId; 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({ @@ -193,7 +203,7 @@ app: this.app, }); this.disposableRegistry.add(() => { - this.stopClockSyncLoop(); + this.clockSyncLoop.dispose(); }); this.disposableRegistry.add(() => { this.clockSyncService.reset(); @@ -217,7 +227,7 @@ return; } - this.startClockSyncLoop(); + this.clockSyncLoop.start(); this.uiStateSyncService.startTicker(); this.uiStateSyncService.emitIfChanged(true); } @@ -283,27 +293,4 @@ 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/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/network/GameActionSender.ts b/apps/client/src/scenes/game/application/network/GameActionSender.ts index 5d02df0..4a8cafe 100644 --- a/apps/client/src/scenes/game/application/network/GameActionSender.ts +++ b/apps/client/src/scenes/game/application/network/GameActionSender.ts @@ -33,6 +33,6 @@ /** 時刻同期PINGをサーバーへ送信する */ public sendPing(clientTime: number): void { - socketManager.game.sendPing(clientTime); + 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 c89f5a5..5334686 100644 --- a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts +++ b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts @@ -160,8 +160,8 @@ { key: "pong", create: (handlers) => ({ - bind: () => socketManager.game.onPong(handlers.onPong), - unbind: () => socketManager.game.offPong(handlers.onPong), + bind: () => socketManager.gameSync.onPong(handlers.onPong), + unbind: () => socketManager.gameSync.offPong(handlers.onPong), }), }, ]; 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 69a74d6..908e72c 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -17,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"; @@ -51,12 +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 onPongReceived: (payload: PongPayload) => void; - private readonly onGameStartClockHint: (serverNowMs: number) => void; - private readonly onDebugLog: (message: string) => void; private readonly receivedEventHandlers: ReceivedGameEventHandlers; constructor({ @@ -89,11 +86,13 @@ onRemotePlayerHit, onRemoteHurricaneHit, }); - this.onGameStarted = onGameStarted; + this.clockSyncEventApplier = new ClockSyncEventApplier({ + onGameStarted, + onGameStartClockHint, + onPongReceived, + onDebugLog, + }); this.onGameEnded = onGameEnded; - this.onPongReceived = onPongReceived; - this.onGameStartClockHint = onGameStartClockHint; - this.onDebugLog = onDebugLog ?? (() => undefined); this.receivedEventHandlers = this.createReceivedEventHandlers(); } @@ -148,17 +147,7 @@ this.playerSyncHandler.handleNewPlayer(payload); }, onReceivedGameStart: (payload) => { - this.onGameStartClockHint(payload.serverNow); - - 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); @@ -189,7 +178,7 @@ this.combatSyncHandler.handleReceivedHurricaneHit(payload); }, onReceivedPong: (payload) => { - this.onPongReceived(payload); + this.clockSyncEventApplier.applyPong(payload); }, }; } 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 index 48daf13..bfe181f 100644 --- a/apps/client/src/scenes/game/application/time/ClockSyncService.ts +++ b/apps/client/src/scenes/game/application/time/ClockSyncService.ts @@ -4,115 +4,130 @@ * 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 = { - offsetAlpha: number; - rttAlpha: number; - maxAcceptedRttMs: number; - maxAcceptedOffsetJumpMs: number; + estimator: PongSampleEstimatorConfig; + smoother: OffsetSmootherConfig; + intervalPolicy: SyncIntervalPolicyConfig; }; /** 時刻同期の既定パラメータ */ export const DEFAULT_CLOCK_SYNC_CONFIG: ClockSyncConfig = { - offsetAlpha: 0.12, - rttAlpha: 0.25, - maxAcceptedRttMs: 1000, - maxAcceptedOffsetJumpMs: 250, + 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 config: ClockSyncConfig; - private smoothedOffsetMs: number | null = null; - private smoothedRttMs: number | null = null; + private readonly nowProvider: TimeProvider["now"]; + private readonly estimator: PongSampleEstimator; + private readonly smoother: OffsetSmoother; + private readonly intervalPolicy: SyncIntervalPolicy; - constructor(config: Partial = {}) { - this.config = { - ...DEFAULT_CLOCK_SYNC_CONFIG, - ...config, + 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 = Date.now()): void { - this.smoothedOffsetMs = serverNowMs - receivedAtMs; + public seedFromServerNow( + serverNowMs: number, + receivedAtMs = this.nowProvider(), + ): void { + this.smoother.seed(serverNowMs, receivedAtMs); } /** PONGサンプルを取り込み,差分とRTTを更新する */ - public updateFromPong(payload: PongPayload, receivedAtMs = Date.now()): void { - const measuredRttMs = receivedAtMs - payload.clientTime; - if (measuredRttMs < 0 || measuredRttMs > this.config.maxAcceptedRttMs) { + public updateFromPong( + payload: PongPayload, + receivedAtMs = this.nowProvider(), + ): void { + const sample = this.estimator.estimate(payload, receivedAtMs); + if (!sample) { 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, - ); + this.smoother.applySample(sample); } /** 平滑化済みの時刻差分ミリ秒を返す */ public getClockOffsetMs(): number { - return this.smoothedOffsetMs ?? 0; + return this.smoother.getClockOffsetMs(); } /** サーバー時刻基準へ補正した現在時刻ミリ秒を返す */ public getSynchronizedNowMs(): number { - return Date.now() + this.getClockOffsetMs(); + return this.nowProvider() + 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; + return this.intervalPolicy.getIntervalMs(this.smoother.getSmoothedRttMs()); } /** 内部状態を初期化する */ 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; + 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; + } +}