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,
};
};