diff --git a/apps/client/src/config/index.ts b/apps/client/src/config/index.ts index 514fee5..8cc2c84 100644 --- a/apps/client/src/config/index.ts +++ b/apps/client/src/config/index.ts @@ -8,13 +8,17 @@ TIMER_DISPLAY_UPDATE_MS: 250, JOIN_REQUEST_TIMEOUT_MS: 8000, + FRAME_DELTA_MAX_MS: 50, + PLAYER_LERP_SMOOTHNESS: 18, PLAYER_LERP_SNAP_THRESHOLD: 0.005, - PLAYER_HIT_BLINK_MS: sharedConfig.GAME_CONFIG.PLAYER_HIT_STUN_MS, - PLAYER_HIT_BLINK_INTERVAL_MS: 100, - PLAYER_HIT_BLINK_HIDDEN_ALPHA: 0.2, - PLAYER_HIT_BLINK_MAX_DELTA_MS: 50, - PLAYER_HIT_EFFECT_DEDUP_WINDOW_MS: 300, + PLAYER_HIT_EFFECT: { + BLINK_DURATION_MS: sharedConfig.GAME_CONFIG.PLAYER_HIT_STUN_MS, + BLINK_INTERVAL_MS: 100, + BLINK_HIDDEN_ALPHA: 0.2, + BLINK_MAX_DELTA_MS: 50, + DEDUP_WINDOW_MS: 300, + }, GRID_CELL_SIZE: 100, diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 4ba00ab..fd96be4 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -120,8 +120,8 @@ }); this.playerHitEffectOrchestrator = new PlayerHitEffectOrchestrator({ players: this.players, - blinkDurationMs: config.GAME_CONFIG.PLAYER_HIT_BLINK_MS, - dedupWindowMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT_DEDUP_WINDOW_MS, + blinkDurationMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_DURATION_MS, + dedupWindowMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.DEDUP_WINDOW_MS, }); } diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts index 723411a..8973ac4 100644 --- a/apps/client/src/scenes/game/application/GameLoop.ts +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -4,6 +4,7 @@ * 各 Step を呼び出して更新順序を統制する */ import { Application, Container, Ticker } from "pixi.js"; +import { config } from "@client/config"; import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { BombManager } from "@client/scenes/game/entities/bomb/BombManager"; import type { GamePlayers } from "./game.types"; @@ -11,6 +12,7 @@ import { SimulationStep } from "./loopSteps/SimulationStep"; import { CameraStep } from "./loopSteps/CameraStep"; import { BombStep } from "./loopSteps/BombStep"; +import { resolveFrameDelta } from "./loopSteps/frameDelta"; type GameLoopOptions = { app: Application; @@ -47,7 +49,10 @@ const me = this.players[this.myId]; if (!me || !(me instanceof LocalPlayerController)) return; - const deltaSeconds = ticker.deltaMS / 1000; + const { deltaSeconds } = resolveFrameDelta( + ticker, + config.GAME_CONFIG.FRAME_DELTA_MAX_MS, + ); const { isMoving } = this.inputStep.run({ me, deltaSeconds }); this.simulationStep.run({ diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 08d62ef..fcda1fe 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -41,73 +41,17 @@ unbind: () => void; }; -type SubscriptionHandlers = { - handleCurrentPlayers: (serverPlayers: CurrentPlayersPayload) => void; - handleNewPlayer: (player: NewPlayerPayload) => void; - handleGameStart: (data: GameStartPayload) => void; - handlePlayerUpdates: (players: UpdatePlayersPayload) => void; - handleRemovePlayer: (id: RemovePlayerPayload) => void; - handleUpdateMapCells: (updates: UpdateMapCellsPayload) => void; - handleGameEnd: () => void; - handleBombPlaced: (payload: BombPlacedPayload) => void; - handleBombPlacedAck: (payload: BombPlacedAckPayload) => void; - handlePlayerDead: (payload: PlayerDeadPayload) => void; -}; - -const createSocketSubscriptions = ({ - handleCurrentPlayers, - handleNewPlayer, - handleGameStart, - handlePlayerUpdates, - handleRemovePlayer, - handleUpdateMapCells, - handleGameEnd, - handleBombPlaced, - handleBombPlacedAck, - handlePlayerDead, -}: SubscriptionHandlers): SocketSubscription[] => { - return [ - { - bind: () => socketManager.game.onCurrentPlayers(handleCurrentPlayers), - unbind: () => socketManager.game.offCurrentPlayers(handleCurrentPlayers), - }, - { - bind: () => socketManager.game.onNewPlayer(handleNewPlayer), - unbind: () => socketManager.game.offNewPlayer(handleNewPlayer), - }, - { - bind: () => socketManager.game.onGameStart(handleGameStart), - unbind: () => socketManager.game.offGameStart(handleGameStart), - }, - { - bind: () => socketManager.game.onUpdatePlayers(handlePlayerUpdates), - unbind: () => socketManager.game.offUpdatePlayers(handlePlayerUpdates), - }, - { - bind: () => socketManager.game.onRemovePlayer(handleRemovePlayer), - unbind: () => socketManager.game.offRemovePlayer(handleRemovePlayer), - }, - { - bind: () => socketManager.game.onUpdateMapCells(handleUpdateMapCells), - unbind: () => socketManager.game.offUpdateMapCells(handleUpdateMapCells), - }, - { - bind: () => socketManager.game.onGameEnd(handleGameEnd), - unbind: () => socketManager.game.offGameEnd(handleGameEnd), - }, - { - bind: () => socketManager.game.onBombPlaced(handleBombPlaced), - unbind: () => socketManager.game.offBombPlaced(handleBombPlaced), - }, - { - bind: () => socketManager.game.onBombPlacedAck(handleBombPlacedAck), - unbind: () => socketManager.game.offBombPlacedAck(handleBombPlacedAck), - }, - { - bind: () => socketManager.game.onPlayerDead(handlePlayerDead), - unbind: () => socketManager.game.offPlayerDead(handlePlayerDead), - }, - ]; +type SocketSubscriptionDictionary = { + currentPlayers: SocketSubscription; + newPlayer: SocketSubscription; + gameStart: SocketSubscription; + updatePlayers: SocketSubscription; + removePlayer: SocketSubscription; + updateMapCells: SocketSubscription; + gameEnd: SocketSubscription; + bombPlaced: SocketSubscription; + bombPlacedAck: SocketSubscription; + playerDead: SocketSubscription; }; /** ゲーム中のネットワークイベント購読と同期処理を管理する */ @@ -122,8 +66,54 @@ private onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; private onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; private onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; - private socketSubscriptions: SocketSubscription[]; + private socketSubscriptions: SocketSubscriptionDictionary; private isBound = false; + /** ソケット購読の bind/unbind を辞書形式で構築する */ + private createSocketSubscriptions(): SocketSubscriptionDictionary { + return { + currentPlayers: { + bind: () => socketManager.game.onCurrentPlayers(this.handleCurrentPlayers), + unbind: () => socketManager.game.offCurrentPlayers(this.handleCurrentPlayers), + }, + newPlayer: { + bind: () => socketManager.game.onNewPlayer(this.handleNewPlayer), + unbind: () => socketManager.game.offNewPlayer(this.handleNewPlayer), + }, + gameStart: { + bind: () => socketManager.game.onGameStart(this.handleGameStart), + unbind: () => socketManager.game.offGameStart(this.handleGameStart), + }, + updatePlayers: { + bind: () => socketManager.game.onUpdatePlayers(this.handlePlayerUpdates), + unbind: () => socketManager.game.offUpdatePlayers(this.handlePlayerUpdates), + }, + removePlayer: { + bind: () => socketManager.game.onRemovePlayer(this.handleRemovePlayer), + unbind: () => socketManager.game.offRemovePlayer(this.handleRemovePlayer), + }, + updateMapCells: { + bind: () => socketManager.game.onUpdateMapCells(this.handleUpdateMapCells), + unbind: () => socketManager.game.offUpdateMapCells(this.handleUpdateMapCells), + }, + gameEnd: { + bind: () => socketManager.game.onGameEnd(this.handleGameEnd), + unbind: () => socketManager.game.offGameEnd(this.handleGameEnd), + }, + bombPlaced: { + bind: () => socketManager.game.onBombPlaced(this.handleBombPlaced), + unbind: () => socketManager.game.offBombPlaced(this.handleBombPlaced), + }, + bombPlacedAck: { + bind: () => socketManager.game.onBombPlacedAck(this.handleBombPlacedAck), + unbind: () => socketManager.game.offBombPlacedAck(this.handleBombPlacedAck), + }, + playerDead: { + bind: () => socketManager.game.onPlayerDead(this.handlePlayerDead), + unbind: () => socketManager.game.offPlayerDead(this.handlePlayerDead), + }, + }; + } + private debugLog = (message: string) => { if (!ENABLE_DEBUG_LOG) { @@ -217,24 +207,13 @@ this.onBombPlacedFromOthers = onBombPlacedFromOthers; this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.socketSubscriptions = createSocketSubscriptions({ - handleCurrentPlayers: this.handleCurrentPlayers, - handleNewPlayer: this.handleNewPlayer, - handleGameStart: this.handleGameStart, - handlePlayerUpdates: this.handlePlayerUpdates, - handleRemovePlayer: this.handleRemovePlayer, - handleUpdateMapCells: this.handleUpdateMapCells, - handleGameEnd: this.handleGameEnd, - handleBombPlaced: this.handleBombPlaced, - handleBombPlacedAck: this.handleBombPlacedAck, - handlePlayerDead: this.handlePlayerDead, - }); + this.socketSubscriptions = this.createSocketSubscriptions(); } public bind() { if (this.isBound) return; - this.socketSubscriptions.forEach((subscription) => { + Object.values(this.socketSubscriptions).forEach((subscription) => { subscription.bind(); }); @@ -244,7 +223,7 @@ public unbind() { if (!this.isBound) return; - this.socketSubscriptions.forEach((subscription) => { + Object.values(this.socketSubscriptions).forEach((subscription) => { subscription.unbind(); }); diff --git a/apps/client/src/scenes/game/application/PlayerHitEffectOrchestrator.ts b/apps/client/src/scenes/game/application/PlayerHitEffectOrchestrator.ts index aeeb2e3..8ffe11c 100644 --- a/apps/client/src/scenes/game/application/PlayerHitEffectOrchestrator.ts +++ b/apps/client/src/scenes/game/application/PlayerHitEffectOrchestrator.ts @@ -4,6 +4,10 @@ * ローカル被弾とネットワーク通知被弾を同じ窓口で扱う */ import type { GamePlayers } from "./game.types"; +import type { + PlayerHitEffectEventName, + PlayerHitEffectEventPayloadMap, +} from "@repo/shared"; type PlayerHitEffectOrchestratorOptions = { players: GamePlayers; @@ -12,14 +16,10 @@ nowMsProvider?: () => number; }; -/** 被弾演出イベント名を表す型 */ -type PlayerHitEffectEventName = "local-bomb-hit" | "network-player-dead"; - /** 被弾演出イベント入力を表す型 */ -type PlayerHitEffectEvent = { - name: PlayerHitEffectEventName; - playerId: string; - localPlayerId: string; +type PlayerHitEffectEvent = { + name: TEventName; + payload: PlayerHitEffectEventPayloadMap[TEventName]; }; /** 被弾演出の発火責務を管理するオーケストレーター */ @@ -46,8 +46,10 @@ public handleLocalBombHit(localPlayerId: string): void { this.dispatch({ name: "local-bomb-hit", - playerId: localPlayerId, - localPlayerId, + payload: { + playerId: localPlayerId, + localPlayerId, + }, }); } @@ -55,22 +57,27 @@ public handleNetworkPlayerDead(playerId: string, localPlayerId: string): void { this.dispatch({ name: "network-player-dead", - playerId, - localPlayerId, + payload: { + playerId, + localPlayerId, + }, }); } /** 被弾演出イベント名に応じて処理を分岐する */ public dispatch(event: PlayerHitEffectEvent): void { - if (event.name === "network-player-dead" && event.playerId === event.localPlayerId) { + if ( + event.name === "network-player-dead" + && event.payload.playerId === event.payload.localPlayerId + ) { return; } - if (!this.shouldTrigger(event.playerId)) { + if (!this.shouldTrigger(event.payload.playerId)) { return; } - this.playBombHitBlink(event.playerId); + this.playBombHitBlink(event.payload.playerId); } /** 指定プレイヤーへ被弾点滅演出を適用する */ diff --git a/apps/client/src/scenes/game/application/loopSteps/frameDelta.ts b/apps/client/src/scenes/game/application/loopSteps/frameDelta.ts new file mode 100644 index 0000000..a9db61c --- /dev/null +++ b/apps/client/src/scenes/game/application/loopSteps/frameDelta.ts @@ -0,0 +1,21 @@ +/** + * frameDelta + * フレーム更新で利用する時間差分を正規化する + * clamp を適用して処理系全体の時間進行を安定化する + */ +import type { Ticker } from "pixi.js"; + +/** フレーム更新で利用する時間差分を表す型 */ +export type FrameDelta = { + deltaMs: number; + deltaSeconds: number; +}; + +/** Ticker から clamp 済みのフレーム時間差分を生成する */ +export const resolveFrameDelta = (ticker: Ticker, maxDeltaMs: number): FrameDelta => { + const boundedDeltaMs = Math.min(Math.max(ticker.deltaMS, 0), maxDeltaMs); + return { + deltaMs: boundedDeltaMs, + deltaSeconds: boundedDeltaMs / 1000, + }; +}; diff --git a/apps/client/src/scenes/game/entities/bomb/BombIdRegistry.ts b/apps/client/src/scenes/game/entities/bomb/BombIdRegistry.ts new file mode 100644 index 0000000..5006495 --- /dev/null +++ b/apps/client/src/scenes/game/entities/bomb/BombIdRegistry.ts @@ -0,0 +1,57 @@ +/** + * BombIdRegistry + * 爆弾の requestId と tempBombId の採番と対応を管理する + * ACK 反映時の ID 解決と後始末を一元管理する + */ +import { PendingBombRequestStore } from "./PendingBombRequestStore"; + +/** 爆弾の仮ID発行結果を表す型 */ +export type IssuedPendingBombId = { + requestId: string; + tempBombId: string; +}; + +/** 爆弾ID関連の採番と対応管理を担うレジストリ */ +export class BombIdRegistry { + private pendingStore = new PendingBombRequestStore(); + private requestSerial = 0; + + /** 新しい requestId と tempBombId を発行して登録する */ + public issuePendingOwnBombId(): IssuedPendingBombId { + const requestId = this.createRequestId(); + const tempBombId = this.createTempBombId(requestId); + this.pendingStore.register(requestId, tempBombId); + return { requestId, tempBombId }; + } + + /** requestId から tempBombId を解決する */ + public resolveTempBombIdByRequestId(requestId: string): string | undefined { + return this.pendingStore.getTempBombIdByRequestId(requestId); + } + + /** requestId 起点で pending 対応を削除する */ + public removeByRequestId(requestId: string): void { + this.pendingStore.removeByRequestId(requestId); + } + + /** bombId 起点で pending 対応を削除する */ + public removeByBombId(bombId: string): void { + this.pendingStore.removeByTempBombId(bombId); + } + + /** すべての pending 対応を削除する */ + public clear(): void { + this.pendingStore.clear(); + } + + /** requestId を採番して文字列化する */ + private createRequestId(): string { + this.requestSerial += 1; + return `${this.requestSerial}`; + } + + /** requestId から tempBombId を構築する */ + private createTempBombId(requestId: string): string { + return `temp:${requestId}`; + } +} diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index a9121bd..1f4b9cb 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -14,7 +14,7 @@ import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; import { BombController } from "./BombController"; -import { PendingBombRequestStore } from "./PendingBombRequestStore"; +import { BombIdRegistry } from "./BombIdRegistry"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; /** 経過時間ミリ秒を返す関数型 */ @@ -63,9 +63,8 @@ private appearanceResolver: AppearanceResolver; private bombs = new Map(); private bombRenderPayloadById = new Map(); - private pendingBombRequestStore = new PendingBombRequestStore(); + private bombIdRegistry = new BombIdRegistry(); private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; - private requestSerial = 0; private onBombExploded?: (payload: BombExplodedPayload) => void; constructor({ worldContainer, players, myId, getElapsedMs, appearanceResolver, onBombExploded }: BombManagerOptions) { @@ -89,18 +88,16 @@ } const position = me.getPosition(); - const requestId = this.createRequestId(elapsedMs); + const { requestId, tempBombId } = this.bombIdRegistry.issuePendingOwnBombId(); const payload: PlaceBombPayload = { requestId, x: position.x, y: position.y, explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, }; - const tempBombId = this.createTempBombId(requestId); // 自分の爆弾は設置時点で teamId を確定して保持する const ownTeamId = this.resolveTeamIdBySocketId(this.myId); - this.pendingBombRequestStore.register(requestId, tempBombId); this.upsertBomb(tempBombId, this.toRenderPayload(payload, ownTeamId)); this.lastBombPlacedElapsedMs = elapsedMs; return { @@ -118,13 +115,13 @@ /** 設置者本人向けACKを反映し,仮IDから正式IDへ置換する */ public applyPlacedBombAck(payload: BombPlacedAckPayload): void { - const tempBombId = this.pendingBombRequestStore.getTempBombIdByRequestId(payload.requestId); + const tempBombId = this.bombIdRegistry.resolveTempBombIdByRequestId(payload.requestId); if (!tempBombId) { return; } const tempPayload = this.bombRenderPayloadById.get(tempBombId); - this.pendingBombRequestStore.removeByRequestId(payload.requestId); + this.bombIdRegistry.removeByRequestId(payload.requestId); if (!tempPayload || tempBombId === payload.bombId) { return; } @@ -161,7 +158,7 @@ bomb.destroy(); this.bombs.delete(bombId); this.bombRenderPayloadById.delete(bombId); - this.pendingBombRequestStore.removeByTempBombId(bombId); + this.bombIdRegistry.removeByBombId(bombId); } /** 爆弾状態を更新し終了済みを破棄する */ @@ -195,7 +192,7 @@ this.bombs.forEach((bomb) => bomb.destroy()); this.bombs.clear(); this.bombRenderPayloadById.clear(); - this.pendingBombRequestStore.clear(); + this.bombIdRegistry.clear(); } private isSameRenderPayload(a: BombRenderPayload, b: BombRenderPayload): boolean { @@ -231,12 +228,4 @@ return playerController.getSnapshot().teamId; } - private createRequestId(_elapsedMs: number): string { - this.requestSerial += 1; - return `${this.requestSerial}`; - } - - private createTempBombId(requestId: string): string { - return `temp:${requestId}`; - } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/player/PlayerController.ts b/apps/client/src/scenes/game/entities/player/PlayerController.ts index 1a03876..3543d25 100644 --- a/apps/client/src/scenes/game/entities/player/PlayerController.ts +++ b/apps/client/src/scenes/game/entities/player/PlayerController.ts @@ -42,9 +42,9 @@ ); this.bombHitBlinkRenderer = new BombHitBlinkRenderer({ target: this.view.displayObject, - blinkIntervalMs: config.GAME_CONFIG.PLAYER_HIT_BLINK_INTERVAL_MS, - hiddenAlpha: config.GAME_CONFIG.PLAYER_HIT_BLINK_HIDDEN_ALPHA, - maxDeltaMs: config.GAME_CONFIG.PLAYER_HIT_BLINK_MAX_DELTA_MS, + blinkIntervalMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_INTERVAL_MS, + hiddenAlpha: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_HIDDEN_ALPHA, + maxDeltaMs: config.GAME_CONFIG.PLAYER_HIT_EFFECT.BLINK_MAX_DELTA_MS, }); const pos = this.model.getPosition(); diff --git a/packages/shared/src/protocol/eventPayloads.ts b/packages/shared/src/protocol/eventPayloads.ts index 471bbf7..e444c18 100644 --- a/packages/shared/src/protocol/eventPayloads.ts +++ b/packages/shared/src/protocol/eventPayloads.ts @@ -34,3 +34,12 @@ GameResultPayload, GameResultRanking, } from "./payloads/gamePayloads"; + +/** 被弾演出イベントのペイロード型を再公開する */ +export type { + LocalBombHitEffectPayload, + NetworkPlayerDeadEffectPayload, + PlayerHitEffectEventPayloadMap, + PlayerHitEffectEventName, + PlayerHitEffectPayloadOf, +} from "./payloads/playerHitEffectPayloads"; diff --git a/packages/shared/src/protocol/payloads/playerHitEffectPayloads.ts b/packages/shared/src/protocol/payloads/playerHitEffectPayloads.ts new file mode 100644 index 0000000..728add1 --- /dev/null +++ b/packages/shared/src/protocol/payloads/playerHitEffectPayloads.ts @@ -0,0 +1,30 @@ +/** + * playerHitEffectPayloads + * プレイヤー被弾演出で利用するイベント型を定義する + * クライアントとサーバーで共有する語彙を提供する + */ + +/** ローカル被弾演出イベントのペイロード型 */ +export type LocalBombHitEffectPayload = { + playerId: string; + localPlayerId: string; +}; + +/** ネットワーク通知由来の被弾演出イベントのペイロード型 */ +export type NetworkPlayerDeadEffectPayload = { + playerId: string; + localPlayerId: string; +}; + +/** 被弾演出イベント名ごとのペイロード対応表 */ +export type PlayerHitEffectEventPayloadMap = { + "local-bomb-hit": LocalBombHitEffectPayload; + "network-player-dead": NetworkPlayerDeadEffectPayload; +}; + +/** 被弾演出イベント名を表す型 */ +export type PlayerHitEffectEventName = keyof PlayerHitEffectEventPayloadMap; + +/** 被弾演出イベント名からペイロード型を取得するユーティリティ型 */ +export type PlayerHitEffectPayloadOf = + PlayerHitEffectEventPayloadMap[TEvent];