diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index fd96be4..fe3d470 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -20,6 +20,8 @@ import { BombHitOrchestrator } from "./application/BombHitOrchestrator"; import { PlayerDeathPolicy } from "./application/PlayerDeathPolicy"; import { PlayerHitEffectOrchestrator } from "./application/PlayerHitEffectOrchestrator"; +import { InputGate } from "./application/lifecycle/InputGate"; +import { HitReportPolicy } from "./application/combat/HitReportPolicy"; import type { BombHitEvaluationResult } from "./application/BombHitOrchestrator"; import type { GamePlayers } from "./application/game.types"; @@ -39,7 +41,8 @@ private gameLoop: GameLoop | null = null; private playerDeathPolicy!: PlayerDeathPolicy; private playerHitEffectOrchestrator!: PlayerHitEffectOrchestrator; - private reportedBombHitIds = new Set(); + private inputGate: InputGate; + private hitReportPolicy = new HitReportPolicy(); // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ public setGameStart(startTime: number) { @@ -50,21 +53,17 @@ return this.timer.getPreStartRemainingSec(); } - private canAcceptInput(): boolean { - return this.inputLockCount === 0 && this.timer.isStarted(); - } - // 現在の残り秒数を取得する public getRemainingTime(): number { return this.timer.getRemainingTime(); } public isInputEnabled(): boolean { - return this.canAcceptInput(); + return this.inputGate.canAcceptInput(); } public placeBomb(): string | null { - if (!this.canAcceptInput()) return null; + if (!this.inputGate.canAcceptInput()) return null; if (!this.bombManager) return null; const placed = this.bombManager.placeBomb(); if (!placed) return null; @@ -85,21 +84,10 @@ private joystickInput = { x: 0, y: 0 }; private isInitialized = false; private isDestroyed = false; - private inputLockCount = 0; public lockInput(): () => void { - this.inputLockCount += 1; this.joystickInput = { x: 0, y: 0 }; - - let released = false; - return () => { - if (released) { - return; - } - - released = true; - this.inputLockCount = Math.max(0, this.inputLockCount - 1); - }; + return this.inputGate.lockInput(); } constructor(container: HTMLDivElement, myId: string) { @@ -108,6 +96,9 @@ this.app = new Application(); this.worldContainer = new Container(); this.worldContainer.sortableChildren = true; + this.inputGate = new InputGate({ + isStartedProvider: () => this.timer.isStarted(), + }); this.initializeHitSubsystem(); } @@ -161,11 +152,7 @@ * React側からジョイスティックの入力を受け取る */ public setJoystickInput(x: number, y: number) { - if (!this.canAcceptInput()) { - this.joystickInput = { x: 0, y: 0 }; - return; - } - this.joystickInput = { x, y }; + this.joystickInput = this.inputGate.sanitizeJoystickInput({ x, y }); } /** @@ -251,7 +238,7 @@ result: BombHitEvaluationResult | undefined, bombId: string, ): void { - if (!this.shouldSendBombHitReport(result, bombId)) { + if (!this.hitReportPolicy.shouldSendReport(result, bombId)) { return; } @@ -261,22 +248,6 @@ socketManager.game.sendBombHitReport({ bombId }); } - private shouldSendBombHitReport( - result: BombHitEvaluationResult | undefined, - bombId: string, - ): boolean { - if (result !== "hit") { - return false; - } - - if (this.reportedBombHitIds.has(bombId)) { - return false; - } - - this.reportedBombHitIds.add(bombId); - return true; - } - /** * クリーンアップ処理(コンポーネントアンマウント時) */ @@ -290,9 +261,9 @@ this.bombHitOrchestrator?.clear(); this.bombHitOrchestrator = null; this.playerDeathPolicy.dispose(); - this.reportedBombHitIds.clear(); + this.hitReportPolicy.clear(); + this.inputGate.reset(); this.players = {}; - this.inputLockCount = 0; this.joystickInput = { x: 0, y: 0 }; // イベント購読の解除 diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index fcda1fe..129ddf9 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -15,10 +15,13 @@ UpdateMapCellsPayload, UpdatePlayersPayload, } from "@repo/shared"; -import { socketManager } from "@client/network/SocketManager"; import { AppearanceResolver } from "./AppearanceResolver"; import { LocalPlayerController, RemotePlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; +import { + createNetworkSubscriptions, + type SocketSubscriptionDictionary, +} from "./network/NetworkSubscriptions"; import type { GamePlayers } from "./game.types"; const ENABLE_DEBUG_LOG = import.meta.env.DEV; @@ -36,24 +39,6 @@ onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; }; -type SocketSubscription = { - bind: () => void; - unbind: () => void; -}; - -type SocketSubscriptionDictionary = { - currentPlayers: SocketSubscription; - newPlayer: SocketSubscription; - gameStart: SocketSubscription; - updatePlayers: SocketSubscription; - removePlayer: SocketSubscription; - updateMapCells: SocketSubscription; - gameEnd: SocketSubscription; - bombPlaced: SocketSubscription; - bombPlacedAck: SocketSubscription; - playerDead: SocketSubscription; -}; - /** ゲーム中のネットワークイベント購読と同期処理を管理する */ export class GameNetworkSync { private worldContainer: Container; @@ -68,52 +53,6 @@ private onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; 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) { @@ -207,7 +146,18 @@ this.onBombPlacedFromOthers = onBombPlacedFromOthers; this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.socketSubscriptions = this.createSocketSubscriptions(); + this.socketSubscriptions = createNetworkSubscriptions({ + onCurrentPlayers: this.handleCurrentPlayers, + onNewPlayer: this.handleNewPlayer, + onGameStart: this.handleGameStart, + onUpdatePlayers: this.handlePlayerUpdates, + onRemovePlayer: this.handleRemovePlayer, + onUpdateMapCells: this.handleUpdateMapCells, + onGameEnd: this.handleGameEnd, + onBombPlaced: this.handleBombPlaced, + onBombPlacedAck: this.handleBombPlacedAck, + onPlayerDead: this.handlePlayerDead, + }); } public bind() { diff --git a/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts b/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts new file mode 100644 index 0000000..bbd607c --- /dev/null +++ b/apps/client/src/scenes/game/application/combat/HitReportPolicy.ts @@ -0,0 +1,32 @@ +/** + * HitReportPolicy + * 爆弾被弾報告の送信可否を判定するポリシー + * 同一爆弾IDの重複送信を抑止する + */ + +/** 被弾判定結果の最小表現型 */ +export type HitEvaluationResult = "duplicate" | "missing-local-player" | "no-hit" | "hit"; + +/** 爆弾被弾報告の送信可否を管理する */ +export class HitReportPolicy { + private readonly reportedBombHitIds = new Set(); + + /** 被弾報告を送信すべき場合に true を返す */ + public shouldSendReport(result: HitEvaluationResult | undefined, bombId: string): boolean { + if (result !== "hit") { + return false; + } + + if (this.reportedBombHitIds.has(bombId)) { + return false; + } + + this.reportedBombHitIds.add(bombId); + return true; + } + + /** 判定済みIDをすべて破棄する */ + public clear(): void { + this.reportedBombHitIds.clear(); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/lifecycle/InputGate.ts b/apps/client/src/scenes/game/application/lifecycle/InputGate.ts new file mode 100644 index 0000000..360e56e --- /dev/null +++ b/apps/client/src/scenes/game/application/lifecycle/InputGate.ts @@ -0,0 +1,60 @@ +/** + * InputGate + * ゲーム入力の受付可否と入力ロック状態を管理する + * 開始状態とロック状態を組み合わせて入力可否を判定する + */ + +/** ジョイスティック入力の座標を表す型 */ +export type JoystickInput = { + x: number; + y: number; +}; + +/** InputGate の初期化入力 */ +export type InputGateOptions = { + isStartedProvider: () => boolean; +}; + +/** ゲーム入力の受付可否とロック状態を管理する */ +export class InputGate { + private readonly isStartedProvider: () => boolean; + private inputLockCount = 0; + + constructor({ isStartedProvider }: InputGateOptions) { + this.isStartedProvider = isStartedProvider; + } + + /** 現在入力を受け付け可能かを返す */ + public canAcceptInput(): boolean { + return this.inputLockCount === 0 && this.isStartedProvider(); + } + + /** 入力ロックを取得し,解除関数を返す */ + public lockInput(): () => void { + this.inputLockCount += 1; + + let released = false; + return () => { + if (released) { + return; + } + + released = true; + this.inputLockCount = Math.max(0, this.inputLockCount - 1); + }; + } + + /** 入力可否に応じてジョイスティック入力を正規化して返す */ + public sanitizeJoystickInput(input: JoystickInput): JoystickInput { + if (!this.canAcceptInput()) { + return { x: 0, y: 0 }; + } + + return input; + } + + /** 管理状態を初期化する */ + public reset(): void { + this.inputLockCount = 0; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts new file mode 100644 index 0000000..658a16c --- /dev/null +++ b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts @@ -0,0 +1,99 @@ +/** + * NetworkSubscriptions + * ゲーム中のソケット購読定義を辞書形式で生成する + * bind と unbind の対応関係を一元管理する + */ +import { socketManager } from "@client/network/SocketManager"; +import type { + BombPlacedAckPayload, + BombPlacedPayload, + CurrentPlayersPayload, + GameStartPayload, + NewPlayerPayload, + PlayerDeadPayload, + RemovePlayerPayload, + UpdateMapCellsPayload, + UpdatePlayersPayload, +} from "@repo/shared"; + +/** ソケット購読の bind と unbind を保持する型 */ +export type SocketSubscription = { + bind: () => void; + unbind: () => void; +}; + +/** ゲーム中に利用する購読辞書の型 */ +export type SocketSubscriptionDictionary = { + currentPlayers: SocketSubscription; + newPlayer: SocketSubscription; + gameStart: SocketSubscription; + updatePlayers: SocketSubscription; + removePlayer: SocketSubscription; + updateMapCells: SocketSubscription; + gameEnd: SocketSubscription; + bombPlaced: SocketSubscription; + bombPlacedAck: SocketSubscription; + playerDead: SocketSubscription; +}; + +/** 購読辞書生成に必要なハンドラ群 */ +export type NetworkSubscriptionHandlers = { + onCurrentPlayers: (payload: CurrentPlayersPayload) => void; + onNewPlayer: (payload: NewPlayerPayload) => void; + onGameStart: (payload: GameStartPayload) => void; + onUpdatePlayers: (payload: UpdatePlayersPayload) => void; + onRemovePlayer: (payload: RemovePlayerPayload) => void; + onUpdateMapCells: (payload: UpdateMapCellsPayload) => void; + onGameEnd: () => void; + onBombPlaced: (payload: BombPlacedPayload) => void; + onBombPlacedAck: (payload: BombPlacedAckPayload) => void; + onPlayerDead: (payload: PlayerDeadPayload) => void; +}; + +/** ソケット購読辞書を生成する */ +export const createNetworkSubscriptions = ( + handlers: NetworkSubscriptionHandlers, +): SocketSubscriptionDictionary => { + return { + currentPlayers: { + bind: () => socketManager.game.onCurrentPlayers(handlers.onCurrentPlayers), + unbind: () => socketManager.game.offCurrentPlayers(handlers.onCurrentPlayers), + }, + newPlayer: { + bind: () => socketManager.game.onNewPlayer(handlers.onNewPlayer), + unbind: () => socketManager.game.offNewPlayer(handlers.onNewPlayer), + }, + gameStart: { + bind: () => socketManager.game.onGameStart(handlers.onGameStart), + unbind: () => socketManager.game.offGameStart(handlers.onGameStart), + }, + updatePlayers: { + bind: () => socketManager.game.onUpdatePlayers(handlers.onUpdatePlayers), + unbind: () => socketManager.game.offUpdatePlayers(handlers.onUpdatePlayers), + }, + removePlayer: { + bind: () => socketManager.game.onRemovePlayer(handlers.onRemovePlayer), + unbind: () => socketManager.game.offRemovePlayer(handlers.onRemovePlayer), + }, + updateMapCells: { + bind: () => socketManager.game.onUpdateMapCells(handlers.onUpdateMapCells), + unbind: () => socketManager.game.offUpdateMapCells(handlers.onUpdateMapCells), + }, + gameEnd: { + bind: () => socketManager.game.onGameEnd(handlers.onGameEnd), + unbind: () => socketManager.game.offGameEnd(handlers.onGameEnd), + }, + bombPlaced: { + bind: () => socketManager.game.onBombPlaced(handlers.onBombPlaced), + unbind: () => socketManager.game.offBombPlaced(handlers.onBombPlaced), + }, + bombPlacedAck: { + bind: () => socketManager.game.onBombPlacedAck(handlers.onBombPlacedAck), + unbind: () => socketManager.game.offBombPlacedAck(handlers.onBombPlacedAck), + }, + playerDead: { + bind: () => socketManager.game.onPlayerDead(handlers.onPlayerDead), + unbind: () => socketManager.game.offPlayerDead(handlers.onPlayerDead), + }, + }; +}; \ No newline at end of file