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..fa34c80 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -7,18 +7,18 @@ import type { BombPlacedAckPayload, BombPlacedPayload, - CurrentPlayersPayload, GameStartPayload, - NewPlayerPayload, PlayerDeadPayload, - RemovePlayerPayload, - 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 { PlayerSyncHandler } from "./network/handlers/PlayerSyncHandler"; +import { MapSyncHandler } from "./network/handlers/MapSyncHandler"; +import { CombatSyncHandler } from "./network/handlers/CombatSyncHandler"; import type { GamePlayers } from "./game.types"; const ENABLE_DEBUG_LOG = import.meta.env.DEV; @@ -36,84 +36,15 @@ 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; - private players: GamePlayers; - private myId: string; - private gameMap: GameMapController; - private appearanceResolver: AppearanceResolver; + private playerSyncHandler: PlayerSyncHandler; + private mapSyncHandler: MapSyncHandler; + private combatSyncHandler: CombatSyncHandler; private onGameStart: (startTime: number) => void; private onGameEnd: () => void; - private onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; - private onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; - 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) { @@ -123,22 +54,6 @@ console.log(message); }; - private handleCurrentPlayers = (serverPlayers: CurrentPlayersPayload) => { - serverPlayers.forEach((p) => { - const playerController = p.id === this.myId - ? new LocalPlayerController(p, this.appearanceResolver) - : new RemotePlayerController(p, this.appearanceResolver); - this.worldContainer.addChild(playerController.getDisplayObject()); - this.players[p.id] = playerController; - }); - }; - - private handleNewPlayer = (p: NewPlayerPayload) => { - const playerController = new RemotePlayerController(p, this.appearanceResolver); - this.worldContainer.addChild(playerController.getDisplayObject()); - this.players[p.id] = playerController; - }; - private handleGameStart = (data: GameStartPayload) => { if (data && data.startTime) { this.onGameStart(data.startTime); @@ -146,45 +61,10 @@ } }; - private handlePlayerUpdates = (changedPlayers: UpdatePlayersPayload) => { - // UPDATE_PLAYERS は差分のみ届くため,対象IDだけ上書き更新する - changedPlayers.forEach((playerData) => { - const target = this.players[playerData.id]; - if (target && target instanceof RemotePlayerController) { - target.applyRemoteUpdate({ x: playerData.x, y: playerData.y }); - } - }); - }; - - private handleRemovePlayer = (id: RemovePlayerPayload) => { - const target = this.players[id]; - if (target) { - this.worldContainer.removeChild(target.getDisplayObject()); - target.destroy(); - delete this.players[id]; - } - }; - - private handleUpdateMapCells = (updates: UpdateMapCellsPayload) => { - this.gameMap.updateCells(updates); - }; - private handleGameEnd = () => { this.onGameEnd(); }; - private handleBombPlaced = (payload: BombPlacedPayload) => { - this.onBombPlacedFromOthers(payload); - }; - - private handleBombPlacedAck = (payload: BombPlacedAckPayload) => { - this.onBombPlacedAckFromNetwork(payload); - }; - - private handlePlayerDead = (payload: PlayerDeadPayload) => { - this.onPlayerDeadFromNetwork(payload); - }; - constructor({ worldContainer, players, @@ -197,17 +77,34 @@ onBombPlacedAckFromNetwork, onPlayerDeadFromNetwork, }: GameNetworkSyncOptions) { - this.worldContainer = worldContainer; - this.players = players; - this.myId = myId; - this.gameMap = gameMap; - this.appearanceResolver = appearanceResolver; + this.playerSyncHandler = new PlayerSyncHandler({ + worldContainer, + players, + myId, + appearanceResolver, + }); + this.mapSyncHandler = new MapSyncHandler({ + gameMap, + }); + this.combatSyncHandler = new CombatSyncHandler({ + onBombPlacedFromOthers, + onBombPlacedAckFromNetwork, + onPlayerDeadFromNetwork, + }); this.onGameStart = onGameStart; this.onGameEnd = onGameEnd; - this.onBombPlacedFromOthers = onBombPlacedFromOthers; - this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; - this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; - this.socketSubscriptions = this.createSocketSubscriptions(); + this.socketSubscriptions = createNetworkSubscriptions({ + onCurrentPlayers: this.playerSyncHandler.handleCurrentPlayers, + onNewPlayer: this.playerSyncHandler.handleNewPlayer, + onGameStart: this.handleGameStart, + onUpdatePlayers: this.playerSyncHandler.handlePlayerUpdates, + onRemovePlayer: this.playerSyncHandler.handleRemovePlayer, + onUpdateMapCells: this.mapSyncHandler.handleUpdateMapCells, + onGameEnd: this.handleGameEnd, + onBombPlaced: this.combatSyncHandler.handleBombPlaced, + onBombPlacedAck: this.combatSyncHandler.handleBombPlacedAck, + onPlayerDead: this.combatSyncHandler.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 diff --git a/apps/client/src/scenes/game/application/network/handlers/CombatSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/CombatSyncHandler.ts new file mode 100644 index 0000000..03e58fe --- /dev/null +++ b/apps/client/src/scenes/game/application/network/handlers/CombatSyncHandler.ts @@ -0,0 +1,49 @@ +/** + * CombatSyncHandler + * 戦闘関連イベントの受信処理を担当する + * 爆弾設置と被弾通知を上位へ橋渡しする + */ +import type { + BombPlacedAckPayload, + BombPlacedPayload, + PlayerDeadPayload, +} from "@repo/shared"; + +/** CombatSyncHandler の初期化入力 */ +export type CombatSyncHandlerOptions = { + onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; + onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; + onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; +}; + +/** 戦闘関連イベントの橋渡しを担当する */ +export class CombatSyncHandler { + private readonly onBombPlacedFromOthers: (payload: BombPlacedPayload) => void; + private readonly onBombPlacedAckFromNetwork: (payload: BombPlacedAckPayload) => void; + private readonly onPlayerDeadFromNetwork: (payload: PlayerDeadPayload) => void; + + constructor({ + onBombPlacedFromOthers, + onBombPlacedAckFromNetwork, + onPlayerDeadFromNetwork, + }: CombatSyncHandlerOptions) { + this.onBombPlacedFromOthers = onBombPlacedFromOthers; + this.onBombPlacedAckFromNetwork = onBombPlacedAckFromNetwork; + this.onPlayerDeadFromNetwork = onPlayerDeadFromNetwork; + } + + /** 他プレイヤーの爆弾設置イベントを橋渡しする */ + public handleBombPlaced = (payload: BombPlacedPayload): void => { + this.onBombPlacedFromOthers(payload); + }; + + /** 設置ACKイベントを橋渡しする */ + public handleBombPlacedAck = (payload: BombPlacedAckPayload): void => { + this.onBombPlacedAckFromNetwork(payload); + }; + + /** 被弾通知イベントを橋渡しする */ + public handlePlayerDead = (payload: PlayerDeadPayload): void => { + this.onPlayerDeadFromNetwork(payload); + }; +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts new file mode 100644 index 0000000..2e5f871 --- /dev/null +++ b/apps/client/src/scenes/game/application/network/handlers/MapSyncHandler.ts @@ -0,0 +1,26 @@ +/** + * MapSyncHandler + * マップ同期イベントの受信処理を担当する + * セル更新データをマップへ適用する + */ +import type { UpdateMapCellsPayload } from "@repo/shared"; +import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; + +/** MapSyncHandler の初期化入力 */ +export type MapSyncHandlerOptions = { + gameMap: GameMapController; +}; + +/** マップ更新イベントの適用を担当する */ +export class MapSyncHandler { + private readonly gameMap: GameMapController; + + constructor({ gameMap }: MapSyncHandlerOptions) { + this.gameMap = gameMap; + } + + /** マップセル更新を適用する */ + public handleUpdateMapCells = (updates: UpdateMapCellsPayload): void => { + this.gameMap.updateCells(updates); + }; +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts new file mode 100644 index 0000000..a1556db --- /dev/null +++ b/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts @@ -0,0 +1,81 @@ +/** + * PlayerSyncHandler + * プレイヤー同期イベントの受信処理を担当する + * 生成,更新,削除の適用を一元管理する + */ +import { Container } from "pixi.js"; +import type { + CurrentPlayersPayload, + NewPlayerPayload, + RemovePlayerPayload, + UpdatePlayersPayload, +} from "@repo/shared"; +import { + LocalPlayerController, + RemotePlayerController, +} from "@client/scenes/game/entities/player/PlayerController"; +import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; +import type { GamePlayers } from "@client/scenes/game/application/game.types"; + +/** PlayerSyncHandler の初期化入力 */ +export type PlayerSyncHandlerOptions = { + worldContainer: Container; + players: GamePlayers; + myId: string; + appearanceResolver: AppearanceResolver; +}; + +/** プレイヤー関連の同期イベント適用を担当する */ +export class PlayerSyncHandler { + private readonly worldContainer: Container; + private readonly players: GamePlayers; + private readonly myId: string; + private readonly appearanceResolver: AppearanceResolver; + + constructor({ worldContainer, players, myId, appearanceResolver }: PlayerSyncHandlerOptions) { + this.worldContainer = worldContainer; + this.players = players; + this.myId = myId; + this.appearanceResolver = appearanceResolver; + } + + /** 初期プレイヤー一覧を生成して反映する */ + public handleCurrentPlayers = (serverPlayers: CurrentPlayersPayload): void => { + serverPlayers.forEach((player) => { + const playerController = player.id === this.myId + ? new LocalPlayerController(player, this.appearanceResolver) + : new RemotePlayerController(player, this.appearanceResolver); + this.worldContainer.addChild(playerController.getDisplayObject()); + this.players[player.id] = playerController; + }); + }; + + /** 新規参加プレイヤーを生成して反映する */ + public handleNewPlayer = (payload: NewPlayerPayload): void => { + const playerController = new RemotePlayerController(payload, this.appearanceResolver); + this.worldContainer.addChild(playerController.getDisplayObject()); + this.players[payload.id] = playerController; + }; + + /** プレイヤー差分更新を反映する */ + public handlePlayerUpdates = (changedPlayers: UpdatePlayersPayload): void => { + changedPlayers.forEach((playerData) => { + const target = this.players[playerData.id]; + if (target && target instanceof RemotePlayerController) { + target.applyRemoteUpdate({ x: playerData.x, y: playerData.y }); + } + }); + }; + + /** 退出プレイヤーを削除する */ + public handleRemovePlayer = (id: RemovePlayerPayload): void => { + const target = this.players[id]; + if (!target) { + return; + } + + this.worldContainer.removeChild(target.getDisplayObject()); + target.destroy(); + delete this.players[id]; + }; +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/bomb/BombManager.ts b/apps/client/src/scenes/game/entities/bomb/BombManager.ts index 1f4b9cb..2b96c85 100644 --- a/apps/client/src/scenes/game/entities/bomb/BombManager.ts +++ b/apps/client/src/scenes/game/entities/bomb/BombManager.ts @@ -1,34 +1,27 @@ /** * BombManager - * 爆弾エンティティの生成,更新,破棄を管理する - * クールダウンと設置位置解決をまとめて扱う + * 爆弾サブシステムの各サービスを調停する + * 外部APIを維持しつつ内部責務を分離する */ import type { Container } from "pixi.js"; -import { config } from "@client/config"; import type { BombPlacedAckPayload, BombPlacedPayload, PlaceBombPayload, } from "@repo/shared"; -import { config as sharedConfig } from "@repo/shared"; -import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; -import { BombController } from "./BombController"; import { BombIdRegistry } from "./BombIdRegistry"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; +import { BombRepository, type BombRenderPayload } from "./runtime/BombRepository"; +import { BombPlacementService } from "./services/BombPlacementService"; +import { BombAckReconciler } from "./services/BombAckReconciler"; +import { BombRuntimeSystem } from "./runtime/BombRuntimeSystem"; /** 経過時間ミリ秒を返す関数型 */ export type ElapsedMsProvider = () => number; /** 爆弾の描画更新に使う入力データ型 */ -export type BombRenderPayload = { - x: number; - y: number; - explodeAtElapsedMs: number; - radiusGrid: number; - teamId: number; - color: number; -}; +export type { BombRenderPayload }; /** 爆弾設置時に返す結果型 */ export type BombPlacementResult = { @@ -56,176 +49,82 @@ /** 爆弾エンティティのライフサイクルを管理する */ export class BombManager { - private worldContainer: Container; - private players: GamePlayers; - private myId: string; - private getElapsedMs: ElapsedMsProvider; - private appearanceResolver: AppearanceResolver; - private bombs = new Map(); - private bombRenderPayloadById = new Map(); - private bombIdRegistry = new BombIdRegistry(); - private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; - private onBombExploded?: (payload: BombExplodedPayload) => void; + private readonly getElapsedMs: ElapsedMsProvider; + private readonly bombIdRegistry = new BombIdRegistry(); + private readonly bombRepository: BombRepository; + private readonly bombPlacementService: BombPlacementService; + private readonly bombAckReconciler: BombAckReconciler; + private readonly bombRuntimeSystem: BombRuntimeSystem; constructor({ worldContainer, players, myId, getElapsedMs, appearanceResolver, onBombExploded }: BombManagerOptions) { - this.worldContainer = worldContainer; - this.players = players; - this.myId = myId; this.getElapsedMs = getElapsedMs; - this.appearanceResolver = appearanceResolver; - this.onBombExploded = onBombExploded; + + this.bombRepository = new BombRepository({ + worldContainer, + bombIdRegistry: this.bombIdRegistry, + }); + this.bombPlacementService = new BombPlacementService({ + players, + myId, + getElapsedMs, + appearanceResolver, + bombIdRegistry: this.bombIdRegistry, + }); + this.bombAckReconciler = new BombAckReconciler({ + bombIdRegistry: this.bombIdRegistry, + bombRepository: this.bombRepository, + }); + this.bombRuntimeSystem = new BombRuntimeSystem({ + bombRepository: this.bombRepository, + onBombExploded, + }); } /** 自プレイヤー位置に爆弾を仮IDで設置し,設置要求を返す */ public placeBomb(): BombPlacementResult | null { - const me = this.players[this.myId]; - if (!me || !(me instanceof LocalPlayerController)) return null; - - const elapsedMs = this.getElapsedMs(); - const { BOMB_COOLDOWN_MS, BOMB_FUSE_MS } = config.GAME_CONFIG; - if (elapsedMs - this.lastBombPlacedElapsedMs < BOMB_COOLDOWN_MS) { + const ownPlacement = this.bombPlacementService.placeOwnBomb(); + if (!ownPlacement) { return null; } - const position = me.getPosition(); - const { requestId, tempBombId } = this.bombIdRegistry.issuePendingOwnBombId(); - const payload: PlaceBombPayload = { - requestId, - x: position.x, - y: position.y, - explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, - }; - // 自分の爆弾は設置時点で teamId を確定して保持する - const ownTeamId = this.resolveTeamIdBySocketId(this.myId); - - this.upsertBomb(tempBombId, this.toRenderPayload(payload, ownTeamId)); - this.lastBombPlacedElapsedMs = elapsedMs; + this.upsertBomb(ownPlacement.tempBombId, ownPlacement.renderPayload); return { - tempBombId, - payload, + tempBombId: ownPlacement.tempBombId, + payload: ownPlacement.payload, }; } /** 他プレイヤー向けの爆弾確定イベントを反映する */ public applyPlacedBombFromOthers(payload: BombPlacedPayload): void { - // 通信では ownerSocketId を受け取り,受信時点で teamId を確定する - const ownerTeamId = this.resolveTeamIdBySocketId(payload.ownerSocketId); - this.upsertBomb(payload.bombId, this.toRenderPayload(payload, ownerTeamId)); + this.upsertBomb( + payload.bombId, + this.bombPlacementService.createRenderPayload(payload, payload.ownerSocketId), + ); } /** 設置者本人向けACKを反映し,仮IDから正式IDへ置換する */ public applyPlacedBombAck(payload: BombPlacedAckPayload): void { - const tempBombId = this.bombIdRegistry.resolveTempBombIdByRequestId(payload.requestId); - if (!tempBombId) { - return; - } - - const tempPayload = this.bombRenderPayloadById.get(tempBombId); - this.bombIdRegistry.removeByRequestId(payload.requestId); - if (!tempPayload || tempBombId === payload.bombId) { - return; - } - - this.removeBomb(tempBombId); - this.upsertBomb(payload.bombId, tempPayload); + this.bombAckReconciler.applyPlacedBombAck(payload); } /** 描画ペイロードで指定IDの爆弾を追加または更新する */ public upsertBomb(bombId: string, payload: BombRenderPayload): void { - const previousPayload = this.bombRenderPayloadById.get(bombId); - if (previousPayload && this.isSameRenderPayload(previousPayload, payload)) { - return; - } - - const current = this.bombs.get(bombId); - if (current) { - this.worldContainer.removeChild(current.getDisplayObject()); - current.destroy(); - } - - const bomb = new BombController(payload); - this.bombs.set(bombId, bomb); - this.bombRenderPayloadById.set(bombId, payload); - this.worldContainer.addChild(bomb.getDisplayObject()); + this.bombRepository.upsertBomb(bombId, payload); } /** 指定IDの爆弾を削除する */ public removeBomb(bombId: string): void { - const bomb = this.bombs.get(bombId); - if (!bomb) return; - - this.worldContainer.removeChild(bomb.getDisplayObject()); - bomb.destroy(); - this.bombs.delete(bombId); - this.bombRenderPayloadById.delete(bombId); - this.bombIdRegistry.removeByBombId(bombId); + this.bombRepository.removeBomb(bombId); } /** 爆弾状態を更新し終了済みを破棄する */ public tick(): void { - const elapsedMs = this.getElapsedMs(); - - this.bombs.forEach((bomb, bombId) => { - const previousState = bomb.getState(); - bomb.tick(elapsedMs); - - if (previousState !== "exploded" && bomb.getState() === "exploded") { - const position = bomb.getPosition(); - this.onBombExploded?.({ - bombId, - x: position.x, - y: position.y, - radius: bomb.getExplosionRadiusGrid(), - teamId: bomb.getTeamId(), - }); - } - - if (bomb.isFinished()) { - this.removeBomb(bombId); - return; - } - }); + this.bombRuntimeSystem.tick(this.getElapsedMs()); } /** 管理中の爆弾をすべて破棄する */ public destroy(): void { - this.bombs.forEach((bomb) => bomb.destroy()); - this.bombs.clear(); - this.bombRenderPayloadById.clear(); + this.bombRepository.clear(); this.bombIdRegistry.clear(); } - - private isSameRenderPayload(a: BombRenderPayload, b: BombRenderPayload): boolean { - return a.x === b.x - && a.y === b.y - && a.explodeAtElapsedMs === b.explodeAtElapsedMs - && a.radiusGrid === b.radiusGrid - && a.teamId === b.teamId - && a.color === b.color; - } - - private toRenderPayload( - payload: { x: number; y: number; explodeAtElapsedMs: number }, - teamId: number - ): BombRenderPayload { - return { - x: payload.x, - y: payload.y, - explodeAtElapsedMs: payload.explodeAtElapsedMs, - radiusGrid: config.GAME_CONFIG.BOMB_RADIUS_GRID, - teamId, - color: this.appearanceResolver.resolveTeamColor(teamId), - }; - } - - private resolveTeamIdBySocketId(socketId: string): number { - const playerController = this.players[socketId]; - if (!playerController) { - // 参照できない場合でも描画継続できるように未知チームで扱う - return sharedConfig.UNKNOWN_TEAM_ID; - } - - return playerController.getSnapshot().teamId; - } - } \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/bomb/runtime/BombRepository.ts b/apps/client/src/scenes/game/entities/bomb/runtime/BombRepository.ts new file mode 100644 index 0000000..a3f557c --- /dev/null +++ b/apps/client/src/scenes/game/entities/bomb/runtime/BombRepository.ts @@ -0,0 +1,98 @@ +/** + * BombRepository + * 爆弾エンティティの保持と描画反映を管理する + * 追加,更新,削除,破棄の基本操作を提供する + */ +import type { Container } from "pixi.js"; +import { BombController } from "@client/scenes/game/entities/bomb/BombController"; +import { BombIdRegistry } from "@client/scenes/game/entities/bomb/BombIdRegistry"; + +/** 爆弾の描画更新に使う入力データ型 */ +export type BombRenderPayload = { + x: number; + y: number; + explodeAtElapsedMs: number; + radiusGrid: number; + teamId: number; + color: number; +}; + +/** BombRepository の初期化入力 */ +export type BombRepositoryOptions = { + worldContainer: Container; + bombIdRegistry: BombIdRegistry; +}; + +/** 爆弾エンティティと描画情報の保持を管理する */ +export class BombRepository { + private readonly worldContainer: Container; + private readonly bombIdRegistry: BombIdRegistry; + private readonly bombs = new Map(); + private readonly bombRenderPayloadById = new Map(); + + constructor({ worldContainer, bombIdRegistry }: BombRepositoryOptions) { + this.worldContainer = worldContainer; + this.bombIdRegistry = bombIdRegistry; + } + + /** 描画ペイロードで指定IDの爆弾を追加または更新する */ + public upsertBomb(bombId: string, payload: BombRenderPayload): void { + const previousPayload = this.bombRenderPayloadById.get(bombId); + if (previousPayload && this.isSameRenderPayload(previousPayload, payload)) { + return; + } + + const current = this.bombs.get(bombId); + if (current) { + this.worldContainer.removeChild(current.getDisplayObject()); + current.destroy(); + } + + const bomb = new BombController(payload); + this.bombs.set(bombId, bomb); + this.bombRenderPayloadById.set(bombId, payload); + this.worldContainer.addChild(bomb.getDisplayObject()); + } + + /** 指定IDの爆弾を削除する */ + public removeBomb(bombId: string): void { + const bomb = this.bombs.get(bombId); + if (!bomb) { + return; + } + + this.worldContainer.removeChild(bomb.getDisplayObject()); + bomb.destroy(); + this.bombs.delete(bombId); + this.bombRenderPayloadById.delete(bombId); + this.bombIdRegistry.removeByBombId(bombId); + } + + /** 指定IDの描画ペイロードを返す */ + public getRenderPayload(bombId: string): BombRenderPayload | undefined { + return this.bombRenderPayloadById.get(bombId); + } + + /** 管理中の爆弾を列挙する */ + public forEachBomb(callback: (bomb: BombController, bombId: string) => void): void { + this.bombs.forEach((bomb, bombId) => { + callback(bomb, bombId); + }); + } + + /** 管理中の爆弾をすべて破棄する */ + public clear(): void { + this.bombs.forEach((bomb) => bomb.destroy()); + this.bombs.clear(); + this.bombRenderPayloadById.clear(); + } + + private isSameRenderPayload(a: BombRenderPayload, b: BombRenderPayload): boolean { + return a.x === b.x + && a.y === b.y + && a.explodeAtElapsedMs === b.explodeAtElapsedMs + && a.radiusGrid === b.radiusGrid + && a.teamId === b.teamId + && a.color === b.color; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/bomb/runtime/BombRuntimeSystem.ts b/apps/client/src/scenes/game/entities/bomb/runtime/BombRuntimeSystem.ts new file mode 100644 index 0000000..82d2eef --- /dev/null +++ b/apps/client/src/scenes/game/entities/bomb/runtime/BombRuntimeSystem.ts @@ -0,0 +1,55 @@ +/** + * BombRuntimeSystem + * 爆弾状態の時間更新と終了判定を処理する + * 爆発遷移時に外部通知イベントを発火する + */ +import { BombRepository } from "./BombRepository"; + +/** 爆発時に外部通知するイベント型 */ +export type BombExplodedEvent = { + bombId: string; + x: number; + y: number; + radius: number; + teamId: number; +}; + +/** BombRuntimeSystem の初期化入力 */ +export type BombRuntimeSystemOptions = { + bombRepository: BombRepository; + onBombExploded?: (payload: BombExplodedEvent) => void; +}; + +/** 爆弾の時間更新と状態遷移を管理する */ +export class BombRuntimeSystem { + private readonly bombRepository: BombRepository; + private readonly onBombExploded?: (payload: BombExplodedEvent) => void; + + constructor({ bombRepository, onBombExploded }: BombRuntimeSystemOptions) { + this.bombRepository = bombRepository; + this.onBombExploded = onBombExploded; + } + + /** 経過時間を用いて爆弾状態を更新する */ + public tick(elapsedMs: number): void { + this.bombRepository.forEachBomb((bomb, bombId) => { + const previousState = bomb.getState(); + bomb.tick(elapsedMs); + + if (previousState !== "exploded" && bomb.getState() === "exploded") { + const position = bomb.getPosition(); + this.onBombExploded?.({ + bombId, + x: position.x, + y: position.y, + radius: bomb.getExplosionRadiusGrid(), + teamId: bomb.getTeamId(), + }); + } + + if (bomb.isFinished()) { + this.bombRepository.removeBomb(bombId); + } + }); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/bomb/services/BombAckReconciler.ts b/apps/client/src/scenes/game/entities/bomb/services/BombAckReconciler.ts new file mode 100644 index 0000000..3b84e6b --- /dev/null +++ b/apps/client/src/scenes/game/entities/bomb/services/BombAckReconciler.ts @@ -0,0 +1,42 @@ +/** + * BombAckReconciler + * 爆弾設置ACKを仮IDから正式IDへ反映する + * requestId と tempBombId の対応解決を担う + */ +import type { BombPlacedAckPayload } from "@repo/shared"; +import { BombIdRegistry } from "@client/scenes/game/entities/bomb/BombIdRegistry"; +import { BombRepository } from "@client/scenes/game/entities/bomb/runtime/BombRepository"; + +/** BombAckReconciler の初期化入力 */ +export type BombAckReconcilerOptions = { + bombIdRegistry: BombIdRegistry; + bombRepository: BombRepository; +}; + +/** 爆弾設置ACKの反映処理を担う */ +export class BombAckReconciler { + private readonly bombIdRegistry: BombIdRegistry; + private readonly bombRepository: BombRepository; + + constructor({ bombIdRegistry, bombRepository }: BombAckReconcilerOptions) { + this.bombIdRegistry = bombIdRegistry; + this.bombRepository = bombRepository; + } + + /** 設置者本人向けACKを反映し,仮IDから正式IDへ置換する */ + public applyPlacedBombAck(payload: BombPlacedAckPayload): void { + const tempBombId = this.bombIdRegistry.resolveTempBombIdByRequestId(payload.requestId); + if (!tempBombId) { + return; + } + + const tempPayload = this.bombRepository.getRenderPayload(tempBombId); + this.bombIdRegistry.removeByRequestId(payload.requestId); + if (!tempPayload || tempBombId === payload.bombId) { + return; + } + + this.bombRepository.removeBomb(tempBombId); + this.bombRepository.upsertBomb(payload.bombId, tempPayload); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts b/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts new file mode 100644 index 0000000..616d416 --- /dev/null +++ b/apps/client/src/scenes/game/entities/bomb/services/BombPlacementService.ts @@ -0,0 +1,110 @@ +/** + * BombPlacementService + * 爆弾設置要求の生成と描画ペイロード化を担う + * クールダウン判定とチーム情報解決を提供する + */ +import { config } from "@client/config"; +import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; +import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; +import type { GamePlayers } from "@client/scenes/game/application/game.types"; +import type { PlaceBombPayload } from "@repo/shared"; +import { config as sharedConfig } from "@repo/shared"; +import { BombIdRegistry } from "@client/scenes/game/entities/bomb/BombIdRegistry"; +import type { BombRenderPayload } from "@client/scenes/game/entities/bomb/runtime/BombRepository"; + +/** BombPlacementService の初期化入力 */ +export type BombPlacementServiceOptions = { + players: GamePlayers; + myId: string; + getElapsedMs: () => number; + appearanceResolver: AppearanceResolver; + bombIdRegistry: BombIdRegistry; +}; + +/** ローカル設置時の生成結果型 */ +export type OwnBombPlacement = { + tempBombId: string; + payload: PlaceBombPayload; + renderPayload: BombRenderPayload; +}; + +/** 爆弾設置要求の生成と描画情報変換を担う */ +export class BombPlacementService { + private readonly players: GamePlayers; + private readonly myId: string; + private readonly getElapsedMs: () => number; + private readonly appearanceResolver: AppearanceResolver; + private readonly bombIdRegistry: BombIdRegistry; + private lastBombPlacedElapsedMs = Number.NEGATIVE_INFINITY; + + constructor({ players, myId, getElapsedMs, appearanceResolver, bombIdRegistry }: BombPlacementServiceOptions) { + this.players = players; + this.myId = myId; + this.getElapsedMs = getElapsedMs; + this.appearanceResolver = appearanceResolver; + this.bombIdRegistry = bombIdRegistry; + } + + /** 自プレイヤー位置に爆弾を仮IDで設置し,設置要求を返す */ + public placeOwnBomb(): OwnBombPlacement | null { + const me = this.players[this.myId]; + if (!me || !(me instanceof LocalPlayerController)) { + return null; + } + + const elapsedMs = this.getElapsedMs(); + const { BOMB_COOLDOWN_MS, BOMB_FUSE_MS } = config.GAME_CONFIG; + if (elapsedMs - this.lastBombPlacedElapsedMs < BOMB_COOLDOWN_MS) { + return null; + } + + const position = me.getPosition(); + const { requestId, tempBombId } = this.bombIdRegistry.issuePendingOwnBombId(); + const payload: PlaceBombPayload = { + requestId, + x: position.x, + y: position.y, + explodeAtElapsedMs: elapsedMs + BOMB_FUSE_MS, + }; + const ownTeamId = this.resolveTeamIdBySocketId(this.myId); + + this.lastBombPlacedElapsedMs = elapsedMs; + return { + tempBombId, + payload, + renderPayload: this.toRenderPayload(payload, ownTeamId), + }; + } + + /** 指定オーナー情報から描画ペイロードを生成する */ + public createRenderPayload( + payload: { x: number; y: number; explodeAtElapsedMs: number }, + ownerSocketId: string, + ): BombRenderPayload { + const ownerTeamId = this.resolveTeamIdBySocketId(ownerSocketId); + return this.toRenderPayload(payload, ownerTeamId); + } + + private toRenderPayload( + payload: { x: number; y: number; explodeAtElapsedMs: number }, + teamId: number, + ): BombRenderPayload { + return { + x: payload.x, + y: payload.y, + explodeAtElapsedMs: payload.explodeAtElapsedMs, + radiusGrid: config.GAME_CONFIG.BOMB_RADIUS_GRID, + teamId, + color: this.appearanceResolver.resolveTeamColor(teamId), + }; + } + + private resolveTeamIdBySocketId(socketId: string): number { + const playerController = this.players[socketId]; + if (!playerController) { + return sharedConfig.UNKNOWN_TEAM_ID; + } + + return playerController.getSnapshot().teamId; + } +} \ No newline at end of file