diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts index 4a4011d..0b4bfa2 100644 --- a/apps/client/src/scenes/game/application/GameLoop.ts +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -8,7 +8,6 @@ import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; import { BombManager } from "@client/scenes/game/entities/bomb/BombManager"; -import type { GamePlayers } from "./game.types"; import { InputStep } from "./loopSteps/InputStep"; import { SimulationStep } from "./loopSteps/SimulationStep"; import { CameraStep } from "./loopSteps/CameraStep"; @@ -20,7 +19,7 @@ type GameLoopOptions = { app: Application; worldContainer: Container; - players: GamePlayers; + playerRepository: PlayerRepository; myId: string; getJoystickInput: () => { x: number; y: number }; bombManager: BombManager; @@ -29,20 +28,20 @@ /** ゲームのフレーム更新順序を管理するループ制御クラス */ export class GameLoop { - private app: Application; - private worldContainer: Container; - private playerRepository: PlayerRepository; - private myId: string; - private inputStep: InputStep; - private simulationStep: SimulationStep; - private bombStep: BombStep; - private cameraStep: CameraStep; - private steps: LoopStep[]; + private readonly app: Application; + private readonly worldContainer: Container; + private readonly playerRepository: PlayerRepository; + private readonly myId: string; + private readonly inputStep: InputStep; + private readonly simulationStep: SimulationStep; + private readonly bombStep: BombStep; + private readonly cameraStep: CameraStep; + private readonly steps: LoopStep[]; - constructor({ app, worldContainer, players, myId, getJoystickInput, bombManager, moveSender }: GameLoopOptions) { + constructor({ app, worldContainer, playerRepository, myId, getJoystickInput, bombManager, moveSender }: GameLoopOptions) { this.app = app; this.worldContainer = worldContainer; - this.playerRepository = new PlayerRepository(players); + this.playerRepository = playerRepository; this.myId = myId; this.inputStep = new InputStep({ getJoystickInput }); this.simulationStep = new SimulationStep({ @@ -70,6 +69,15 @@ isMoving: false, }; + const frameContext: LoopFrameContext = { + app: this.app, + worldContainer: this.worldContainer, + playerRepository: this.playerRepository, + me, + deltaSeconds, + getIsMoving: () => frameState.isMoving, + }; + const effects: LoopFrameEffects = { setIsMoving: (isMoving) => { frameState.isMoving = isMoving; @@ -77,14 +85,6 @@ }; this.steps.forEach((step) => { - 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 1552a3e..0b48001 100644 --- a/apps/client/src/scenes/game/application/GameNetworkSync.ts +++ b/apps/client/src/scenes/game/application/GameNetworkSync.ts @@ -14,13 +14,12 @@ import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; 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; type GameNetworkSyncOptions = { worldContainer: Container; - players: GamePlayers; + playerRepository: PlayerRepository; myId: string; gameMap: GameMapController; appearanceResolver: AppearanceResolver; @@ -46,7 +45,7 @@ constructor({ worldContainer, - players, + playerRepository, myId, gameMap, appearanceResolver, @@ -56,8 +55,6 @@ onBombPlacementAcknowledged, onRemotePlayerDead, }: GameNetworkSyncOptions) { - const playerRepository = new PlayerRepository(players); - this.stateApplier = new GameNetworkStateApplier({ worldContainer, playerRepository, @@ -72,18 +69,9 @@ onDebugLog: this.debugLog, }); - 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), - }); + this.eventReceiver = new GameNetworkEventReceiver( + this.stateApplier.getReceivedEventHandlers(), + ); } public bind() { diff --git a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts index 65ffb82..af34e68 100644 --- a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts @@ -14,7 +14,7 @@ playerRepository: PlayerRepository; me: LocalPlayerController; deltaSeconds: number; - isMoving: boolean; + getIsMoving: () => boolean; }; /** 1フレーム内で許可する副作用操作の型 */ diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts index 860d7e7..e2852a6 100644 --- a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts @@ -19,7 +19,7 @@ me: LocalPlayerController; playerRepository: PlayerRepository; deltaSeconds: number; - isMoving: boolean; + getIsMoving: () => boolean; }; /** シミュレーション段の更新処理を担うステップ */ @@ -43,17 +43,17 @@ me: context.me, playerRepository: context.playerRepository, deltaSeconds: context.deltaSeconds, - isMoving: context.isMoving, + getIsMoving: context.getIsMoving, }; - this.runLocalSimulation({ me: params.me, isMoving: params.isMoving }); + this.runLocalSimulation({ me: params.me, isMoving: params.getIsMoving() }); this.runRemoteSimulation({ playerRepository: params.playerRepository, deltaSeconds: params.deltaSeconds, }); } - private runLocalSimulation({ me, isMoving }: Pick) { + private runLocalSimulation({ me, isMoving }: { me: LocalPlayerController; isMoving: boolean }) { if (isMoving) { me.tick(); diff --git a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts index 2a62a51..1df634d 100644 --- a/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts +++ b/apps/client/src/scenes/game/application/network/handlers/GameNetworkStateApplier.ts @@ -7,13 +7,7 @@ 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"; @@ -27,6 +21,7 @@ import { CombatSyncHandler } from "./CombatSyncHandler"; import { MapSyncHandler } from "./MapSyncHandler"; import { PlayerSyncHandler } from "./PlayerSyncHandler"; +import type { ReceivedGameEventHandlers } from "../receivers/GameNetworkEventReceiver"; /** 状態反映処理の初期化入力 */ export type GameNetworkStateApplierOptions = { @@ -51,6 +46,7 @@ private readonly onGameStarted: (startTime: number) => void; private readonly onGameEnded: () => void; private readonly onDebugLog: (message: string) => void; + private readonly receivedEventHandlers: ReceivedGameEventHandlers; constructor({ worldContainer, @@ -86,61 +82,48 @@ this.onGameStarted = onGameStarted; this.onGameEnded = onGameEnded; this.onDebugLog = onDebugLog ?? (() => undefined); + this.receivedEventHandlers = { + onReceivedCurrentPlayers: (payload) => { + this.playerSyncHandler.handleCurrentPlayers(payload); + }, + onReceivedNewPlayer: (payload) => { + this.playerSyncHandler.handleNewPlayer(payload); + }, + onReceivedGameStart: (payload) => { + const startTime = toGameStartedAt(payload); + if (startTime === null) { + return; + } + + this.onGameStarted(startTime); + this.onDebugLog(`[GameNetworkSync] ゲーム開始時刻同期完了: ${startTime}`); + }, + onReceivedUpdatePlayers: (payload) => { + this.playerSyncHandler.handlePlayerUpdates(payload); + }, + onReceivedRemovePlayer: (payload) => { + this.playerSyncHandler.handleRemovePlayer(payload); + }, + onReceivedUpdateMapCells: (payload) => { + this.mapSyncHandler.handleUpdateMapCells(payload); + }, + onReceivedGameEnd: () => { + this.onGameEnded(); + }, + onReceivedBombPlaced: (payload) => { + this.combatSyncHandler.handleReceivedBombPlaced(payload); + }, + onReceivedBombPlacedAck: (payload) => { + this.combatSyncHandler.handleReceivedBombPlacedAck(payload); + }, + onReceivedPlayerDead: (payload) => { + this.combatSyncHandler.handleReceivedPlayerDead(payload); + }, + }; } - /** 初期プレイヤー一覧の受信イベントを適用する */ - 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); + /** 受信イベント配信先ハンドラ群を返す */ + public getReceivedEventHandlers(): ReceivedGameEventHandlers { + return this.receivedEventHandlers; } } diff --git a/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts b/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts index 540d27f..098faf6 100644 --- a/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts +++ b/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts @@ -42,19 +42,13 @@ /** 初期プレイヤー一覧を生成して反映する */ 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.playerRepository.upsert(player.id, playerController); + this.replacePlayerController(player.id, player); }); }; /** 新規参加プレイヤーを生成して反映する */ public handleNewPlayer = (payload: NewPlayerPayload): void => { - const playerController = new RemotePlayerController(payload, this.appearanceResolver); - this.worldContainer.addChild(playerController.getDisplayObject()); - this.playerRepository.upsert(payload.id, playerController); + this.replacePlayerController(payload.id, payload); }; /** プレイヤー差分更新を反映する */ @@ -77,4 +71,19 @@ this.worldContainer.removeChild(target.getDisplayObject()); target.destroy(); }; + + private replacePlayerController(playerId: string, payload: NewPlayerPayload): void { + const existing = this.playerRepository.remove(playerId); + if (existing) { + this.worldContainer.removeChild(existing.getDisplayObject()); + existing.destroy(); + } + + const playerController = playerId === this.myId + ? new LocalPlayerController(payload, this.appearanceResolver) + : new RemotePlayerController(payload, this.appearanceResolver); + + this.worldContainer.addChild(playerController.getDisplayObject()); + this.playerRepository.upsert(playerId, playerController); + } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts index d614355..96c9ce9 100644 --- a/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts +++ b/apps/client/src/scenes/game/application/orchestrators/GameSceneOrchestrator.ts @@ -16,11 +16,12 @@ import { GameLoop } from "@client/scenes/game/application/GameLoop"; import type { MoveSender } from "@client/scenes/game/application/network/PlayerMoveSender"; import type { GamePlayers } from "@client/scenes/game/application/game.types"; +import type { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; /** GameNetworkSync 生成入力型 */ export type CreateNetworkSyncOptions = { worldContainer: Container; - players: GamePlayers; + playerRepository: PlayerRepository; myId: string; gameMap: GameMapController; appearanceResolver: AppearanceResolver; @@ -55,7 +56,7 @@ export type CreateGameLoopOptions = { app: Application; worldContainer: Container; - players: GamePlayers; + playerRepository: PlayerRepository; myId: string; getJoystickInput: () => { x: number; y: number }; bombManager: BombManager; @@ -74,6 +75,7 @@ app: Application; worldContainer: Container; players: GamePlayers; + playerRepository: PlayerRepository; myId: string; appearanceResolver: AppearanceResolver; getElapsedMs: () => number; @@ -96,6 +98,7 @@ private readonly app: Application; private readonly worldContainer: Container; private readonly players: GamePlayers; + private readonly playerRepository: PlayerRepository; private readonly myId: string; private readonly appearanceResolver: AppearanceResolver; private readonly getElapsedMs: () => number; @@ -110,6 +113,7 @@ app, worldContainer, players, + playerRepository, myId, appearanceResolver, getElapsedMs, @@ -121,6 +125,7 @@ this.app = app; this.worldContainer = worldContainer; this.players = players; + this.playerRepository = playerRepository; this.myId = myId; this.appearanceResolver = appearanceResolver; this.getElapsedMs = getElapsedMs; @@ -158,7 +163,7 @@ private initializeNetworkSync(gameMap: GameMapController): GameNetworkSync { const networkSync = this.createNetworkSync({ worldContainer: this.worldContainer, - players: this.players, + playerRepository: this.playerRepository, myId: this.myId, gameMap, appearanceResolver: this.appearanceResolver, @@ -189,7 +194,7 @@ return this.createGameLoop({ app: this.app, worldContainer: this.worldContainer, - players: this.players, + playerRepository: this.playerRepository, myId: this.myId, getJoystickInput: this.getJoystickInput, bombManager, diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts index 11e085e..74ed5f2 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -15,6 +15,9 @@ import type { GameSessionFacade } from "../lifecycle/GameSessionFacade"; import { DisposableRegistry } from "../lifecycle/DisposableRegistry"; import { GameSceneRuntimeWiring } from "./GameSceneRuntimeWiring"; +import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; + +type RuntimeLifecycleState = "created" | "initialized" | "destroyed"; export type GameSceneRuntimeOptions = { app: Application; @@ -41,6 +44,7 @@ private readonly getElapsedMs: () => number; private readonly eventPorts: GameSceneEventPorts; private readonly sceneFactories?: GameSceneFactoryOptions; + private readonly playerRepository: PlayerRepository; private readonly disposableRegistry = new DisposableRegistry(); private readonly appearanceResolver = new AppearanceResolver(); @@ -48,6 +52,7 @@ private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; private joystickInput = { x: 0, y: 0 }; + private lifecycleState: RuntimeLifecycleState = "created"; constructor({ app, @@ -71,6 +76,7 @@ this.getElapsedMs = getElapsedMs; this.eventPorts = eventPorts; this.sceneFactories = sceneFactories; + this.playerRepository = new PlayerRepository(this.players); this.disposableRegistry.add(() => { this.clearJoystickInput(); @@ -82,10 +88,15 @@ /** シーン実行に必要なサブシステムを初期化する */ public initialize(): void { + if (this.lifecycleState !== "created") { + return; + } + const runtimeWiring = new GameSceneRuntimeWiring({ app: this.app, worldContainer: this.worldContainer, players: this.players, + playerRepository: this.playerRepository, myId: this.myId, appearanceResolver: this.appearanceResolver, getElapsedMs: this.getElapsedMs, @@ -99,6 +110,7 @@ this.networkSync = initializedScene.networkSync; this.bombManager = initializedScene.bombManager; this.gameLoop = initializedScene.gameLoop; + this.lifecycleState = "initialized"; this.disposableRegistry.add(() => { this.networkSync?.unbind(); @@ -146,6 +158,11 @@ /** 実行系サブシステムを破棄する */ public destroy(): void { + if (this.lifecycleState === "destroyed") { + return; + } + + this.lifecycleState = "destroyed"; this.disposableRegistry.disposeAll(); } } diff --git a/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts b/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts index 191b88f..c886e60 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntimeWiring.ts @@ -13,12 +13,14 @@ } from "../orchestrators/GameSceneOrchestrator"; import type { GamePlayers } from "../game.types"; import type { MoveSender } from "../network/PlayerMoveSender"; +import type { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; /** Runtime配線処理の入力型 */ export type GameSceneRuntimeWiringOptions = { app: Application; worldContainer: Container; players: GamePlayers; + playerRepository: PlayerRepository; myId: string; appearanceResolver: AppearanceResolver; getElapsedMs: () => number; @@ -42,6 +44,7 @@ app: this.options.app, worldContainer: this.options.worldContainer, players: this.options.players, + playerRepository: this.options.playerRepository, myId: this.options.myId, appearanceResolver: this.options.appearanceResolver, getElapsedMs: this.options.getElapsedMs, diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.styles.ts b/apps/client/src/scenes/game/input/GameInputOverlay.styles.ts new file mode 100644 index 0000000..d52d5c4 --- /dev/null +++ b/apps/client/src/scenes/game/input/GameInputOverlay.styles.ts @@ -0,0 +1,14 @@ +/** + * GameInputOverlay.styles + * GameInputOverlayのレイヤースタイルを集約する + * 入力UI全体の配置スタイルを提供する + */ +import type { CSSProperties } from "react"; + +/** 入力UI全体レイヤーの固定スタイル */ +export const GAME_INPUT_OVERLAY_LAYER_STYLE: CSSProperties = { + position: "absolute", + zIndex: 20, + width: "100%", + height: "100%", +}; diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx index c3e9c26..0870400 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx +++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx @@ -7,6 +7,7 @@ import { JoystickInputPresenter } from "./joystick/presentation/JoystickInputPresenter"; import { BombButton } from "./bomb/presentation/BombButton"; import { useBombCooldownClock } from "./bomb/hooks/useBombCooldownClock"; +import { GAME_INPUT_OVERLAY_LAYER_STYLE } from "./GameInputOverlay.styles"; /** 入力UIレイヤーの入力プロパティ */ type GameInputOverlayProps = { @@ -15,13 +16,6 @@ onPlaceBomb: () => boolean; }; -const UI_LAYER_STYLE: React.CSSProperties = { - position: "absolute", - zIndex: 20, - width: "100%", - height: "100%", -}; - /** 入力UIレイヤーを描画する */ export const GameInputOverlay = ({ isInputEnabled, @@ -45,7 +39,7 @@ }; return ( -
+