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/GameInputOverlay.tsx b/apps/client/src/scenes/game/input/GameInputOverlay.tsx index 4c8dd51..ffdadfd 100644 --- a/apps/client/src/scenes/game/input/GameInputOverlay.tsx +++ b/apps/client/src/scenes/game/input/GameInputOverlay.tsx @@ -8,6 +8,7 @@ import { BombButton } from "./bomb/presentation/BombButton"; import { useBombCooldownClock } from "./bomb/hooks/useBombCooldownClock"; import { buildGameInputOverlayLayerStyle } from "./GameInputOverlay.styles"; +import { useCallback } from "react"; /** 入力UIレイヤーの入力プロパティ */ type GameInputOverlayProps = { @@ -30,7 +31,7 @@ const { cooldownState, markTriggered } = useBombCooldownClock(bombCooldownMs); const layerStyle = buildGameInputOverlayLayerStyle(); - const handlePressBomb = () => { + const handlePressBomb = useCallback(() => { if (!isInputEnabled || !cooldownState.isReady) { return; } @@ -41,7 +42,7 @@ } markTriggered(); - }; + }, [cooldownState.isReady, isInputEnabled, markTriggered, onPlaceBomb]); return (
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..72ff333 100644 --- a/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx +++ b/apps/client/src/scenes/game/input/bomb/presentation/BombButton.tsx @@ -9,6 +9,7 @@ buildBombButtonStyle, } from "./BombButton.styles"; import { useImmediatePressHandlers } from "@client/scenes/game/input/presentation/useImmediatePressHandlers"; +import { useCallback } from "react"; /** 爆弾設置ボタンの入力プロパティ */ export type BombButtonProps = { @@ -31,13 +32,13 @@ const buttonStyle = buildBombButtonStyle(isReady, isFeverTime); const hitAreaStyle = buildBombButtonHitAreaStyle(isReady); - const handleActivate = () => { + const handleActivate = useCallback(() => { if (!isReady) { return; } onPress(); - }; + }, [isReady, onPress]); const { onPointerDown: onHitAreaPointerDown, onClick: onHitAreaClick } = useImmediatePressHandlers(handleActivate, { diff --git a/apps/client/src/scenes/game/input/joystick/common/joystick.types.ts b/apps/client/src/scenes/game/input/joystick/common/joystick.types.ts index 3871b58..fbe2fd6 100644 --- a/apps/client/src/scenes/game/input/joystick/common/joystick.types.ts +++ b/apps/client/src/scenes/game/input/joystick/common/joystick.types.ts @@ -18,6 +18,7 @@ /** useJoystickState に渡す設定型 */ export type UseJoystickStateProps = { maxDist?: number; + onNormalizedInput?: (normalized: NormalizedInput) => void; }; /** useJoystickState が返すUI向けの状態とハンドラ型 */ diff --git a/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts b/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts index db339a1..36664ac 100644 --- a/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts +++ b/apps/client/src/scenes/game/input/joystick/hooks/useJoystickController.ts @@ -12,15 +12,75 @@ } from "../common"; import { JOYSTICK_MIN_MOVEMENT_DELTA, - JOYSTICK_SEND_ZERO_ON_END, } from "../common"; import { useJoystickState } from "./useJoystickState"; +const isJoystickDebugEnabled = (): boolean => { + if (import.meta.env.DEV) { + try { + return window.localStorage.getItem("debug:joystick") !== "0"; + } catch { + return true; + } + } + + try { + return window.localStorage.getItem("debug:joystick") === "1"; + } catch { + return false; + } +}; + +const debugJoystick = (label: string, payload?: unknown): void => { + if (!isJoystickDebugEnabled()) { + return; + } + + if (payload === undefined) { + console.log(`[joystick-controller] ${label}`); + return; + } + + console.log(`[joystick-controller] ${label}`, payload); +}; + /** 入力イベントと通知処理を仲介するフック */ export const useJoystickController = ({ onInput, maxDist, }: UseJoystickControllerProps): UseJoystickControllerReturn => { + const lastEmittedRef = useRef(null); + + const emitInput = useCallback( + (normalized: NormalizedInput) => { + debugJoystick("emit", normalized); + onInput(normalized.x, normalized.y); + }, + [onInput], + ); + + const emitInputIfChanged = useCallback( + (normalized: NormalizedInput) => { + const last = lastEmittedRef.current; + if (last) { + const delta = Math.hypot(normalized.x - last.x, normalized.y - last.y); + if (delta < JOYSTICK_MIN_MOVEMENT_DELTA) { + debugJoystick("skip-small-delta", { + normalized, + last, + delta, + threshold: JOYSTICK_MIN_MOVEMENT_DELTA, + }); + return; + } + } + + emitInput(normalized); + lastEmittedRef.current = normalized; + }, + [emitInput], + ); + const { isMoving, center, @@ -30,56 +90,26 @@ handleMove: baseHandleMove, handleEnd: baseHandleEnd, reset: baseReset, - } = useJoystickState({ maxDist }); - - const lastEmittedRef = useRef(null); - - const emitInput = useCallback( - (normalized: NormalizedInput) => { - onInput(normalized.x, normalized.y); - }, - [onInput], - ); + } = useJoystickState({ maxDist, onNormalizedInput: emitInputIfChanged }); const handleMove = useCallback( (e: JoystickPointerEvent) => { - const normalized = baseHandleMove(e); - if (!normalized) return; - - const last = lastEmittedRef.current; - if (last) { - const delta = Math.hypot(normalized.x - last.x, normalized.y - last.y); - if (delta < JOYSTICK_MIN_MOVEMENT_DELTA) { - return; - } - } - - emitInput(normalized); - lastEmittedRef.current = normalized; + baseHandleMove(e); }, - [baseHandleMove, emitInput], + [baseHandleMove], ); const handleEnd = useCallback( (e: JoystickPointerEvent) => { baseHandleEnd(e); - - if (JOYSTICK_SEND_ZERO_ON_END) { - emitInput({ x: 0, y: 0 }); - lastEmittedRef.current = { x: 0, y: 0 }; - return; - } - - lastEmittedRef.current = null; }, - [baseHandleEnd, emitInput], + [baseHandleEnd], ); const reset = useCallback(() => { baseReset(); - emitInput({ x: 0, y: 0 }); - lastEmittedRef.current = { x: 0, y: 0 }; - }, [baseReset, emitInput]); + emitInputIfChanged({ x: 0, y: 0 }); + }, [baseReset, emitInputIfChanged]); return { isMoving, diff --git a/apps/client/src/scenes/game/input/joystick/hooks/useJoystickState.ts b/apps/client/src/scenes/game/input/joystick/hooks/useJoystickState.ts index 77bf87e..0c32851 100644 --- a/apps/client/src/scenes/game/input/joystick/hooks/useJoystickState.ts +++ b/apps/client/src/scenes/game/input/joystick/hooks/useJoystickState.ts @@ -8,6 +8,7 @@ import { computeJoystick } from "../model/JoystickModel"; import type { JoystickPointerEvent, + NormalizedInput, Point, UseJoystickStateProps, UseJoystickStateReturn, @@ -17,84 +18,332 @@ return { x: e.clientX, y: e.clientY }; }; +const getPointerPoint = (event: PointerEvent): Point => { + return { x: event.clientX, y: event.clientY }; +}; + +const DEADZONE_STREAK_THRESHOLD = 3; +const POINTER_END_CONFIRM_MS = 80; +const DIRECT_END_CONFIRM_MS = 0; + /** ジョイスティック入力状態と入力ハンドラを提供する */ export const useJoystickState = ({ maxDist, + onNormalizedInput, }: UseJoystickStateProps): UseJoystickStateReturn => { const [isMoving, setIsMoving] = useState(false); const [center, setCenter] = useState({ x: 0, y: 0 }); const [knobOffset, setKnobOffset] = useState({ x: 0, y: 0 }); const activePointerIdRef = useRef(null); - const activePointerTargetRef = useRef(null); + const activePointerTypeRef = useRef(null); + const capturedElementRef = useRef(null); + const deadzoneStreakRef = useRef(0); + const pendingEndTimerIdRef = useRef(null); + const pendingEndPointerIdRef = useRef(null); const radius = maxDist ?? MAX_DIST; - const reset = useCallback(() => { - const pointerId = activePointerIdRef.current; - const pointerTarget = activePointerTargetRef.current; - if ( - pointerId !== null - && pointerTarget - && pointerTarget.hasPointerCapture(pointerId) - ) { - pointerTarget.releasePointerCapture(pointerId); + const clearPendingEnd = useCallback(() => { + if (pendingEndTimerIdRef.current !== null) { + window.clearTimeout(pendingEndTimerIdRef.current); + pendingEndTimerIdRef.current = null; } - - activePointerIdRef.current = null; - activePointerTargetRef.current = null; - setIsMoving(false); - setKnobOffset({ x: 0, y: 0 }); + pendingEndPointerIdRef.current = null; }, []); + const reset = useCallback(() => { + clearPendingEnd(); + if ( + capturedElementRef.current !== null && + activePointerIdRef.current !== null && + capturedElementRef.current.hasPointerCapture(activePointerIdRef.current) + ) { + try { + capturedElementRef.current.releasePointerCapture(activePointerIdRef.current); + } catch { + // capture の解放失敗時も入力状態リセットを優先する + } + } + capturedElementRef.current = null; + activePointerIdRef.current = null; + activePointerTypeRef.current = null; + deadzoneStreakRef.current = 0; + setIsMoving(false); + setKnobOffset({ x: 0, y: 0 }); + }, [clearPendingEnd]); + + const confirmPointerEnd = useCallback( + (pointerId: number) => { + if (activePointerIdRef.current !== pointerId) { + return; + } + + onNormalizedInput?.({ x: 0, y: 0 }); + reset(); + }, + [onNormalizedInput, reset], + ); + + const schedulePointerEnd = useCallback( + ( + pointerId: number, + _source: string, + confirmDelayMs: number = POINTER_END_CONFIRM_MS, + ) => { + if (confirmDelayMs <= 0) { + clearPendingEnd(); + confirmPointerEnd(pointerId); + return; + } + + if ( + pendingEndPointerIdRef.current === pointerId && + pendingEndTimerIdRef.current !== null + ) { + return; + } + + clearPendingEnd(); + pendingEndPointerIdRef.current = pointerId; + pendingEndTimerIdRef.current = window.setTimeout(() => { + pendingEndTimerIdRef.current = null; + pendingEndPointerIdRef.current = null; + confirmPointerEnd(pointerId); + }, confirmDelayMs); + }, + [clearPendingEnd, confirmPointerEnd], + ); + + const applyNormalizedInput = useCallback( + ( + normalized: NormalizedInput, + knob: Point, + allowPendingEndCancellation: boolean = true, + ) => { + setKnobOffset(knob); + + if (allowPendingEndCancellation && pendingEndPointerIdRef.current !== null) { + clearPendingEnd(); + } + + onNormalizedInput?.(normalized); + }, + [clearPendingEnd, onNormalizedInput], + ); + const handleStart = useCallback((e: JoystickPointerEvent) => { - if (activePointerIdRef.current !== null) return; + if (activePointerIdRef.current !== null) { + return; + } const point = getClientPoint(e); - if (!point) return; - if (point.x > window.innerWidth / 2) return; + if (!point) { + return; + } + if (point.x > window.innerWidth / 2) { + return; + } activePointerIdRef.current = e.pointerId; - activePointerTargetRef.current = e.currentTarget; - e.currentTarget.setPointerCapture(e.pointerId); + activePointerTypeRef.current = e.pointerType; + try { + e.currentTarget.setPointerCapture(e.pointerId); + capturedElementRef.current = e.currentTarget; + } catch { + capturedElementRef.current = null; + } + deadzoneStreakRef.current = 0; + clearPendingEnd(); setCenter(point); setKnobOffset({ x: 0, y: 0 }); setIsMoving(true); - }, []); + }, [clearPendingEnd]); const handleMove = useCallback( (e: JoystickPointerEvent) => { - if (!isMoving || activePointerIdRef.current !== e.pointerId) return null; + if (!isMoving || activePointerIdRef.current !== e.pointerId) { + return null; + } + const point = getClientPoint(e); - if (!point) return null; + if (!point) { + return null; + } const computed = computeJoystick(center, point, radius); + const allowPendingEndCancellation = + e.pointerType !== "mouse" || e.buttons !== 0; const magnitude = Math.hypot( computed.normalized.x, computed.normalized.y, ); if (magnitude < JOYSTICK_DEADZONE) { - setKnobOffset({ x: 0, y: 0 }); - return { x: 0, y: 0 }; + deadzoneStreakRef.current += 1; + if (deadzoneStreakRef.current < DEADZONE_STREAK_THRESHOLD) { + return null; + } + + const zero = { x: 0, y: 0 }; + applyNormalizedInput(zero, zero, allowPendingEndCancellation); + return zero; } - setKnobOffset(computed.knobOffset); + deadzoneStreakRef.current = 0; + + applyNormalizedInput( + computed.normalized, + computed.knobOffset, + allowPendingEndCancellation, + ); return computed.normalized; }, - [isMoving, center.x, center.y, radius], + [ + applyNormalizedInput, + isMoving, + center.x, + center.y, + radius, + ], + ); + + const handleWindowPointerMove = useCallback( + (event: PointerEvent) => { + if (!isMoving || activePointerIdRef.current !== event.pointerId) { + return; + } + + const point = getPointerPoint(event); + const computed = computeJoystick(center, point, radius); + const allowPendingEndCancellation = + event.pointerType !== "mouse" || event.buttons !== 0; + const magnitude = Math.hypot( + computed.normalized.x, + computed.normalized.y, + ); + + if (magnitude < JOYSTICK_DEADZONE) { + deadzoneStreakRef.current += 1; + if (deadzoneStreakRef.current < DEADZONE_STREAK_THRESHOLD) { + return; + } + + const zero = { x: 0, y: 0 }; + applyNormalizedInput(zero, zero, allowPendingEndCancellation); + return; + } + + deadzoneStreakRef.current = 0; + + applyNormalizedInput( + computed.normalized, + computed.knobOffset, + allowPendingEndCancellation, + ); + }, + [ + applyNormalizedInput, + isMoving, + center.x, + center.y, + radius, + ], ); const handleEnd = useCallback( (e: JoystickPointerEvent) => { - if (activePointerIdRef.current !== e.pointerId) return; + if (activePointerIdRef.current !== e.pointerId) { + return; + } - reset(); + if (e.type === "pointercancel" || e.type === "lostpointercapture") { + schedulePointerEnd( + e.pointerId, + `element-${e.type}`, + DIRECT_END_CONFIRM_MS, + ); + return; + } + + if (e.type !== "pointerup") { + return; + } + + schedulePointerEnd(e.pointerId, "element-pointerup", DIRECT_END_CONFIRM_MS); }, - [reset], + [schedulePointerEnd], ); + const handleWindowPointerEnd = useCallback( + (event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + + if (event.type === "pointercancel") { + schedulePointerEnd( + event.pointerId, + "window-pointercancel", + DIRECT_END_CONFIRM_MS, + ); + return; + } + + if (event.type !== "pointerup") { + return; + } + + schedulePointerEnd( + event.pointerId, + "window-pointerup", + DIRECT_END_CONFIRM_MS, + ); + }, + [schedulePointerEnd], + ); + + const handleWindowMouseUp = useCallback(() => { + if (activePointerTypeRef.current !== "mouse") { + return; + } + + const activePointerId = activePointerIdRef.current; + if (activePointerId === null) { + return; + } + + schedulePointerEnd(activePointerId, "window-mouseup", DIRECT_END_CONFIRM_MS); + }, [schedulePointerEnd]); + + const handleDocumentMouseUp = useCallback(() => { + if (activePointerTypeRef.current !== "mouse") { + return; + } + + const activePointerId = activePointerIdRef.current; + if (activePointerId === null) { + return; + } + + schedulePointerEnd( + activePointerId, + "document-mouseup", + DIRECT_END_CONFIRM_MS, + ); + }, [schedulePointerEnd]); + useEffect(() => { const handleWindowBlur = () => { - reset(); + if (activePointerTypeRef.current !== "mouse") { + return; + } + + const activePointerId = activePointerIdRef.current; + if (activePointerId === null) { + return; + } + + // blur で pointerup を取り逃した場合に備えて,短い遅延で終了を予約する + schedulePointerEnd(activePointerId, "window-blur", POINTER_END_CONFIRM_MS); }; const handleVisibilityChange = () => { @@ -110,7 +359,33 @@ window.removeEventListener("blur", handleWindowBlur); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [reset]); + }, [reset, schedulePointerEnd]); + + useEffect(() => { + if (!isMoving) { + return; + } + + window.addEventListener("pointermove", handleWindowPointerMove, true); + window.addEventListener("pointerup", handleWindowPointerEnd, true); + window.addEventListener("pointercancel", handleWindowPointerEnd, true); + window.addEventListener("mouseup", handleWindowMouseUp, true); + document.addEventListener("mouseup", handleDocumentMouseUp, true); + + return () => { + window.removeEventListener("pointermove", handleWindowPointerMove, true); + window.removeEventListener("pointerup", handleWindowPointerEnd, true); + window.removeEventListener("pointercancel", handleWindowPointerEnd, true); + window.removeEventListener("mouseup", handleWindowMouseUp, true); + document.removeEventListener("mouseup", handleDocumentMouseUp, true); + }; + }, [ + handleDocumentMouseUp, + handleWindowMouseUp, + handleWindowPointerEnd, + handleWindowPointerMove, + isMoving, + ]); return { isMoving, 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..d2831a3 100644 --- a/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx +++ b/apps/client/src/scenes/game/input/minimap/presentation/MiniMapPanel.tsx @@ -3,7 +3,7 @@ * ミニマップの開閉操作と表示を担うプレゼンテーションコンポーネント * 全体マップ枠とローカルプレイヤー現在地のみを描画する */ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { buildMiniMapDotStyle, MINIMAP_CANVAS_STYLE, @@ -28,12 +28,12 @@ }: MiniMapPanelProps) => { const [isOpen, setIsOpen] = useState(false); - const handleToggle = () => { + const handleToggle = useCallback(() => { setIsOpen((prev) => !prev); - }; - const { onPointerDown, onClick } = useImmediatePressHandlers( - handleToggle, - ); + }, []); + const { onPointerDown, onClick } = + useImmediatePressHandlers(handleToggle); + const { canvasRef, markerPosition } = useMiniMapCanvas({ isOpen, frameSizePx: MINIMAP_UI_CONFIG.FRAME_SIZE_PX,