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 76a7f05..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,24 +32,20 @@ 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, { stopPropagation: false, }); - const { - onPointerDown: onButtonPointerDown, - onClick: onButtonClick, - onKeyDown: onButtonKeyDown, - onKeyUp: onButtonKeyUp, - } = useImmediatePressHandlers(handleActivate); + const { onPointerDown: onButtonPointerDown, onClick: onButtonClick } = + useImmediatePressHandlers(handleActivate); return (
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 e6baaf7..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,11 +28,12 @@ }: MiniMapPanelProps) => { const [isOpen, setIsOpen] = useState(false); - const handleToggle = () => { + const handleToggle = useCallback(() => { setIsOpen((prev) => !prev); - }; - const { onPointerDown, onClick, onKeyDown, onKeyUp } = + }, []); + const { onPointerDown, onClick } = useImmediatePressHandlers(handleToggle); + const { canvasRef, markerPosition } = useMiniMapCanvas({ isOpen, frameSizePx: MINIMAP_UI_CONFIG.FRAME_SIZE_PX, @@ -49,8 +50,6 @@ 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 5af1695..e871421 100644 --- a/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts +++ b/apps/client/src/scenes/game/input/presentation/useImmediatePressHandlers.ts @@ -10,10 +10,6 @@ stopPropagation?: boolean; }; -const isActivationKey = (key: string): boolean => { - return key === "Enter" || key === " " || key === "Spacebar"; -}; - /** pointerdown と click 抑止のハンドラを生成する */ export const useImmediatePressHandlers = ( onPress: () => void, @@ -42,39 +38,8 @@ [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, }; };