diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 9429ad1..d9d2f1e 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -25,6 +25,8 @@ import type { HurricaneHitPayload } from "@repo/shared"; import { GameUiStateSyncService, + type GameHudState, + type MiniMapState, type GameUiState, } from "./application/ui/GameUiStateSyncService"; import { preloadGameStartAssets } from "./application/assets/GameAssetPreloader"; @@ -39,7 +41,11 @@ }; /** GameScene の UI 表示状態型を外部参照向けに再公開する */ -export type { GameUiState } from "./application/ui/GameUiStateSyncService"; +export type { + GameUiState, + GameHudState, + MiniMapState, +} from "./application/ui/GameUiStateSyncService"; /** ゲームシーンの実行ライフサイクルを管理するマネージャー */ export class GameManager { @@ -214,6 +220,20 @@ return this.uiStateSyncService.subscribe(listener); } + /** HUD状態購読を登録し,解除関数を返す */ + public subscribeHudState( + listener: (state: GameHudState) => void, + ): () => void { + return this.uiStateSyncService.subscribeHud(listener); + } + + /** ミニマップ状態購読を登録し,解除関数を返す */ + public subscribeMiniMapState( + listener: (state: MiniMapState) => void, + ): () => void { + return this.uiStateSyncService.subscribeMiniMap(listener); + } + private getUiStateSnapshot(): GameUiState { const miniMapTeamIds = this.runtime.getMiniMapTeamIds(); diff --git a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts index 0ccf12e..bfbca76 100644 --- a/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts +++ b/apps/client/src/scenes/game/application/ui/GameUiStateSyncService.ts @@ -71,6 +71,8 @@ private readonly getSnapshot: () => GameUiState; private readonly timeProvider: TimeProvider; private readonly listeners = new Set<(state: GameUiState) => void>(); + private readonly hudListeners = new Set<(state: GameHudState) => void>(); + private readonly miniMapListeners = new Set<(state: MiniMapState) => void>(); private lastState: GameUiState | null = null; private alignTimeoutId: number | null = null; private timerId: number | null = null; @@ -89,33 +91,76 @@ }; } + /** HUD状態の購読を登録し,解除関数を返す */ + public subscribeHud(listener: (state: GameHudState) => void): () => void { + this.hudListeners.add(listener); + listener(this.getSnapshot().hud); + + return () => { + this.hudListeners.delete(listener); + }; + } + + /** ミニマップ状態の購読を登録し,解除関数を返す */ + public subscribeMiniMap(listener: (state: MiniMapState) => void): () => void { + this.miniMapListeners.add(listener); + listener(this.getSnapshot().miniMap); + + return () => { + this.miniMapListeners.delete(listener); + }; + } + + private hasAnyListeners(): boolean { + return this.listeners.size > 0 + || this.hudListeners.size > 0 + || this.miniMapListeners.size > 0; + } + + private isSameHudState(a: GameHudState, b: GameHudState): boolean { + return a.remainingTimeSec === b.remainingTimeSec + && a.startCountdownSec === b.startCountdownSec + && a.isInputEnabled === b.isInputEnabled + && a.localBombHitCount === b.localBombHitCount + && isSamePaintRates(a.teamPaintRates, b.teamPaintRates); + } + + private isSameMiniMapState(a: MiniMapState, b: MiniMapState): boolean { + return a.mapRevision === b.mapRevision + && isSameLocalPlayerPosition(a.localPlayerPosition, b.localPlayerPosition); + } + public emitIfChanged(force = false): void { - if (this.listeners.size === 0 && !force) { + if (!this.hasAnyListeners() && !force) { return; } const snapshot = this.getSnapshot(); - if ( - !force && - this.lastState && - this.lastState.hud.remainingTimeSec === snapshot.hud.remainingTimeSec && - this.lastState.hud.startCountdownSec === snapshot.hud.startCountdownSec && - this.lastState.hud.isInputEnabled === snapshot.hud.isInputEnabled && - this.lastState.hud.localBombHitCount === snapshot.hud.localBombHitCount && - isSamePaintRates( - this.lastState.hud.teamPaintRates, - snapshot.hud.teamPaintRates, - ) && - this.lastState.miniMap.mapRevision === snapshot.miniMap.mapRevision && - isSameLocalPlayerPosition( - this.lastState.miniMap.localPlayerPosition, - snapshot.miniMap.localPlayerPosition, - ) - ) { + const hudChanged = force + || !this.lastState + || !this.isSameHudState(this.lastState.hud, snapshot.hud); + const miniMapChanged = force + || !this.lastState + || !this.isSameMiniMapState(this.lastState.miniMap, snapshot.miniMap); + + if (!hudChanged && !miniMapChanged) { return; } this.lastState = snapshot; + + if (hudChanged) { + this.hudListeners.forEach((listener) => { + listener(snapshot.hud); + }); + } + + if (miniMapChanged) { + this.miniMapListeners.forEach((listener) => { + listener(snapshot.miniMap); + }); + } + this.listeners.forEach((listener) => { listener(snapshot); }); @@ -154,6 +199,8 @@ public clear(): void { this.listeners.clear(); + this.hudListeners.clear(); + this.miniMapListeners.clear(); this.lastState = null; } } diff --git a/apps/client/src/scenes/game/hooks/useGameSceneController.ts b/apps/client/src/scenes/game/hooks/useGameSceneController.ts index f285bdd..aabba96 100644 --- a/apps/client/src/scenes/game/hooks/useGameSceneController.ts +++ b/apps/client/src/scenes/game/hooks/useGameSceneController.ts @@ -3,8 +3,12 @@ * ゲーム画面の状態管理と GameManager 連携を担うフック * Pixi描画領域,残り時間表示,入力橋渡しを提供する */ -import { useCallback, useEffect, useRef, useState } from "react"; -import { GameManager } from "@client/scenes/game/GameManager"; +import { useCallback, useEffect, useReducer, useRef } from "react"; +import { + GameManager, + type GameHudState, + type MiniMapState, +} from "@client/scenes/game/GameManager"; import { config } from "@client/config"; import { buildStartCountdownText, @@ -19,26 +23,72 @@ config.GAME_CONFIG.GRID_COLS * config.GAME_CONFIG.GRID_ROWS, ).fill(-1); +type SceneControllerState = { + timeLeft: string; + startCountdownText: string | null; + isInputEnabled: boolean; + teamPaintRates: number[]; + miniMapTeamIds: number[]; + localBombHitCount: number; + localPlayerPosition: { x: number; y: number } | null; +}; + +type SceneControllerAction = + | { type: "syncHud"; payload: GameHudState } + | { type: "syncMiniMap"; payload: MiniMapState } + | { type: "reset" }; + +const INITIAL_SCENE_CONTROLLER_STATE: SceneControllerState = { + timeLeft: getInitialTimeDisplay(), + startCountdownText: null, + isInputEnabled: false, + teamPaintRates: DEFAULT_TEAM_PAINT_RATES, + miniMapTeamIds: DEFAULT_MINIMAP_TEAM_IDS, + localBombHitCount: 0, + localPlayerPosition: null, +}; + +const sceneControllerReducer = ( + state: SceneControllerState, + action: SceneControllerAction, +): SceneControllerState => { + switch (action.type) { + case "syncHud": { + const hud = action.payload; + return { + ...state, + timeLeft: formatRemainingTime(hud.remainingTimeSec), + startCountdownText: buildStartCountdownText(hud.startCountdownSec), + isInputEnabled: hud.isInputEnabled, + teamPaintRates: hud.teamPaintRates, + localBombHitCount: hud.localBombHitCount, + }; + } + case "syncMiniMap": { + const miniMap = action.payload; + return { + ...state, + miniMapTeamIds: miniMap.teamIds, + localPlayerPosition: miniMap.localPlayerPosition, + }; + } + case "reset": { + return INITIAL_SCENE_CONTROLLER_STATE; + } + default: { + return state; + } + } +}; + /** ゲーム画面の状態と入力ハンドラを提供するフック */ export const useGameSceneController = (myId: string | null) => { const pixiContainerRef = useRef(null); const gameManagerRef = useRef(null); - const [timeLeft, setTimeLeft] = useState(getInitialTimeDisplay()); - const [startCountdownText, setStartCountdownText] = useState( - null, + const [state, dispatch] = useReducer( + sceneControllerReducer, + INITIAL_SCENE_CONTROLLER_STATE, ); - const [isInputEnabled, setIsInputEnabled] = useState(false); - const [teamPaintRates, setTeamPaintRates] = useState( - DEFAULT_TEAM_PAINT_RATES, - ); - const [miniMapTeamIds, setMiniMapTeamIds] = useState( - DEFAULT_MINIMAP_TEAM_IDS, - ); - const [localBombHitCount, setLocalBombHitCount] = useState(0); - const [localPlayerPosition, setLocalPlayerPosition] = useState<{ - x: number; - y: number; - } | null>(null); useEffect(() => { if (!pixiContainerRef.current || !myId) return; @@ -47,36 +97,19 @@ manager.init(); gameManagerRef.current = manager; - const unsubscribeUiState = manager.subscribeUiState((state) => { - const nextDisplay = formatRemainingTime(state.hud.remainingTimeSec); - setTimeLeft((prev) => (prev === nextDisplay ? prev : nextDisplay)); - - const nextCountdown = buildStartCountdownText(state.hud.startCountdownSec); - setStartCountdownText((prev) => - prev === nextCountdown ? prev : nextCountdown, - ); - - const nextInputEnabled = state.hud.isInputEnabled; - setIsInputEnabled((prev) => - prev === nextInputEnabled ? prev : nextInputEnabled, - ); - - setTeamPaintRates(state.hud.teamPaintRates); - setMiniMapTeamIds(state.miniMap.teamIds); - setLocalBombHitCount(state.hud.localBombHitCount); - setLocalPlayerPosition(state.miniMap.localPlayerPosition); + const unsubscribeHud = manager.subscribeHudState((hudState) => { + dispatch({ type: "syncHud", payload: hudState }); + }); + const unsubscribeMiniMap = manager.subscribeMiniMapState((miniMapState) => { + dispatch({ type: "syncMiniMap", payload: miniMapState }); }); return () => { - unsubscribeUiState(); + unsubscribeHud(); + unsubscribeMiniMap(); manager.destroy(); gameManagerRef.current = null; - setStartCountdownText(null); - setIsInputEnabled(false); - setTeamPaintRates(DEFAULT_TEAM_PAINT_RATES); - setMiniMapTeamIds(DEFAULT_MINIMAP_TEAM_IDS); - setLocalBombHitCount(0); - setLocalPlayerPosition(null); + dispatch({ type: "reset" }); }; }, [myId]); @@ -90,13 +123,13 @@ return { pixiContainerRef, - timeLeft, - startCountdownText, - isInputEnabled, - teamPaintRates, - miniMapTeamIds, - localBombHitCount, - localPlayerPosition, + timeLeft: state.timeLeft, + startCountdownText: state.startCountdownText, + isInputEnabled: state.isInputEnabled, + teamPaintRates: state.teamPaintRates, + miniMapTeamIds: state.miniMapTeamIds, + localBombHitCount: state.localBombHitCount, + localPlayerPosition: state.localPlayerPosition, handleInput, handlePlaceBomb, }; 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 43887d0..76a7f05 100644 --- a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx +++ b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx @@ -43,8 +43,12 @@ useImmediatePressHandlers(handleActivate, { stopPropagation: false, }); - const { onPointerDown: onButtonPointerDown, onClick: onButtonClick } = - useImmediatePressHandlers(handleActivate); + const { + onPointerDown: onButtonPointerDown, + onClick: onButtonClick, + onKeyDown: onButtonKeyDown, + onKeyUp: onButtonKeyUp, + } = useImmediatePressHandlers(handleActivate); return (
diff --git a/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx b/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx index 15b5476..e6baaf7 100644 --- a/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx +++ b/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx @@ -31,9 +31,8 @@ const handleToggle = () => { setIsOpen((prev) => !prev); }; - const { onPointerDown, onClick } = useImmediatePressHandlers( - handleToggle, - ); + const { onPointerDown, onClick, onKeyDown, onKeyUp } = + useImmediatePressHandlers(handleToggle); const { canvasRef, markerPosition } = useMiniMapCanvas({ isOpen, frameSizePx: MINIMAP_UI_CONFIG.FRAME_SIZE_PX, @@ -50,6 +49,8 @@ style={buttonStyle} onPointerDown={onPointerDown} onClick={onClick} + onKeyDown={onKeyDown} + onKeyUp={onKeyUp} > {isOpen ? "閉じる" : "ミニマップ"} diff --git a/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts b/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts index e871421..5af1695 100644 --- a/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts +++ b/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts @@ -10,6 +10,10 @@ stopPropagation?: boolean; }; +const isActivationKey = (key: string): boolean => { + return key === "Enter" || key === " " || key === "Spacebar"; +}; + /** pointerdown と click 抑止のハンドラを生成する */ export const useImmediatePressHandlers = ( onPress: () => void, @@ -38,8 +42,39 @@ [stopPropagation], ); + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!isActivationKey(event.key) || event.repeat) { + return; + } + + event.preventDefault(); + if (stopPropagation) { + event.stopPropagation(); + } + onPress(); + }, + [onPress, stopPropagation], + ); + + const onKeyUp = useCallback( + (event: React.KeyboardEvent) => { + if (!isActivationKey(event.key)) { + return; + } + + event.preventDefault(); + if (stopPropagation) { + event.stopPropagation(); + } + }, + [stopPropagation], + ); + return { onPointerDown, onClick, + onKeyDown, + onKeyUp, }; };