diff --git a/apps/client/src/scenes/game/application/GameLoop.ts b/apps/client/src/scenes/game/application/GameLoop.ts index 0b4bfa2..b00d64d 100644 --- a/apps/client/src/scenes/game/application/GameLoop.ts +++ b/apps/client/src/scenes/game/application/GameLoop.ts @@ -12,7 +12,12 @@ import { SimulationStep } from "./loopSteps/SimulationStep"; import { CameraStep } from "./loopSteps/CameraStep"; import { BombStep } from "./loopSteps/BombStep"; -import type { LoopFrameContext, LoopFrameEffects, LoopStep } from "./loopSteps/LoopStep"; +import type { + LoopFrameContext, + LoopFrameEffects, + LoopMovementState, + LoopStep, +} from "./loopSteps/LoopStep"; import { resolveFrameDelta } from "./loopSteps/frameDelta"; import type { MoveSender } from "./network/PlayerMoveSender"; @@ -65,8 +70,12 @@ ticker, config.GAME_CONFIG.FRAME_DELTA_MAX_MS, ); - const frameState = { - isMoving: false, + const frameState: { movement: LoopMovementState } = { + movement: { + isMoving: false, + axisX: 0, + axisY: 0, + }, }; const frameContext: LoopFrameContext = { @@ -75,12 +84,14 @@ playerRepository: this.playerRepository, me, deltaSeconds, - getIsMoving: () => frameState.isMoving, }; const effects: LoopFrameEffects = { - setIsMoving: (isMoving) => { - frameState.isMoving = isMoving; + setMovementState: (movement) => { + frameState.movement = movement; + }, + getMovementState: () => { + return frameState.movement; }, }; diff --git a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts index f9d1458..8c9ce3b 100644 --- a/apps/client/src/scenes/game/application/loopSteps/InputStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/InputStep.ts @@ -4,7 +4,12 @@ * ジョイスティック入力をローカルプレイヤーへ適用する */ import { LocalPlayerController } from "@client/scenes/game/entities/player/PlayerController"; -import type { LoopFrameContext, LoopFrameEffects, LoopStep } from "./LoopStep"; +import type { + LoopFrameContext, + LoopFrameEffects, + LoopMovementState, + LoopStep, +} from "./LoopStep"; type InputStepOptions = { getJoystickInput: () => { x: number; y: number }; @@ -33,11 +38,11 @@ deltaSeconds: context.deltaSeconds, }; - const isMoving = this.applyInput(params); - effects.setIsMoving(isMoving); + const movementState = this.applyInput(params); + effects.setMovementState(movementState); } - private applyInput({ me, deltaSeconds }: InputStepParams): boolean { + private applyInput({ me, deltaSeconds }: InputStepParams): LoopMovementState { const { x: axisX, y: axisY } = this.getJoystickInput(); const isMoving = axisX !== 0 || axisY !== 0; @@ -45,6 +50,10 @@ me.applyLocalInput({ axisX, axisY, deltaTime: deltaSeconds }); } - return isMoving; + return { + isMoving, + axisX, + axisY, + }; } } \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts index af34e68..76efc9d 100644 --- a/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/LoopStep.ts @@ -14,12 +14,19 @@ playerRepository: PlayerRepository; me: LocalPlayerController; deltaSeconds: number; - getIsMoving: () => boolean; +}; + +/** 入力由来の移動状態を表す型 */ +export type LoopMovementState = { + isMoving: boolean; + axisX: number; + axisY: number; }; /** 1フレーム内で許可する副作用操作の型 */ export type LoopFrameEffects = { - setIsMoving: (isMoving: boolean) => void; + setMovementState: (movement: LoopMovementState) => void; + getMovementState: () => LoopMovementState; }; /** ゲームループ内で実行されるステップ共通インターフェース */ diff --git a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts index e2852a6..0f4759e 100644 --- a/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts +++ b/apps/client/src/scenes/game/application/loopSteps/SimulationStep.ts @@ -7,7 +7,12 @@ 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, LoopFrameEffects, LoopStep } from "./LoopStep"; +import type { + LoopFrameContext, + LoopFrameEffects, + LoopMovementState, + LoopStep, +} from "./LoopStep"; /** SimulationStep の初期化入力 */ type SimulationStepOptions = { @@ -19,7 +24,7 @@ me: LocalPlayerController; playerRepository: PlayerRepository; deltaSeconds: number; - getIsMoving: () => boolean; + movementState: LoopMovementState; }; /** シミュレーション段の更新処理を担うステップ */ @@ -37,16 +42,16 @@ /** ローカル更新とリモート補間更新を実行する */ public run( context: Readonly, - _effects: LoopFrameEffects, + effects: LoopFrameEffects, ): void { const params: SimulationStepParams = { me: context.me, playerRepository: context.playerRepository, deltaSeconds: context.deltaSeconds, - getIsMoving: context.getIsMoving, + movementState: effects.getMovementState(), }; - this.runLocalSimulation({ me: params.me, isMoving: params.getIsMoving() }); + this.runLocalSimulation({ me: params.me, isMoving: params.movementState.isMoving }); this.runRemoteSimulation({ playerRepository: params.playerRepository, deltaSeconds: params.deltaSeconds, diff --git a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts index 658a16c..965b5fd 100644 --- a/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts +++ b/apps/client/src/scenes/game/application/network/NetworkSubscriptions.ts @@ -50,50 +50,90 @@ onPlayerDead: (payload: PlayerDeadPayload) => void; }; +type SubscriptionDefinition = { + key: keyof SocketSubscriptionDictionary; + create: (handlers: NetworkSubscriptionHandlers) => SocketSubscription; +}; + +const SUBSCRIPTION_DEFINITIONS: SubscriptionDefinition[] = [ + { + key: "currentPlayers", + create: (handlers) => ({ + bind: () => socketManager.game.onCurrentPlayers(handlers.onCurrentPlayers), + unbind: () => socketManager.game.offCurrentPlayers(handlers.onCurrentPlayers), + }), + }, + { + key: "newPlayer", + create: (handlers) => ({ + bind: () => socketManager.game.onNewPlayer(handlers.onNewPlayer), + unbind: () => socketManager.game.offNewPlayer(handlers.onNewPlayer), + }), + }, + { + key: "gameStart", + create: (handlers) => ({ + bind: () => socketManager.game.onGameStart(handlers.onGameStart), + unbind: () => socketManager.game.offGameStart(handlers.onGameStart), + }), + }, + { + key: "updatePlayers", + create: (handlers) => ({ + bind: () => socketManager.game.onUpdatePlayers(handlers.onUpdatePlayers), + unbind: () => socketManager.game.offUpdatePlayers(handlers.onUpdatePlayers), + }), + }, + { + key: "removePlayer", + create: (handlers) => ({ + bind: () => socketManager.game.onRemovePlayer(handlers.onRemovePlayer), + unbind: () => socketManager.game.offRemovePlayer(handlers.onRemovePlayer), + }), + }, + { + key: "updateMapCells", + create: (handlers) => ({ + bind: () => socketManager.game.onUpdateMapCells(handlers.onUpdateMapCells), + unbind: () => socketManager.game.offUpdateMapCells(handlers.onUpdateMapCells), + }), + }, + { + key: "gameEnd", + create: (handlers) => ({ + bind: () => socketManager.game.onGameEnd(handlers.onGameEnd), + unbind: () => socketManager.game.offGameEnd(handlers.onGameEnd), + }), + }, + { + key: "bombPlaced", + create: (handlers) => ({ + bind: () => socketManager.game.onBombPlaced(handlers.onBombPlaced), + unbind: () => socketManager.game.offBombPlaced(handlers.onBombPlaced), + }), + }, + { + key: "bombPlacedAck", + create: (handlers) => ({ + bind: () => socketManager.game.onBombPlacedAck(handlers.onBombPlacedAck), + unbind: () => socketManager.game.offBombPlacedAck(handlers.onBombPlacedAck), + }), + }, + { + key: "playerDead", + create: (handlers) => ({ + bind: () => socketManager.game.onPlayerDead(handlers.onPlayerDead), + unbind: () => socketManager.game.offPlayerDead(handlers.onPlayerDead), + }), + }, +]; + /** ソケット購読辞書を生成する */ 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), - }, - }; + return SUBSCRIPTION_DEFINITIONS.reduce((dictionary, definition) => { + dictionary[definition.key] = definition.create(handlers); + return dictionary; + }, {} as SocketSubscriptionDictionary); }; \ No newline at end of file diff --git a/apps/client/src/scenes/game/application/network/handlers/PlayerControllerFactory.ts b/apps/client/src/scenes/game/application/network/handlers/PlayerControllerFactory.ts new file mode 100644 index 0000000..c19b4df --- /dev/null +++ b/apps/client/src/scenes/game/application/network/handlers/PlayerControllerFactory.ts @@ -0,0 +1,37 @@ +/** + * PlayerControllerFactory + * プレイヤー種別に応じたコントローラー生成を担当する + * Local/Remote 分岐の生成責務を同期ハンドラから分離する + */ +import type { NewPlayerPayload } from "@repo/shared"; +import { + LocalPlayerController, + RemotePlayerController, +} from "@client/scenes/game/entities/player/PlayerController"; +import type { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; + +/** PlayerControllerFactory の初期化入力 */ +export type PlayerControllerFactoryOptions = { + myId: string; + appearanceResolver: AppearanceResolver; +}; + +/** プレイヤーIDに応じたコントローラーを生成する */ +export class PlayerControllerFactory { + private readonly myId: string; + private readonly appearanceResolver: AppearanceResolver; + + constructor({ myId, appearanceResolver }: PlayerControllerFactoryOptions) { + this.myId = myId; + this.appearanceResolver = appearanceResolver; + } + + /** プレイヤー生成payloadから適切なコントローラーを生成する */ + public create(playerId: string, payload: NewPlayerPayload): LocalPlayerController | RemotePlayerController { + if (playerId === this.myId) { + return new LocalPlayerController(payload, this.appearanceResolver); + } + + return new RemotePlayerController(payload, this.appearanceResolver); + } +} 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 098faf6..fc7c7e6 100644 --- a/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts +++ b/apps/client/src/scenes/game/application/network/handlers/PlayerSyncHandler.ts @@ -11,11 +11,11 @@ UpdatePlayersPayload, } from "@repo/shared"; import { - LocalPlayerController, RemotePlayerController, } from "@client/scenes/game/entities/player/PlayerController"; import { PlayerRepository } from "@client/scenes/game/entities/player/PlayerRepository"; import { AppearanceResolver } from "@client/scenes/game/application/AppearanceResolver"; +import { PlayerControllerFactory } from "./PlayerControllerFactory"; /** PlayerSyncHandler の初期化入力 */ export type PlayerSyncHandlerOptions = { @@ -29,14 +29,15 @@ export class PlayerSyncHandler { private readonly worldContainer: Container; private readonly playerRepository: PlayerRepository; - private readonly myId: string; - private readonly appearanceResolver: AppearanceResolver; + private readonly playerControllerFactory: PlayerControllerFactory; constructor({ worldContainer, playerRepository, myId, appearanceResolver }: PlayerSyncHandlerOptions) { this.worldContainer = worldContainer; this.playerRepository = playerRepository; - this.myId = myId; - this.appearanceResolver = appearanceResolver; + this.playerControllerFactory = new PlayerControllerFactory({ + myId, + appearanceResolver, + }); } /** 初期プレイヤー一覧を生成して反映する */ @@ -79,9 +80,7 @@ existing.destroy(); } - const playerController = playerId === this.myId - ? new LocalPlayerController(payload, this.appearanceResolver) - : new RemotePlayerController(payload, this.appearanceResolver); + const playerController = this.playerControllerFactory.create(playerId, payload); this.worldContainer.addChild(playerController.getDisplayObject()); this.playerRepository.upsert(playerId, playerController); diff --git a/apps/client/src/scenes/game/application/runtime/GameManagerBootstrapper.ts b/apps/client/src/scenes/game/application/runtime/GameManagerBootstrapper.ts index 0166a4b..e7e54f6 100644 --- a/apps/client/src/scenes/game/application/runtime/GameManagerBootstrapper.ts +++ b/apps/client/src/scenes/game/application/runtime/GameManagerBootstrapper.ts @@ -43,9 +43,7 @@ } 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.runtime.activate(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 74ed5f2..58a1db8 100644 --- a/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts +++ b/apps/client/src/scenes/game/application/runtime/GameSceneRuntime.ts @@ -52,6 +52,7 @@ private networkSync: GameNetworkSync | null = null; private gameLoop: GameLoop | null = null; private joystickInput = { x: 0, y: 0 }; + private tickerHandler: ((ticker: Ticker) => void) | null = null; private lifecycleState: RuntimeLifecycleState = "created"; constructor({ @@ -122,6 +123,18 @@ }); } + /** シーン実行を開始する */ + public activate(tick: (ticker: Ticker) => void): void { + if (this.lifecycleState !== "created") { + return; + } + + this.initialize(); + this.readyForGame(); + this.app.ticker.add(tick); + this.tickerHandler = tick; + } + public isInputEnabled(): boolean { return this.sessionFacade.canAcceptInput(); } @@ -163,6 +176,10 @@ } this.lifecycleState = "destroyed"; + if (this.tickerHandler) { + this.app.ticker.remove(this.tickerHandler); + this.tickerHandler = null; + } this.disposableRegistry.disposeAll(); } } diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.styles.ts b/apps/client/src/scenes/game/input/GameInputOverlay.styles.ts index d52d5c4..d8c1815 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.styles.ts +++ b/apps/client/src/scenes/game/input/GameInputOverlay.styles.ts @@ -12,3 +12,10 @@ width: "100%", height: "100%", }; + +/** 入力UI全体レイヤーの描画スタイルを返す */ +export const buildGameInputOverlayLayerStyle = (): CSSProperties => { + return { + ...GAME_INPUT_OVERLAY_LAYER_STYLE, + }; +}; diff --git a/apps/client/src/scenes/game/input/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx index 0870400..654daa8 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx +++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx @@ -7,7 +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"; +import { buildGameInputOverlayLayerStyle } from "./GameInputOverlay.styles"; /** 入力UIレイヤーの入力プロパティ */ type GameInputOverlayProps = { @@ -24,6 +24,7 @@ }: GameInputOverlayProps) => { const bombCooldownMs = config.GAME_CONFIG.BOMB_COOLDOWN_MS; const { cooldownState, markTriggered } = useBombCooldownClock(bombCooldownMs); + const layerStyle = buildGameInputOverlayLayerStyle(); const handlePressBomb = () => { if (!isInputEnabled || !cooldownState.isReady) { @@ -39,7 +40,7 @@ }; return ( -
+