diff --git a/apps/client/src/scenes/game/ARCHITECTURE.md b/apps/client/src/scenes/game/ARCHITECTURE.md index 7bbfe21..c69cbcd 100644 --- a/apps/client/src/scenes/game/ARCHITECTURE.md +++ b/apps/client/src/scenes/game/ARCHITECTURE.md @@ -26,3 +26,13 @@ - 依存方向違反は `eslint.config.mjs` の `no-restricted-imports` で検出する - 新規ファイル追加時はまずどのレイヤーへ属するかを決めてから配置する - 依存方向をまたぐ必要がある場合は application層へ adapter を追加して橋渡しする + +## 移動完了済みマップ +- `PlayerRepository` は `application/player` から `entities/player` へ移動済み +- `GameUiPresenter` は `application/presentation` から `input/presentation` へ移動済み +- `input/joystick` は `presentation` `hooks` `model` の責務分割へ移行済み +- `input/bomb/BombButton` は `bomb/presentation` へ移行済み + +## 次回以降の移動候補 +- `application` 内の受信処理は `network/receivers` と `network/handlers` へ分離を優先する +- UI構成要素のスタイル定数は `presentation/*.styles.ts` へ集約する diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 57613df..e1e8da3 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -13,6 +13,7 @@ type GameSceneFactoryOptions, } from "./application/orchestrators/GameSceneOrchestrator"; import { GameSceneRuntime } from "./application/runtime/GameSceneRuntime"; +import { GameManagerBootstrapper } from "./application/runtime/GameManagerBootstrapper"; import { SocketGameActionSender, type GameActionSender, @@ -172,29 +173,19 @@ * ゲームエンジンの初期化 */ public async init() { - // PixiJS本体の初期化 - await this.app.init({ - resizeTo: window, - backgroundColor: 0x111111, - antialias: true, + const bootstrapper = new GameManagerBootstrapper({ + app: this.app, + lifecycleState: this.lifecycleState, + container: this.container, + runtime: this.runtime, + tick: this.tick, }); - // 初期化完了前に destroy() が呼ばれていたら、ここで処理を中断して破棄する - if (this.lifecycleState.shouldAbortInit()) { - this.app.destroy(true, { children: true }); + const result = await bootstrapper.bootstrap(); + if (!result.initialized) { return; } - this.container.appendChild(this.app.canvas); - - this.runtime.initialize(); - - // サーバーへゲーム準備完了を通知 - this.runtime.readyForGame(); - - // メインループの登録 - this.app.ticker.add(this.tick); - this.lifecycleState.markInitialized(); this.uiStateSyncService.startTicker(); this.uiStateSyncService.emitIfChanged(true); } diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts index 6a67d2e..4a4011d 100644 --- a/apps/client/src/scenes/game/application/GameLoop.ts +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -13,7 +13,7 @@ import { SimulationStep } from "./loopSteps/SimulationStep"; import { CameraStep } from "./loopSteps/CameraStep"; import { BombStep } from "./loopSteps/BombStep"; -import type { LoopFrameContext, LoopStep } from "./loopSteps/LoopStep"; +import type { LoopFrameContext, LoopFrameEffects, LoopStep } from "./loopSteps/LoopStep"; import { resolveFrameDelta } from "./loopSteps/frameDelta"; import type { MoveSender } from "./network/PlayerMoveSender"; @@ -66,17 +66,26 @@ ticker, config.GAME_CONFIG.FRAME_DELTA_MAX_MS, ); - const frameContext: LoopFrameContext = { - app: this.app, - worldContainer: this.worldContainer, - playerRepository: this.playerRepository, - me, - deltaSeconds, + const frameState = { isMoving: false, }; + const effects: LoopFrameEffects = { + setIsMoving: (isMoving) => { + frameState.isMoving = isMoving; + }, + }; + this.steps.forEach((step) => { - step.run(frameContext); + const frameContext: LoopFrameContext = { + app: this.app, + worldContainer: this.worldContainer, + playerRepository: this.playerRepository, + me, + deltaSeconds, + isMoving: frameState.isMoving, + }; + step.run(frameContext, effects); }); }; } diff --git a/apps/client/src/scenes/game/application/GameNetworkSync.ts b/apps/client/src/scenes/game/application/GameNetworkSync.ts index 22f35d8..1552a3e 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -7,25 +7,13 @@ import type { BombPlacedAckPayload, BombPlacedPayload, - GameStartPayload, PlayerDeadPayload, } from "@repo/shared"; import { AppearanceResolver } from "./AppearanceResolver"; import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; -import { - createNetworkSubscriptions, - type SocketSubscriptionDictionary, -} from "./network/NetworkSubscriptions"; -import { - toBombPlacementAcknowledgedPayload, - toGameStartedAt, - toRemoteBombPlacedPayload, - toRemotePlayerDeadPayload, -} from "./network/adapters/GameNetworkEventAdapter"; import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; -import { PlayerSyncHandler } from "./network/handlers/PlayerSyncHandler"; -import { MapSyncHandler } from "./network/handlers/MapSyncHandler"; -import { CombatSyncHandler } from "./network/handlers/CombatSyncHandler"; +import { GameNetworkEventReceiver } from "./network/receivers/GameNetworkEventReceiver"; +import { GameNetworkStateApplier } from "./network/handlers/GameNetworkStateApplier"; import type { GamePlayers } from "./game.types"; const ENABLE_DEBUG_LOG = import.meta.env.DEV; @@ -45,14 +33,8 @@ /** ゲーム中のネットワークイベント購読と同期処理を管理する */ export class GameNetworkSync { - private readonly playerRepository: PlayerRepository; - private playerSyncHandler: PlayerSyncHandler; - private mapSyncHandler: MapSyncHandler; - private combatSyncHandler: CombatSyncHandler; - private onGameStarted: (startTime: number) => void; - private onGameEnded: () => void; - private socketSubscriptions: SocketSubscriptionDictionary; - private isBound = false; + private readonly eventReceiver: GameNetworkEventReceiver; + private readonly stateApplier: GameNetworkStateApplier; private debugLog = (message: string) => { if (!ENABLE_DEBUG_LOG) { @@ -62,18 +44,6 @@ console.log(message); }; - private handleReceivedGameStart = (payload: GameStartPayload) => { - const startTime = toGameStartedAt(payload); - if (startTime !== null) { - this.onGameStarted(startTime); - this.debugLog(`[GameNetworkSync] ゲーム開始時刻同期完了: ${startTime}`); - } - }; - - private handleReceivedGameEnd = () => { - this.onGameEnded(); - }; - constructor({ worldContainer, players, @@ -86,60 +56,41 @@ onBombPlacementAcknowledged, onRemotePlayerDead, }: GameNetworkSyncOptions) { - this.playerRepository = new PlayerRepository(players); - this.playerSyncHandler = new PlayerSyncHandler({ + const playerRepository = new PlayerRepository(players); + + this.stateApplier = new GameNetworkStateApplier({ worldContainer, - playerRepository: this.playerRepository, + playerRepository, myId, - appearanceResolver, - }); - this.mapSyncHandler = new MapSyncHandler({ gameMap, + appearanceResolver, + onGameStarted, + onGameEnded, + onRemoteBombPlaced, + onBombPlacementAcknowledged, + onRemotePlayerDead, + onDebugLog: this.debugLog, }); - this.combatSyncHandler = new CombatSyncHandler({ - onRemoteBombPlaced: (payload) => { - onRemoteBombPlaced(toRemoteBombPlacedPayload(payload)); - }, - onBombPlacementAcknowledged: (payload) => { - onBombPlacementAcknowledged(toBombPlacementAcknowledgedPayload(payload)); - }, - onRemotePlayerDead: (payload) => { - onRemotePlayerDead(toRemotePlayerDeadPayload(payload)); - }, - }); - this.onGameStarted = onGameStarted; - this.onGameEnded = onGameEnded; - this.socketSubscriptions = createNetworkSubscriptions({ - onCurrentPlayers: this.playerSyncHandler.handleCurrentPlayers, - onNewPlayer: this.playerSyncHandler.handleNewPlayer, - onGameStart: this.handleReceivedGameStart, - onUpdatePlayers: this.playerSyncHandler.handlePlayerUpdates, - onRemovePlayer: this.playerSyncHandler.handleRemovePlayer, - onUpdateMapCells: this.mapSyncHandler.handleUpdateMapCells, - onGameEnd: this.handleReceivedGameEnd, - onBombPlaced: this.combatSyncHandler.handleReceivedBombPlaced, - onBombPlacedAck: this.combatSyncHandler.handleReceivedBombPlacedAck, - onPlayerDead: this.combatSyncHandler.handleReceivedPlayerDead, + + this.eventReceiver = new GameNetworkEventReceiver({ + onReceivedCurrentPlayers: this.stateApplier.applyReceivedCurrentPlayers.bind(this.stateApplier), + onReceivedNewPlayer: this.stateApplier.applyReceivedNewPlayer.bind(this.stateApplier), + onReceivedGameStart: this.stateApplier.applyReceivedGameStart.bind(this.stateApplier), + onReceivedUpdatePlayers: this.stateApplier.applyReceivedUpdatePlayers.bind(this.stateApplier), + onReceivedRemovePlayer: this.stateApplier.applyReceivedRemovePlayer.bind(this.stateApplier), + onReceivedUpdateMapCells: this.stateApplier.applyReceivedUpdateMapCells.bind(this.stateApplier), + onReceivedGameEnd: this.stateApplier.applyReceivedGameEnd.bind(this.stateApplier), + onReceivedBombPlaced: this.stateApplier.applyReceivedBombPlaced.bind(this.stateApplier), + onReceivedBombPlacedAck: this.stateApplier.applyReceivedBombPlacedAck.bind(this.stateApplier), + onReceivedPlayerDead: this.stateApplier.applyReceivedPlayerDead.bind(this.stateApplier), }); } public bind() { - if (this.isBound) return; - - Object.values(this.socketSubscriptions).forEach((subscription) => { - subscription.bind(); - }); - - this.isBound = true; + this.eventReceiver.bind(); } public unbind() { - if (!this.isBound) return; - - Object.values(this.socketSubscriptions).forEach((subscription) => { - subscription.unbind(); - }); - - this.isBound = false; + this.eventReceiver.unbind(); } } diff --git a/apps/client/src/scenes/game/application/loopSteps/BombStep.ts b/apps/client/src/scenes/game/application/loopSteps/BombStep.ts index 3166a0c..4df6d74 100644 --- a/apps/client/src/scenes/game/application/loopSteps/BombStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/BombStep.ts @@ -4,7 +4,7 @@ * 爆弾エンティティの時間更新と状態遷移を実行する */ import { BombManager } from "@client/scenes/game/entities/bomb/BombManager"; -import type { LoopFrameContext, LoopStep } from "./LoopStep"; +import type { LoopFrameContext, LoopFrameEffects, LoopStep } from "./LoopStep"; /** BombStep の初期化入力 */ type BombStepOptions = { @@ -20,7 +20,10 @@ } /** 爆弾更新を実行する,時間管理は GameTimer 由来の経過時刻を利用する */ - public run(_context: LoopFrameContext): void { + public run( + _context: Readonly, + _effects: LoopFrameEffects, + ): void { this.bombManager.tick(); } } diff --git a/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts index a72d16f..e0f1b4b 100644 --- a/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/CameraStep.ts @@ -5,7 +5,7 @@ */ import { Application, Container } from "pixi.js"; import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; -import type { LoopFrameContext, LoopStep } from "./LoopStep"; +import type { LoopFrameContext, LoopFrameEffects, LoopStep } from "./LoopStep"; type CameraStepParams = { app: Application; @@ -16,7 +16,10 @@ /** カメラ追従更新を担うステップ */ export class CameraStep implements LoopStep { /** ローカルプレイヤー位置へカメラを追従させる */ - public run(context: LoopFrameContext): void { + public run( + context: Readonly, + _effects: LoopFrameEffects, + ): void { const params: CameraStepParams = { app: context.app, worldContainer: context.worldContainer, diff --git a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts index 5b2ace4..f9d1458 100644 --- a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts @@ -4,7 +4,7 @@ * ジョイスティック入力をローカルプレイヤーへ適用する */ import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; -import type { LoopFrameContext, LoopStep } from "./LoopStep"; +import type { LoopFrameContext, LoopFrameEffects, LoopStep } from "./LoopStep"; type InputStepOptions = { getJoystickInput: () => { x: number; y: number }; @@ -24,14 +24,17 @@ } /** 入力文脈を適用して移動状態を更新する */ - public run(context: LoopFrameContext): void { + public run( + context: Readonly, + effects: LoopFrameEffects, + ): void { const params: InputStepParams = { me: context.me, deltaSeconds: context.deltaSeconds, }; const isMoving = this.applyInput(params); - context.isMoving = isMoving; + effects.setIsMoving(isMoving); } private applyInput({ me, deltaSeconds }: InputStepParams): boolean { diff --git a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts index ac8d393..65ffb82 100644 --- a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts @@ -17,7 +17,12 @@ isMoving: boolean; }; +/** 1フレーム内で許可する副作用操作の型 */ +export type LoopFrameEffects = { + setIsMoving: (isMoving: boolean) => void; +}; + /** ゲームループ内で実行されるステップ共通インターフェース */ export type LoopStep = { - run: (context: LoopFrameContext) => void; + run: (context: Readonly, effects: LoopFrameEffects) => void; }; \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts index e34a162..860d7e7 100644 --- a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts @@ -7,7 +7,7 @@ import { LocalPlayerController, RemotePlayerController } from "@client/scenes/game/entities/player/PlayerController"; import type { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; import type { MoveSender } from "@client/scenes/game/application/network/PlayerMoveSender"; -import type { LoopFrameContext, LoopStep } from "./LoopStep"; +import type { LoopFrameContext, LoopFrameEffects, LoopStep } from "./LoopStep"; /** SimulationStep の初期化入力 */ type SimulationStepOptions = { @@ -35,7 +35,10 @@ } /** ローカル更新とリモート補間更新を実行する */ - public run(context: LoopFrameContext): void { + public run( + context: Readonly, + _effects: LoopFrameEffects, + ): void { const params: SimulationStepParams = { me: context.me, playerRepository: context.playerRepository, diff --git a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts new file mode 100644 index 0000000..2a62a51 --- /dev/null +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -0,0 +1,146 @@ +/** + * GameNetworkStateApplier + * 受信イベントの状態反映を担当する + * ハンドラ群とadapter適用を受信層から分離する + */ +import { Container } from "pixi.js"; +import type { + BombPlacedAckPayload, + BombPlacedPayload, + CurrentPlayersPayload, + GameStartPayload, + NewPlayerPayload, + PlayerDeadPayload, + RemovePlayerPayload, + UpdateMapCellsPayload, + UpdatePlayersPayload, +} from "@repo/shared"; +import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; +import { GameMapController } from "@client/scenes/game/entities/map/GameMapController"; +import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; +import { + toBombPlacementAcknowledgedPayload, + toGameStartedAt, + toRemoteBombPlacedPayload, + toRemotePlayerDeadPayload, +} from "@client/scenes/game/application/network/adapters/GameNetworkEventAdapter"; +import { CombatSyncHandler } from "./CombatSyncHandler"; +import { MapSyncHandler } from "./MapSyncHandler"; +import { PlayerSyncHandler } from "./PlayerSyncHandler"; + +/** 状態反映処理の初期化入力 */ +export type GameNetworkStateApplierOptions = { + worldContainer: Container; + playerRepository: PlayerRepository; + myId: string; + gameMap: GameMapController; + appearanceResolver: AppearanceResolver; + onGameStarted: (startTime: number) => void; + onGameEnded: () => void; + onRemoteBombPlaced: (payload: BombPlacedPayload) => void; + onBombPlacementAcknowledged: (payload: BombPlacedAckPayload) => void; + onRemotePlayerDead: (payload: PlayerDeadPayload) => void; + onDebugLog?: (message: string) => void; +}; + +/** 受信イベントの状態反映を担当する */ +export class GameNetworkStateApplier { + private readonly playerSyncHandler: PlayerSyncHandler; + private readonly mapSyncHandler: MapSyncHandler; + private readonly combatSyncHandler: CombatSyncHandler; + private readonly onGameStarted: (startTime: number) => void; + private readonly onGameEnded: () => void; + private readonly onDebugLog: (message: string) => void; + + constructor({ + worldContainer, + playerRepository, + myId, + gameMap, + appearanceResolver, + onGameStarted, + onGameEnded, + onRemoteBombPlaced, + onBombPlacementAcknowledged, + onRemotePlayerDead, + onDebugLog, + }: GameNetworkStateApplierOptions) { + this.playerSyncHandler = new PlayerSyncHandler({ + worldContainer, + playerRepository, + myId, + appearanceResolver, + }); + this.mapSyncHandler = new MapSyncHandler({ gameMap }); + this.combatSyncHandler = new CombatSyncHandler({ + onRemoteBombPlaced: (payload) => { + onRemoteBombPlaced(toRemoteBombPlacedPayload(payload)); + }, + onBombPlacementAcknowledged: (payload) => { + onBombPlacementAcknowledged(toBombPlacementAcknowledgedPayload(payload)); + }, + onRemotePlayerDead: (payload) => { + onRemotePlayerDead(toRemotePlayerDeadPayload(payload)); + }, + }); + this.onGameStarted = onGameStarted; + this.onGameEnded = onGameEnded; + this.onDebugLog = onDebugLog ?? (() => undefined); + } + + /** 初期プレイヤー一覧の受信イベントを適用する */ + public applyReceivedCurrentPlayers(payload: CurrentPlayersPayload): void { + this.playerSyncHandler.handleCurrentPlayers(payload); + } + + /** 新規参加プレイヤー受信イベントを適用する */ + public applyReceivedNewPlayer(payload: NewPlayerPayload): void { + this.playerSyncHandler.handleNewPlayer(payload); + } + + /** ゲーム開始受信イベントを適用する */ + public applyReceivedGameStart(payload: GameStartPayload): void { + const startTime = toGameStartedAt(payload); + if (startTime === null) { + return; + } + + this.onGameStarted(startTime); + this.onDebugLog(`[GameNetworkSync] ゲーム開始時刻同期完了: ${startTime}`); + } + + /** プレイヤー更新受信イベントを適用する */ + public applyReceivedUpdatePlayers(payload: UpdatePlayersPayload): void { + this.playerSyncHandler.handlePlayerUpdates(payload); + } + + /** プレイヤー退出受信イベントを適用する */ + public applyReceivedRemovePlayer(payload: RemovePlayerPayload): void { + this.playerSyncHandler.handleRemovePlayer(payload); + } + + /** マップセル更新受信イベントを適用する */ + public applyReceivedUpdateMapCells(payload: UpdateMapCellsPayload): void { + this.mapSyncHandler.handleUpdateMapCells(payload); + } + + /** ゲーム終了受信イベントを適用する */ + public applyReceivedGameEnd(): void { + this.onGameEnded(); + } + + /** 爆弾設置受信イベントを適用する */ + public applyReceivedBombPlaced(payload: BombPlacedPayload): void { + this.combatSyncHandler.handleReceivedBombPlaced(payload); + } + + /** 爆弾設置ACK受信イベントを適用する */ + public applyReceivedBombPlacedAck(payload: BombPlacedAckPayload): void { + this.combatSyncHandler.handleReceivedBombPlacedAck(payload); + } + + /** プレイヤー死亡受信イベントを適用する */ + public applyReceivedPlayerDead(payload: PlayerDeadPayload): void { + this.combatSyncHandler.handleReceivedPlayerDead(payload); + } +} diff --git a/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts b/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts new file mode 100644 index 0000000..dcb95ed --- /dev/null +++ b/apps/client/src/scenes/game/application/network/receivers/GameNetworkEventReceiver.ts @@ -0,0 +1,81 @@ +/** + * GameNetworkEventReceiver + * 受信イベント購読と配信を担当する + * bindとunbindの管理を受信層へ分離する + */ +import type { + BombPlacedAckPayload, + BombPlacedPayload, + CurrentPlayersPayload, + GameStartPayload, + NewPlayerPayload, + PlayerDeadPayload, + RemovePlayerPayload, + UpdateMapCellsPayload, + UpdatePlayersPayload, +} from "@repo/shared"; +import { + createNetworkSubscriptions, + type SocketSubscriptionDictionary, +} from "@client/scenes/game/application/network/NetworkSubscriptions"; + +/** 受信イベント配信先のハンドラ群 */ +export type ReceivedGameEventHandlers = { + onReceivedCurrentPlayers: (payload: CurrentPlayersPayload) => void; + onReceivedNewPlayer: (payload: NewPlayerPayload) => void; + onReceivedGameStart: (payload: GameStartPayload) => void; + onReceivedUpdatePlayers: (payload: UpdatePlayersPayload) => void; + onReceivedRemovePlayer: (payload: RemovePlayerPayload) => void; + onReceivedUpdateMapCells: (payload: UpdateMapCellsPayload) => void; + onReceivedGameEnd: () => void; + onReceivedBombPlaced: (payload: BombPlacedPayload) => void; + onReceivedBombPlacedAck: (payload: BombPlacedAckPayload) => void; + onReceivedPlayerDead: (payload: PlayerDeadPayload) => void; +}; + +/** 受信イベント購読の管理を担当する */ +export class GameNetworkEventReceiver { + private readonly socketSubscriptions: SocketSubscriptionDictionary; + private isBound = false; + + constructor(handlers: ReceivedGameEventHandlers) { + this.socketSubscriptions = createNetworkSubscriptions({ + onCurrentPlayers: handlers.onReceivedCurrentPlayers, + onNewPlayer: handlers.onReceivedNewPlayer, + onGameStart: handlers.onReceivedGameStart, + onUpdatePlayers: handlers.onReceivedUpdatePlayers, + onRemovePlayer: handlers.onReceivedRemovePlayer, + onUpdateMapCells: handlers.onReceivedUpdateMapCells, + onGameEnd: handlers.onReceivedGameEnd, + onBombPlaced: handlers.onReceivedBombPlaced, + onBombPlacedAck: handlers.onReceivedBombPlacedAck, + onPlayerDead: handlers.onReceivedPlayerDead, + }); + } + + /** 受信イベント購読を開始する */ + public bind(): void { + if (this.isBound) { + return; + } + + Object.values(this.socketSubscriptions).forEach((subscription) => { + subscription.bind(); + }); + + this.isBound = true; + } + + /** 受信イベント購読を解除する */ + public unbind(): void { + if (!this.isBound) { + return; + } + + Object.values(this.socketSubscriptions).forEach((subscription) => { + subscription.unbind(); + }); + + this.isBound = false; + } +} diff --git a/apps/client/src/scenes/game/application/runtime/GameManagerBootstrapper.ts b/apps/client/src/scenes/game/application/runtime/GameManagerBootstrapper.ts new file mode 100644 index 0000000..0166a4b --- /dev/null +++ b/apps/client/src/scenes/game/application/runtime/GameManagerBootstrapper.ts @@ -0,0 +1,53 @@ +/** + * GameManagerBootstrapper + * GameManagerの起動配線を担当する + * 初期化順序と中断時処理を一元管理する + */ +import { Application, Ticker } from "pixi.js"; +import type { SceneLifecycleState } from "@client/scenes/game/application/lifecycle/SceneLifecycleState"; +import type { GameSceneRuntime } from "./GameSceneRuntime"; + +/** 起動配線処理の入力型 */ +export type GameManagerBootstrapperOptions = { + app: Application; + lifecycleState: SceneLifecycleState; + container: HTMLDivElement; + runtime: GameSceneRuntime; + tick: (ticker: Ticker) => void; +}; + +/** 起動配線処理の成否を返す結果型 */ +export type GameManagerBootstrapResult = { + initialized: boolean; +}; + +/** 起動配線の実行を担当する */ +export class GameManagerBootstrapper { + private readonly options: GameManagerBootstrapperOptions; + + constructor(options: GameManagerBootstrapperOptions) { + this.options = options; + } + + /** GameManagerの起動配線を実行する */ + public async bootstrap(): Promise { + await this.options.app.init({ + resizeTo: window, + backgroundColor: 0x111111, + antialias: true, + }); + + if (this.options.lifecycleState.shouldAbortInit()) { + this.options.app.destroy(true, { children: true }); + return { initialized: false }; + } + + this.options.container.appendChild(this.options.app.canvas); + this.options.runtime.initialize(); + this.options.runtime.readyForGame(); + this.options.app.ticker.add(this.options.tick); + this.options.lifecycleState.markInitialized(); + + return { initialized: true }; + } +} diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 224ea18..11e085e 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -7,17 +7,14 @@ import { AppearanceResolver } from "../AppearanceResolver"; import { GameNetworkSync } from "../GameNetworkSync"; import { GameLoop } from "../GameLoop"; -import { - GameSceneOrchestrator, - type GameSceneEventPorts, - type GameSceneFactoryOptions, -} from "../orchestrators/GameSceneOrchestrator"; +import { type GameSceneEventPorts, type GameSceneFactoryOptions } from "../orchestrators/GameSceneOrchestrator"; import type { GamePlayers } from "../game.types"; import type { BombManager } from "../../entities/bomb/BombManager"; import type { MoveSender } from "../network/PlayerMoveSender"; import type { GameActionSender } from "../network/GameActionSender"; import type { GameSessionFacade } from "../lifecycle/GameSessionFacade"; import { DisposableRegistry } from "../lifecycle/DisposableRegistry"; +import { GameSceneRuntimeWiring } from "./GameSceneRuntimeWiring"; export type GameSceneRuntimeOptions = { app: Application; @@ -85,7 +82,7 @@ /** シーン実行に必要なサブシステムを初期化する */ public initialize(): void { - const orchestrator = new GameSceneOrchestrator({ + const runtimeWiring = new GameSceneRuntimeWiring({ app: this.app, worldContainer: this.worldContainer, players: this.players, @@ -95,10 +92,10 @@ getJoystickInput: () => this.joystickInput, moveSender: this.moveSender, eventPorts: this.eventPorts, - factories: this.sceneFactories, + sceneFactories: this.sceneFactories, }); - const initializedScene = orchestrator.initialize(); + const initializedScene = runtimeWiring.wire(); this.networkSync = initializedScene.networkSync; this.bombManager = initializedScene.bombManager; this.gameLoop = initializedScene.gameLoop; diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts new file mode 100644 index 0000000..191b88f --- /dev/null +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts @@ -0,0 +1,56 @@ +/** + * GameSceneRuntimeWiring + * ゲームシーン実行系の初期配線を担当する + * Orchestrator呼び出しをRuntime本体から分離する + */ +import { Application, Container } from "pixi.js"; +import { AppearanceResolver } from "../AppearanceResolver"; +import { + GameSceneOrchestrator, + type GameSceneEventPorts, + type GameSceneFactoryOptions, + type InitializedGameScene, +} from "../orchestrators/GameSceneOrchestrator"; +import type { GamePlayers } from "../game.types"; +import type { MoveSender } from "../network/PlayerMoveSender"; + +/** Runtime配線処理の入力型 */ +export type GameSceneRuntimeWiringOptions = { + app: Application; + worldContainer: Container; + players: GamePlayers; + myId: string; + appearanceResolver: AppearanceResolver; + getElapsedMs: () => number; + getJoystickInput: () => { x: number; y: number }; + moveSender: MoveSender; + eventPorts: GameSceneEventPorts; + sceneFactories?: GameSceneFactoryOptions; +}; + +/** Runtime向けの初期配線結果を返す */ +export class GameSceneRuntimeWiring { + private readonly options: GameSceneRuntimeWiringOptions; + + constructor(options: GameSceneRuntimeWiringOptions) { + this.options = options; + } + + /** シーン実行系サブシステムの初期配線を行う */ + public wire(): InitializedGameScene { + const orchestrator = new GameSceneOrchestrator({ + app: this.options.app, + worldContainer: this.options.worldContainer, + players: this.options.players, + myId: this.options.myId, + appearanceResolver: this.options.appearanceResolver, + getElapsedMs: this.options.getElapsedMs, + getJoystickInput: this.options.getJoystickInput, + moveSender: this.options.moveSender, + eventPorts: this.options.eventPorts, + factories: this.options.sceneFactories, + }); + + return orchestrator.initialize(); + } +} diff --git a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.styles.ts b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.styles.ts new file mode 100644 index 0000000..0c39482 --- /dev/null +++ b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.styles.ts @@ -0,0 +1,78 @@ +/** + * BombButton.styles + * BombButtonの描画スタイルを集約する + * 固定スタイルと動的スタイル生成関数を提供する + */ +import type { CSSProperties } from "react"; + +/** 爆弾ボタン外枠の固定スタイル */ +export const BOMB_BUTTON_FRAME_STYLE: CSSProperties = { + width: "108px", + height: "108px", + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + pointerEvents: "none", +}; + +/** 爆弾ボタン入力領域の固定スタイル */ +export const BOMB_BUTTON_HIT_AREA_STYLE: CSSProperties = { + position: "fixed", + right: "24px", + bottom: "28px", + width: "120px", + height: "120px", + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 9999, + touchAction: "manipulation", +}; + +/** 爆弾ボタン本体の固定スタイル */ +export const BOMB_BUTTON_STYLE: CSSProperties = { + width: "96px", + height: "96px", + borderRadius: "50%", + border: "2px solid rgba(255,255,255,0.75)", + background: "rgba(220, 60, 60, 0.85)", + color: "white", + fontSize: "18px", + fontWeight: "bold", + pointerEvents: "auto", + touchAction: "manipulation", + display: "flex", + alignItems: "center", + justifyContent: "center", +}; + +/** 外枠の進捗表示スタイルを生成する */ +export const buildBombButtonFrameStyle = ( + cooldownProgress: number, +): CSSProperties => { + const progressDeg = Math.max(0, Math.min(1, cooldownProgress)) * 360; + return { + ...BOMB_BUTTON_FRAME_STYLE, + background: `conic-gradient(rgba(255,255,255,0.95) ${progressDeg}deg, rgba(255,255,255,0.2) ${progressDeg}deg 360deg)`, + }; +}; + +/** ボタン活性状態に応じた本体スタイルを生成する */ +export const buildBombButtonStyle = (isReady: boolean): CSSProperties => { + return { + ...BOMB_BUTTON_STYLE, + background: isReady ? "rgba(220, 60, 60, 0.85)" : "rgba(110, 40, 40, 0.85)", + opacity: isReady ? 1 : 0.88, + cursor: isReady ? "pointer" : "not-allowed", + }; +}; + +/** 入力領域の活性状態スタイルを生成する */ +export const buildBombButtonHitAreaStyle = (isReady: boolean): CSSProperties => { + return { + ...BOMB_BUTTON_HIT_AREA_STYLE, + cursor: isReady ? "pointer" : "not-allowed", + }; +}; diff --git a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx index 26725d0..675752a 100644 --- a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx +++ b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx @@ -3,6 +3,11 @@ * 爆弾設置ボタンの見た目とクリック入力を担う * 画面右下固定の操作ボタンを提供する */ +import { + buildBombButtonFrameStyle, + buildBombButtonHitAreaStyle, + buildBombButtonStyle, +} from "./BombButton.styles"; /** 爆弾設置ボタンの入力プロパティ */ export type BombButtonProps = { @@ -12,46 +17,6 @@ remainingSecText: string | null; }; -const BOMB_BUTTON_FRAME_STYLE: React.CSSProperties = { - width: "108px", - height: "108px", - borderRadius: "50%", - display: "flex", - alignItems: "center", - justifyContent: "center", - pointerEvents: "none", -}; - -const BOMB_BUTTON_HIT_AREA_STYLE: React.CSSProperties = { - position: "fixed", - right: "24px", - bottom: "28px", - width: "120px", - height: "120px", - borderRadius: "50%", - display: "flex", - alignItems: "center", - justifyContent: "center", - zIndex: 9999, - touchAction: "manipulation", -}; - -const BOMB_BUTTON_STYLE: React.CSSProperties = { - width: "96px", - height: "96px", - borderRadius: "50%", - border: "2px solid rgba(255,255,255,0.75)", - background: "rgba(220, 60, 60, 0.85)", - color: "white", - fontSize: "18px", - fontWeight: "bold", - pointerEvents: "auto", - touchAction: "manipulation", - display: "flex", - alignItems: "center", - justifyContent: "center", -}; - /** 画面右下の爆弾設置ボタンを描画する */ export const BombButton = ({ onPress, @@ -59,23 +24,9 @@ isReady, remainingSecText, }: BombButtonProps) => { - const progressDeg = Math.max(0, Math.min(1, cooldownProgress)) * 360; - const frameStyle: React.CSSProperties = { - ...BOMB_BUTTON_FRAME_STYLE, - background: `conic-gradient(rgba(255,255,255,0.95) ${progressDeg}deg, rgba(255,255,255,0.2) ${progressDeg}deg 360deg)`, - }; - - const buttonStyle: React.CSSProperties = { - ...BOMB_BUTTON_STYLE, - background: isReady ? "rgba(220, 60, 60, 0.85)" : "rgba(110, 40, 40, 0.85)", - opacity: isReady ? 1 : 0.88, - cursor: isReady ? "pointer" : "not-allowed", - }; - - const hitAreaStyle: React.CSSProperties = { - ...BOMB_BUTTON_HIT_AREA_STYLE, - cursor: isReady ? "pointer" : "not-allowed", - }; + const frameStyle = buildBombButtonFrameStyle(cooldownProgress); + const buttonStyle = buildBombButtonStyle(isReady); + const hitAreaStyle = buildBombButtonHitAreaStyle(isReady); const handleActivate = () => { if (!isReady) { diff --git a/apps/client/src/scenes/game/input/joystick/presentation/JoystickInputPresenter.styles.ts b/apps/client/src/scenes/game/input/joystick/presentation/JoystickInputPresenter.styles.ts new file mode 100644 index 0000000..3ac17f5 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/presentation/JoystickInputPresenter.styles.ts @@ -0,0 +1,27 @@ +/** + * JoystickInputPresenter.styles + * JoystickInputPresenterの入力レイヤースタイルを集約する + * 活性状態に応じたスタイル生成を提供する + */ +import type { CSSProperties } from "react"; + +/** ジョイスティック入力レイヤーの固定スタイル */ +export const JOYSTICK_INPUT_LAYER_STYLE: CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "50%", + height: "100%", + zIndex: 10, + touchAction: "none", +}; + +/** 活性状態に応じた入力レイヤースタイルを生成する */ +export const buildJoystickInputLayerStyle = ( + isEnabled: boolean, +): CSSProperties => { + return { + ...JOYSTICK_INPUT_LAYER_STYLE, + pointerEvents: isEnabled ? "auto" : "none", + }; +}; diff --git a/apps/client/src/scenes/game/input/joystick/presentation/JoystickInputPresenter.tsx b/apps/client/src/scenes/game/input/joystick/presentation/JoystickInputPresenter.tsx index de09ab1..1303c4e 100644 --- a/apps/client/src/scenes/game/input/joystick/presentation/JoystickInputPresenter.tsx +++ b/apps/client/src/scenes/game/input/joystick/presentation/JoystickInputPresenter.tsx @@ -7,6 +7,7 @@ import { useJoystickController } from "../hooks/useJoystickController"; import { JoystickView } from "./JoystickView.tsx"; import type { UseJoystickInputPresenterProps } from "../common"; +import { buildJoystickInputLayerStyle } from "./JoystickInputPresenter.styles"; /** 入力と表示状態の橋渡しを行う */ export const JoystickInputPresenter = ({ @@ -33,6 +34,8 @@ reset(); }, [isEnabled, reset]); + const layerStyle = buildJoystickInputLayerStyle(isEnabled); + return (