From 1ba980e428be029cd6bd1f9fce0cd602a6c007de Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Mon, 8 Jun 2026 07:32:10 +0200 Subject: [PATCH 1/4] Replace fixed mobile joystick with Stampede-style drag control. Portrait-cover scenes now use horizontal analog drag with an ephemeral indicator at the touch anchor, tap-to-interact, and no on-screen buttons. Co-authored-by: Cursor --- CONTEXT.md | 9 + docs/game-design/player-manual.md | 4 +- src/game/shell/Game.tsx | 75 +---- .../shell/portraitCoverTouchInput.test.ts | 24 ++ src/game/shell/portraitCoverTouchInput.ts | 19 ++ src/game/shell/useGameTouchControls.ts | 261 +++++++++--------- src/shared/i18n/messages/en/navigation.ts | 2 +- 7 files changed, 189 insertions(+), 205 deletions(-) create mode 100644 src/game/shell/portraitCoverTouchInput.test.ts create mode 100644 src/game/shell/portraitCoverTouchInput.ts diff --git a/CONTEXT.md b/CONTEXT.md index e7d59568..9dfe95cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -396,6 +396,15 @@ walking and authored traversal interactions such as climb, descend, drop, lift, bridge, enter, inspect, and recovery helpers. _Avoid_: mini-game controls, one-off arcade movement, required jump platforming +**Portrait-Cover Touch Control**: +Mobile input for side-view portrait-cover Phaser scenes such as the +Exploration Map, Hobbies, Basement, and Ridge. The player drags anywhere on +the game area for horizontal analog movement; a small ephemeral drag indicator +appears at the touch anchor only while dragging. Tap releases interact when the +touch did not become a move. Jump stays desktop-only for these scenes. +_Avoid_: fixed on-screen joystick, permanent jump/interact buttons, swipe-up +jump on mobile, vertical analog stick + **First Playable Interaction Vocabulary**: The small shared verb set for the First Playable Route: move left/right, interact or talk, inspect, enter/exit an Area Interior Pocket when present, diff --git a/docs/game-design/player-manual.md b/docs/game-design/player-manual.md index dd4e5e57..2c599ab5 100644 --- a/docs/game-design/player-manual.md +++ b/docs/game-design/player-manual.md @@ -21,9 +21,9 @@ Current shipped behavior for interactive mode. Concept and future-design notes l ### Mobile / Touch -- **Move:** swipe left or right on the game area. -- **Jump:** swipe up. +- **Move:** drag left or right anywhere on the game area. A small drag indicator appears under your finger while moving. - **Interact:** tap the game area. +- **Jump:** desktop only (up arrow). Not mapped on mobile touch. ## Overworld diff --git a/src/game/shell/Game.tsx b/src/game/shell/Game.tsx index d8be7bea..3a82bf3a 100644 --- a/src/game/shell/Game.tsx +++ b/src/game/shell/Game.tsx @@ -1,6 +1,6 @@ import { useRef } from 'react'; import * as Phaser from 'phaser'; -import { Circle, Hand } from 'lucide-react'; +import { ControlMatDragIndicator } from '@/shared/ui'; import { useGameBridgeCallbacks } from './useGameBridgeCallbacks'; import { useGameTouchControls } from './useGameTouchControls'; import { usePhaserGameBoot } from './usePhaserGameBoot'; @@ -35,7 +35,7 @@ export default function Game({ }: GameProps) { const containerRef = useRef(null); const gameRef = useRef(null); - const shouldUseGestureOverlay = presentationMode === 'portrait-cover'; + const shouldUsePortraitCoverTouch = presentationMode === 'portrait-cover'; const { bridgeRef, stableOnEnterScene, @@ -68,74 +68,21 @@ export default function Game({ return (
- {shouldUseGestureOverlay && ( + {shouldUsePortraitCoverTouch && ( <>
+ - )}
); } - -function ExplorationTouchControls({ - controls -}: { - controls: ReturnType; -}) { - /** - * Temporary Ridge exploration touch UI. - * - * Longer term, mobile controls should use one shared scene-control system instead of each - * mode inventing its own overlay. The target shape is closer to Stampede Sketch: a large - * touch/control mat around the game card with controls that appear under touch or fade when - * idle, so movement has more usable area and buttons do not permanently block the playfield. - */ - const actionButtonClassName = [ - 'pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full border-2 border-[#1a1a1a]/70', - 'bg-[#fbfbf9]/50 text-[#1a1a1a]/82 shadow-[3px_3px_0px_0px_rgba(26,26,26,0.32)] backdrop-blur-[2px]', - 'touch-none active:scale-95 active:bg-[#f3df8b]/62' - ].join(' '); - - return ( -
-
-
-
-
-
-
- - -
-
- ); -} diff --git a/src/game/shell/portraitCoverTouchInput.test.ts b/src/game/shell/portraitCoverTouchInput.test.ts new file mode 100644 index 00000000..fd475002 --- /dev/null +++ b/src/game/shell/portraitCoverTouchInput.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + resolvePortraitCoverHorizontalAxis, + shouldPortraitCoverDragActivate +} from './portraitCoverTouchInput'; + +describe('portraitCoverTouchInput', () => { + it('returns zero inside the movement deadzone', () => { + expect(resolvePortraitCoverHorizontalAxis(4)).toBe(0); + expect(resolvePortraitCoverHorizontalAxis(-4)).toBe(0); + }); + + it('maps horizontal drag delta to a clamped axis', () => { + expect(resolvePortraitCoverHorizontalAxis(22)).toBeCloseTo(0.5); + expect(resolvePortraitCoverHorizontalAxis(-44)).toBe(-1); + expect(resolvePortraitCoverHorizontalAxis(80)).toBe(1); + }); + + it('activates drag only after horizontal threshold', () => { + expect(shouldPortraitCoverDragActivate(10)).toBe(false); + expect(shouldPortraitCoverDragActivate(16)).toBe(true); + expect(shouldPortraitCoverDragActivate(-20)).toBe(true); + }); +}); diff --git a/src/game/shell/portraitCoverTouchInput.ts b/src/game/shell/portraitCoverTouchInput.ts new file mode 100644 index 00000000..c5a7f79b --- /dev/null +++ b/src/game/shell/portraitCoverTouchInput.ts @@ -0,0 +1,19 @@ +export const PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX = 44; +export const PORTRAIT_COVER_TAP_THRESHOLD_PX = 15; +export const PORTRAIT_COVER_MOVEMENT_DEADZONE = 0.18; + +export function resolvePortraitCoverHorizontalAxis( + deltaX: number, + maxDistance = PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX, + deadzone = PORTRAIT_COVER_MOVEMENT_DEADZONE +): number { + const normalized = Math.max(-1, Math.min(1, deltaX / maxDistance)); + return Math.abs(normalized) >= deadzone ? normalized : 0; +} + +export function shouldPortraitCoverDragActivate( + deltaX: number, + threshold = PORTRAIT_COVER_TAP_THRESHOLD_PX +): boolean { + return Math.abs(deltaX) > threshold; +} diff --git a/src/game/shell/useGameTouchControls.ts b/src/game/shell/useGameTouchControls.ts index c17f18cc..e755e936 100644 --- a/src/game/shell/useGameTouchControls.ts +++ b/src/game/shell/useGameTouchControls.ts @@ -1,163 +1,148 @@ -import { useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useReducer, + useRef, + type PointerEvent +} from 'react'; import { bridgeActions } from '@/game/bridge/store'; -import { useTouchGestures } from '@/shared/hooks/useTouchGestures'; -import type { PointerEvent } from 'react'; +import type { ControlMatDragIndicatorState } from '@/shared/ui'; +import { + PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX, + resolvePortraitCoverHorizontalAxis, + shouldPortraitCoverDragActivate +} from './portraitCoverTouchInput'; interface UseGameTouchControlsOptions { isPaused: boolean; } -type TouchDirection = 'left' | 'right' | 'up' | 'down'; - -const JOYSTICK_RADIUS_PX = 48; -const JOYSTICK_KNOB_RADIUS_PX = 34; -const JOYSTICK_DEADZONE = 0.18; +type DragIndicatorAction = + | { type: 'begin'; point: { x: number; y: number } } + | { type: 'move'; point: { x: number; y: number } } + | { type: 'clear' }; + +function dragIndicatorReducer( + state: ControlMatDragIndicatorState | null, + action: DragIndicatorAction +): ControlMatDragIndicatorState | null { + switch (action.type) { + case 'begin': + return { + anchorX: action.point.x, + anchorY: action.point.y, + currentX: action.point.x, + currentY: action.point.y + }; + case 'move': + return state + ? { ...state, currentX: action.point.x, currentY: action.point.y } + : null; + case 'clear': + return null; + } +} export function useGameTouchControls({ isPaused }: UseGameTouchControlsOptions) { - const joystickPointerId = useRef(null); - const [joystickOffset, setJoystickOffset] = useState({ x: 0, y: 0 }); + const activePointerIdRef = useRef(null); + const anchorRef = useRef({ x: 0, y: 0 }); + const hasDraggedRef = useRef(false); + const [dragIndicator, dispatchDragIndicator] = useReducer(dragIndicatorReducer, null); + + const clearMovement = useCallback(() => { + bridgeActions.setTouchDirectional('left', 0); + bridgeActions.setTouchDirectional('right', 0); + }, []); + + const releasePointer = useCallback(() => { + activePointerIdRef.current = null; + hasDraggedRef.current = false; + clearMovement(); + dispatchDragIndicator({ type: 'clear' }); + }, [clearMovement]); + + useEffect(() => { + if (isPaused) { + releasePointer(); + } + }, [isPaused, releasePointer]); - function setDirection(direction: TouchDirection, intensity: number): void { - if (isPaused && intensity > 0) return; - bridgeActions.setTouchDirectional(direction, intensity); - } + const readPoint = useCallback((event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; + }, []); - function setDirectionalAxes(horizontalAxis: number, verticalAxis: number): void { - setDirection('left', Math.max(0, -horizontalAxis)); - setDirection('right', Math.max(0, horizontalAxis)); - setDirection('up', Math.max(0, -verticalAxis)); - setDirection('down', Math.max(0, verticalAxis)); - } + const stopPointer = useCallback((event: PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); - function stopPointer(e: PointerEvent): void { - e.preventDefault(); - e.stopPropagation(); - } + const applyDrag = useCallback((deltaX: number) => { + if (isPaused) return; - const gestureHandlers = useTouchGestures({ - onSwipeLeft: (intensity) => { - setDirection('left', intensity); - setDirection('right', 0); - }, - onSwipeRight: (intensity) => { - setDirection('right', intensity); - setDirection('left', 0); - }, - onSwipeUp: () => { - if (isPaused) return; - bridgeActions.queueJump(); - }, - onSwipeEnd: () => { - setDirection('left', 0); - setDirection('right', 0); - setDirection('up', 0); - setDirection('down', 0); - }, - onTap: () => { + const axisX = resolvePortraitCoverHorizontalAxis(deltaX); + bridgeActions.setTouchDirectional('left', Math.max(0, -axisX)); + bridgeActions.setTouchDirectional('right', Math.max(0, axisX)); + }, [isPaused]); + + const pointerHandlers = { + onPointerDown: useCallback((event: PointerEvent) => { + stopPointer(event); if (isPaused) return; - bridgeActions.tapInteract(); - } - }); - function getDirectionalButtonHandlers(direction: TouchDirection) { - return { - onPointerDown: (e: PointerEvent) => { - stopPointer(e); - e.currentTarget.setPointerCapture(e.pointerId); - setDirection(direction, 1); - }, - onPointerUp: (e: PointerEvent) => { - stopPointer(e); - setDirection(direction, 0); - }, - onPointerCancel: (e: PointerEvent) => { - stopPointer(e); - setDirection(direction, 0); - }, - onPointerLeave: (e: PointerEvent) => { - stopPointer(e); - setDirection(direction, 0); + const point = readPoint(event); + activePointerIdRef.current = event.pointerId; + anchorRef.current = point; + hasDraggedRef.current = false; + event.currentTarget.setPointerCapture(event.pointerId); + dispatchDragIndicator({ type: 'begin', point }); + clearMovement(); + }, [clearMovement, isPaused, readPoint, stopPointer]), + + onPointerMove: useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); + + const point = readPoint(event); + const deltaX = point.x - anchorRef.current.x; + if (shouldPortraitCoverDragActivate(deltaX)) { + hasDraggedRef.current = true; } - }; - } - function updateJoystick(e: PointerEvent): void { - const rect = e.currentTarget.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const rawX = (e.clientX - centerX) / JOYSTICK_RADIUS_PX; - const rawY = (e.clientY - centerY) / JOYSTICK_RADIUS_PX; - const length = Math.hypot(rawX, rawY); - const scale = length > 1 ? 1 / length : 1; - const clampedX = rawX * scale; - const clampedY = rawY * scale; - const active = length >= JOYSTICK_DEADZONE; - const axisX = active ? clampedX : 0; - const axisY = active ? clampedY : 0; - - setDirectionalAxes(axisX, axisY); - setJoystickOffset({ - x: axisX * JOYSTICK_KNOB_RADIUS_PX, - y: axisY * JOYSTICK_KNOB_RADIUS_PX - }); - } + dispatchDragIndicator({ type: 'move', point }); + applyDrag(deltaX); + }, [applyDrag, readPoint, stopPointer]), - function releaseJoystick(): void { - joystickPointerId.current = null; - setDirectionalAxes(0, 0); - setJoystickOffset({ x: 0, y: 0 }); - } + onPointerUp: useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); - const joystickHandlers = { - onPointerDown: (e: PointerEvent) => { - stopPointer(e); - if (isPaused) return; - joystickPointerId.current = e.pointerId; - e.currentTarget.setPointerCapture(e.pointerId); - updateJoystick(e); - }, - onPointerMove: (e: PointerEvent) => { - if (joystickPointerId.current !== e.pointerId) return; - stopPointer(e); - updateJoystick(e); - }, - onPointerUp: (e: PointerEvent) => { - if (joystickPointerId.current !== e.pointerId) return; - stopPointer(e); - releaseJoystick(); - }, - onPointerCancel: (e: PointerEvent) => { - if (joystickPointerId.current !== e.pointerId) return; - stopPointer(e); - releaseJoystick(); - }, - onPointerLeave: (e: PointerEvent) => { - if (joystickPointerId.current !== e.pointerId) return; - stopPointer(e); - releaseJoystick(); - } - }; + if (!isPaused && !hasDraggedRef.current) { + bridgeActions.tapInteract(); + } - const jumpButtonHandlers = { - onPointerDown: (e: PointerEvent) => { - stopPointer(e); - if (!isPaused) bridgeActions.queueJump(); - } - }; + releasePointer(); + }, [isPaused, releasePointer, stopPointer]), - const interactButtonHandlers = { - onPointerDown: (e: PointerEvent) => { - stopPointer(e); - if (!isPaused) bridgeActions.tapInteract(); - } + onPointerCancel: useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); + releasePointer(); + }, [releasePointer, stopPointer]), + + onPointerLeave: useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); + releasePointer(); + }, [releasePointer, stopPointer]) }; return { - gestureHandlers, - getDirectionalButtonHandlers, - joystickHandlers, - joystickOffset, - jumpButtonHandlers, - interactButtonHandlers + dragIndicator, + dragMaxDistance: PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX, + pointerHandlers }; } diff --git a/src/shared/i18n/messages/en/navigation.ts b/src/shared/i18n/messages/en/navigation.ts index 0cacba4b..e24bfd08 100644 --- a/src/shared/i18n/messages/en/navigation.ts +++ b/src/shared/i18n/messages/en/navigation.ts @@ -1,6 +1,6 @@ export const navigationMessages = { hints: "Use A/D or Arrows to walk • Up/Down to climb • Space to jump • Press E to enter", - hintsCompact: "Use D-pad to move and climb • Jump button to hop • Tap game to interact", + hintsCompact: "Drag to walk • Tap to interact", enter: "[E] ENTER", interact: "[E] INTERACT", exit: "EXIT", From f85c40191f5330e0842de859cb22d186ccee0bec Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Mon, 8 Jun 2026 07:36:06 +0200 Subject: [PATCH 2/4] refactor: dedupe drag indicator reducer and drop dead gesture hook Extract reduceControlMatDragIndicator for portrait-cover and control-mat hooks, flatten touch handler return, unexport internal constants, and remove orphaned useTouchGestures. Co-authored-by: Cursor --- src/game/shell/Game.tsx | 7 +- src/game/shell/portraitCoverTouchInput.ts | 6 +- src/game/shell/useGameTouchControls.ts | 132 +++++++----------- .../shell/useSceneControlMatPointerBridge.ts | 30 +--- src/shared/hooks/useTouchGestures.test.ts | 132 ------------------ src/shared/hooks/useTouchGestures.ts | 108 -------------- .../chrome/ControlMatDragIndicator.tsx | 26 ++++ src/shared/ui/NotebookShell/index.ts | 4 +- src/shared/ui/index.ts | 2 + 9 files changed, 94 insertions(+), 353 deletions(-) delete mode 100644 src/shared/hooks/useTouchGestures.test.ts delete mode 100644 src/shared/hooks/useTouchGestures.ts diff --git a/src/game/shell/Game.tsx b/src/game/shell/Game.tsx index 3a82bf3a..aa24710b 100644 --- a/src/game/shell/Game.tsx +++ b/src/game/shell/Game.tsx @@ -74,11 +74,14 @@ export default function Game({ aria-label="Move and interact" className="absolute inset-0 z-10 touch-none md:hidden" role="application" - {...touchControls.pointerHandlers} + onPointerCancel={touchControls.onPointerCancel} + onPointerDown={touchControls.onPointerDown} + onPointerLeave={touchControls.onPointerLeave} + onPointerMove={touchControls.onPointerMove} + onPointerUp={touchControls.onPointerUp} /> diff --git a/src/game/shell/portraitCoverTouchInput.ts b/src/game/shell/portraitCoverTouchInput.ts index c5a7f79b..559f7578 100644 --- a/src/game/shell/portraitCoverTouchInput.ts +++ b/src/game/shell/portraitCoverTouchInput.ts @@ -1,6 +1,6 @@ -export const PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX = 44; -export const PORTRAIT_COVER_TAP_THRESHOLD_PX = 15; -export const PORTRAIT_COVER_MOVEMENT_DEADZONE = 0.18; +const PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX = 44; +const PORTRAIT_COVER_TAP_THRESHOLD_PX = 15; +const PORTRAIT_COVER_MOVEMENT_DEADZONE = 0.18; export function resolvePortraitCoverHorizontalAxis( deltaX: number, diff --git a/src/game/shell/useGameTouchControls.ts b/src/game/shell/useGameTouchControls.ts index e755e936..cef32dca 100644 --- a/src/game/shell/useGameTouchControls.ts +++ b/src/game/shell/useGameTouchControls.ts @@ -6,9 +6,8 @@ import { type PointerEvent } from 'react'; import { bridgeActions } from '@/game/bridge/store'; -import type { ControlMatDragIndicatorState } from '@/shared/ui'; +import { reduceControlMatDragIndicator } from '@/shared/ui'; import { - PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX, resolvePortraitCoverHorizontalAxis, shouldPortraitCoverDragActivate } from './portraitCoverTouchInput'; @@ -17,37 +16,11 @@ interface UseGameTouchControlsOptions { isPaused: boolean; } -type DragIndicatorAction = - | { type: 'begin'; point: { x: number; y: number } } - | { type: 'move'; point: { x: number; y: number } } - | { type: 'clear' }; - -function dragIndicatorReducer( - state: ControlMatDragIndicatorState | null, - action: DragIndicatorAction -): ControlMatDragIndicatorState | null { - switch (action.type) { - case 'begin': - return { - anchorX: action.point.x, - anchorY: action.point.y, - currentX: action.point.x, - currentY: action.point.y - }; - case 'move': - return state - ? { ...state, currentX: action.point.x, currentY: action.point.y } - : null; - case 'clear': - return null; - } -} - export function useGameTouchControls({ isPaused }: UseGameTouchControlsOptions) { const activePointerIdRef = useRef(null); const anchorRef = useRef({ x: 0, y: 0 }); const hasDraggedRef = useRef(false); - const [dragIndicator, dispatchDragIndicator] = useReducer(dragIndicatorReducer, null); + const [dragIndicator, dispatchDragIndicator] = useReducer(reduceControlMatDragIndicator, null); const clearMovement = useCallback(() => { bridgeActions.setTouchDirectional('left', 0); @@ -88,61 +61,62 @@ export function useGameTouchControls({ isPaused }: UseGameTouchControlsOptions) bridgeActions.setTouchDirectional('right', Math.max(0, axisX)); }, [isPaused]); - const pointerHandlers = { - onPointerDown: useCallback((event: PointerEvent) => { - stopPointer(event); - if (isPaused) return; - - const point = readPoint(event); - activePointerIdRef.current = event.pointerId; - anchorRef.current = point; - hasDraggedRef.current = false; - event.currentTarget.setPointerCapture(event.pointerId); - dispatchDragIndicator({ type: 'begin', point }); - clearMovement(); - }, [clearMovement, isPaused, readPoint, stopPointer]), - - onPointerMove: useCallback((event: PointerEvent) => { - if (activePointerIdRef.current !== event.pointerId) return; - stopPointer(event); - - const point = readPoint(event); - const deltaX = point.x - anchorRef.current.x; - if (shouldPortraitCoverDragActivate(deltaX)) { - hasDraggedRef.current = true; - } - - dispatchDragIndicator({ type: 'move', point }); - applyDrag(deltaX); - }, [applyDrag, readPoint, stopPointer]), - - onPointerUp: useCallback((event: PointerEvent) => { - if (activePointerIdRef.current !== event.pointerId) return; - stopPointer(event); - - if (!isPaused && !hasDraggedRef.current) { - bridgeActions.tapInteract(); - } + const onPointerDown = useCallback((event: PointerEvent) => { + stopPointer(event); + if (isPaused) return; - releasePointer(); - }, [isPaused, releasePointer, stopPointer]), + const point = readPoint(event); + activePointerIdRef.current = event.pointerId; + anchorRef.current = point; + hasDraggedRef.current = false; + event.currentTarget.setPointerCapture(event.pointerId); + dispatchDragIndicator({ type: 'begin', point }); + clearMovement(); + }, [clearMovement, isPaused, readPoint, stopPointer]); - onPointerCancel: useCallback((event: PointerEvent) => { - if (activePointerIdRef.current !== event.pointerId) return; - stopPointer(event); - releasePointer(); - }, [releasePointer, stopPointer]), + const onPointerMove = useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); - onPointerLeave: useCallback((event: PointerEvent) => { - if (activePointerIdRef.current !== event.pointerId) return; - stopPointer(event); - releasePointer(); - }, [releasePointer, stopPointer]) - }; + const point = readPoint(event); + const deltaX = point.x - anchorRef.current.x; + if (shouldPortraitCoverDragActivate(deltaX)) { + hasDraggedRef.current = true; + } + + dispatchDragIndicator({ type: 'move', point }); + applyDrag(deltaX); + }, [applyDrag, readPoint, stopPointer]); + + const onPointerUp = useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); + + if (!isPaused && !hasDraggedRef.current) { + bridgeActions.tapInteract(); + } + + releasePointer(); + }, [isPaused, releasePointer, stopPointer]); + + const onPointerCancel = useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); + releasePointer(); + }, [releasePointer, stopPointer]); + + const onPointerLeave = useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); + releasePointer(); + }, [releasePointer, stopPointer]); return { dragIndicator, - dragMaxDistance: PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX, - pointerHandlers + onPointerCancel, + onPointerDown, + onPointerLeave, + onPointerMove, + onPointerUp }; } diff --git a/src/game/shell/useSceneControlMatPointerBridge.ts b/src/game/shell/useSceneControlMatPointerBridge.ts index edac6cb4..e54eb37c 100644 --- a/src/game/shell/useSceneControlMatPointerBridge.ts +++ b/src/game/shell/useSceneControlMatPointerBridge.ts @@ -5,7 +5,7 @@ import { } from '@/game/bridge/store'; import { GAME_DESIGN_HEIGHT, GAME_DESIGN_WIDTH } from '@/game/sharedSceneRuntime/designSize'; import type { SceneId } from '@/game/scenes/sceneIds'; -import type { ControlMatDragIndicatorState } from '@/shared/ui'; +import { reduceControlMatDragIndicator } from '@/shared/ui'; interface UseSceneControlMatPointerBridgeOptions { disabled: boolean; @@ -25,7 +25,7 @@ export function useSceneControlMatPointerBridge({ const lastPointerEventRef = useRef(null); const pendingMoveRef = useRef(null); const pendingMoveFrameRef = useRef(null); - const [pointerVisual, dispatchPointerVisual] = useReducer(pointerVisualReducer, null); + const [pointerVisual, dispatchPointerVisual] = useReducer(reduceControlMatDragIndicator, null); const dispatchPointer = useCallback(( kind: SceneControlPointerEventKind, @@ -230,32 +230,6 @@ function releasePointerCapture(target: HTMLElement | null, pointerId: number): v } } -type PointerVisualAction = - | { type: 'begin'; point: { x: number; y: number } } - | { type: 'move'; point: { x: number; y: number } } - | { type: 'clear' }; - -function pointerVisualReducer( - state: ControlMatDragIndicatorState | null, - action: PointerVisualAction -): ControlMatDragIndicatorState | null { - switch (action.type) { - case 'begin': - return { - anchorX: action.point.x, - anchorY: action.point.y, - currentX: action.point.x, - currentY: action.point.y - }; - case 'move': - return state - ? { ...state, currentX: action.point.x, currentY: action.point.y } - : null; - case 'clear': - return null; - } -} - function shouldIgnoreControlMatEvent(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; return Boolean( diff --git a/src/shared/hooks/useTouchGestures.test.ts b/src/shared/hooks/useTouchGestures.test.ts deleted file mode 100644 index bb4d9e49..00000000 --- a/src/shared/hooks/useTouchGestures.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { renderHook, act } from '@testing-library/react'; -import { useTouchGestures } from './useTouchGestures'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -describe('useTouchGestures', () => { - const callbacks = { - onSwipeLeft: vi.fn(), - onSwipeRight: vi.fn(), - onSwipeUp: vi.fn(), - onSwipeEnd: vi.fn(), - onTap: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - const setup = () => { - const { result } = renderHook(() => useTouchGestures(callbacks)); - return { ...callbacks, result }; - }; - - it('triggers swipe left with intensity when moving horizontally', () => { - const { onSwipeLeft, result } = setup(); - - act(() => { - result.current.onPointerDown({ clientX: 100, clientY: 100 } as any); - result.current.onPointerMove({ clientX: 50, clientY: 100 } as any); // deltaX: -50 - }); - - expect(onSwipeLeft).toHaveBeenCalledWith(expect.any(Number)); - const intensity = onSwipeLeft.mock.calls[0][0]; - expect(intensity).toBeGreaterThan(0.2); - expect(intensity).toBeLessThan(1.0); - }); - - it('triggers swipe up when moving vertically', () => { - const { onSwipeUp, result } = setup(); - - act(() => { - result.current.onPointerDown({ clientX: 100, clientY: 100 } as any); - result.current.onPointerMove({ clientX: 100, clientY: 50 } as any); // deltaY: -50 - }); - - expect(onSwipeUp).toHaveBeenCalled(); - }); - - it('allows simultaneous swipe up and swipe left with intensity (running jump)', () => { - const { onSwipeUp, onSwipeLeft, result } = setup(); - - act(() => { - result.current.onPointerDown({ clientX: 100, clientY: 100 } as any); - }); - - // Move diagonally: deltaX = -50, deltaY = -35 - // Previously: abs(deltaY) (35) <= abs(deltaX) (50) would NOT trigger jump. - // Now: deltaY (-35) < -30 triggers jump. - act(() => { - result.current.onPointerMove({ clientX: 50, clientY: 65 } as any); - }); - - expect(onSwipeUp).toHaveBeenCalled(); - expect(onSwipeLeft).toHaveBeenCalledWith(expect.any(Number)); - }); - - it('allows multiple jumps during a single drag by resetting hasJumped', () => { - const { onSwipeUp, result } = setup(); - - act(() => { - result.current.onPointerDown({ clientX: 100, clientY: 100 } as any); - }); - - // Jump 1 - act(() => { - result.current.onPointerMove({ clientX: 100, clientY: 65 } as any); // deltaY: -35 - }); - expect(onSwipeUp).toHaveBeenCalledTimes(1); - - // Reset jump by moving finger back down - act(() => { - result.current.onPointerMove({ clientX: 100, clientY: 90 } as any); // deltaY: -10 (> -15) - }); - - // Jump 2 - act(() => { - result.current.onPointerMove({ clientX: 100, clientY: 65 } as any); // deltaY: -35 - }); - expect(onSwipeUp).toHaveBeenCalledTimes(2); - }); - - it('does not trigger tap if a jump occurred', () => { - const { onSwipeUp, onTap, result } = setup(); - - act(() => { - result.current.onPointerDown({ clientX: 100, clientY: 100 } as any); - result.current.onPointerMove({ clientX: 100, clientY: 50 } as any); // deltaY: -50 - }); - - expect(onSwipeUp).toHaveBeenCalled(); - - act(() => { - result.current.onPointerUp(); - }); - - expect(onTap).not.toHaveBeenCalled(); - }); - - it('triggers tap if no swipe or jump occurred', () => { - const { onTap, result } = setup(); - - act(() => { - result.current.onPointerDown({ clientX: 100, clientY: 100 } as any); - result.current.onPointerUp(); - }); - - expect(onTap).toHaveBeenCalled(); - }); - - it('triggers swipe end on pointer up', () => { - const { onSwipeEnd, result } = setup(); - - act(() => { - result.current.onPointerDown({ clientX: 100, clientY: 100 } as any); - result.current.onPointerUp(); - }); - - expect(onSwipeEnd).toHaveBeenCalled(); - }); -}); diff --git a/src/shared/hooks/useTouchGestures.ts b/src/shared/hooks/useTouchGestures.ts deleted file mode 100644 index a1b73c57..00000000 --- a/src/shared/hooks/useTouchGestures.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { useRef, useCallback } from 'react'; - -interface GestureCallbacks { - onSwipeLeft: (intensity: number) => void; - onSwipeRight: (intensity: number) => void; - onSwipeUp: () => void; - onSwipeEnd: () => void; - onTap: () => void; -} - -const THRESHOLD = 15; // px -const MAX_DRAG = 60; // px -const MIN_INTENSITY = 0.2; -const JUMP_TRIGGER_Y = -30; // px relative to touch start -const JUMP_RESET_Y = -15; // px relative to touch start - -/** - * A custom hook to detect mobile gestures: swipes (L/R/Up) and taps. - * Architecturally separates gesture recognition from game logic. - */ -export function useTouchGestures({ - onSwipeLeft, - onSwipeRight, - onSwipeUp, - onSwipeEnd, - onTap, -}: GestureCallbacks) { - const dragRef = useRef({ - startX: 0, - startY: 0, - hasSwiped: false, - hasJumped: false, - isActive: false, - }); - - const onPointerDown = useCallback((e: React.PointerEvent) => { - dragRef.current = { - startX: e.clientX, - startY: e.clientY, - hasSwiped: false, - hasJumped: false, - isActive: true, - }; - }, []); - - const onPointerMove = useCallback( - (e: React.PointerEvent) => { - const state = dragRef.current; - if (!state.isActive) return; - - const deltaX = e.clientX - state.startX; - const deltaY = e.clientY - state.startY; - - // Handle jump gesture independently - if (deltaY < JUMP_TRIGGER_Y) { - if (!state.hasJumped) { - onSwipeUp(); - state.hasJumped = true; - } - } else if (deltaY > JUMP_RESET_Y) { - // Reset jump flag if they bring their finger back down - state.hasJumped = false; - } - - // Handle horizontal swipe with dynamic intensity - if (Math.abs(deltaX) > THRESHOLD) { - state.hasSwiped = true; - const dragRange = MAX_DRAG - THRESHOLD; - // Calculate intensity between 0.2 and 1.0 - const rawIntensity = dragRange > 0 ? (Math.abs(deltaX) - THRESHOLD) / dragRange : 1; - const intensity = Math.min(Math.max(rawIntensity, MIN_INTENSITY), 1); - - if (deltaX > 0) { - onSwipeRight(intensity); - } else { - onSwipeLeft(intensity); - } - } else if (state.hasSwiped) { - // If we were swiping horizontally but moved back to neutral zone - onSwipeEnd(); - } - }, - [onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeEnd] - ); - - const onPointerUp = useCallback(() => { - const state = dragRef.current; - if (!state.isActive) return; - - if (!state.hasSwiped && !state.hasJumped) { - onTap(); - } - - state.isActive = false; - onSwipeEnd(); - }, [onTap, onSwipeEnd]); - - const onPointerCancel = onPointerUp; - const onPointerLeave = onPointerUp; - - return { - onPointerDown, - onPointerMove, - onPointerUp, - onPointerCancel, - onPointerLeave, - }; -} diff --git a/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx b/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx index 64e7540c..b2deb512 100644 --- a/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx +++ b/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx @@ -8,6 +8,32 @@ export interface ControlMatDragIndicatorState { currentY: number; } +export type ControlMatDragIndicatorAction = + | { type: 'begin'; point: { x: number; y: number } } + | { type: 'move'; point: { x: number; y: number } } + | { type: 'clear' }; + +export function reduceControlMatDragIndicator( + state: ControlMatDragIndicatorState | null, + action: ControlMatDragIndicatorAction +): ControlMatDragIndicatorState | null { + switch (action.type) { + case 'begin': + return { + anchorX: action.point.x, + anchorY: action.point.y, + currentX: action.point.x, + currentY: action.point.y + }; + case 'move': + return state + ? { ...state, currentX: action.point.x, currentY: action.point.y } + : null; + case 'clear': + return null; + } +} + interface ControlMatDragIndicatorProps extends HTMLAttributes { maxDistance?: number; state: ControlMatDragIndicatorState | null; diff --git a/src/shared/ui/NotebookShell/index.ts b/src/shared/ui/NotebookShell/index.ts index 7a618b71..98e35897 100644 --- a/src/shared/ui/NotebookShell/index.ts +++ b/src/shared/ui/NotebookShell/index.ts @@ -2,9 +2,11 @@ export { ControlMat } from './chrome/ControlMat'; export { - ControlMatDragIndicator + ControlMatDragIndicator, + reduceControlMatDragIndicator } from './chrome/ControlMatDragIndicator'; export type { + ControlMatDragIndicatorAction, ControlMatDragIndicatorState } from './chrome/ControlMatDragIndicator'; export { diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 3f678179..28929858 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -8,6 +8,7 @@ export { ModalShell } from './ModalShell'; export { ControlMat, ControlMatDragIndicator, + reduceControlMatDragIndicator, NotebookMenuSheet, NotebookHeaderChrome, NotebookPageFrame, @@ -21,6 +22,7 @@ export { SceneStatusSlip } from './NotebookShell'; export type { + ControlMatDragIndicatorAction, ControlMatDragIndicatorState, NotebookChoiceTone, NotebookFooterMode, From b75aa86c9add0903045dd122815912719357c9b5 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Mon, 8 Jun 2026 07:37:28 +0200 Subject: [PATCH 3/4] fix: harden portrait-cover pointer tracking for review feedback Cache drag anchor on pointer down, ignore secondary touches, and treat vertical travel as drag so release does not accidentally tap interact. Co-authored-by: Cursor --- .../shell/portraitCoverTouchInput.test.ts | 9 ++- src/game/shell/portraitCoverTouchInput.ts | 9 +++ src/game/shell/useGameTouchControls.ts | 63 +++++++++++++------ 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/game/shell/portraitCoverTouchInput.test.ts b/src/game/shell/portraitCoverTouchInput.test.ts index fd475002..a797d6d6 100644 --- a/src/game/shell/portraitCoverTouchInput.test.ts +++ b/src/game/shell/portraitCoverTouchInput.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { resolvePortraitCoverHorizontalAxis, - shouldPortraitCoverDragActivate + shouldPortraitCoverDragActivate, + shouldPortraitCoverPointerTravelActivate } from './portraitCoverTouchInput'; describe('portraitCoverTouchInput', () => { @@ -21,4 +22,10 @@ describe('portraitCoverTouchInput', () => { expect(shouldPortraitCoverDragActivate(16)).toBe(true); expect(shouldPortraitCoverDragActivate(-20)).toBe(true); }); + + it('activates pointer travel when either axis crosses threshold', () => { + expect(shouldPortraitCoverPointerTravelActivate(10, 10)).toBe(false); + expect(shouldPortraitCoverPointerTravelActivate(16, 0)).toBe(true); + expect(shouldPortraitCoverPointerTravelActivate(0, 16)).toBe(true); + }); }); diff --git a/src/game/shell/portraitCoverTouchInput.ts b/src/game/shell/portraitCoverTouchInput.ts index 559f7578..e7d67bf6 100644 --- a/src/game/shell/portraitCoverTouchInput.ts +++ b/src/game/shell/portraitCoverTouchInput.ts @@ -17,3 +17,12 @@ export function shouldPortraitCoverDragActivate( ): boolean { return Math.abs(deltaX) > threshold; } + +export function shouldPortraitCoverPointerTravelActivate( + deltaX: number, + deltaY: number, + threshold = PORTRAIT_COVER_TAP_THRESHOLD_PX +): boolean { + return shouldPortraitCoverDragActivate(deltaX, threshold) + || shouldPortraitCoverDragActivate(deltaY, threshold); +} diff --git a/src/game/shell/useGameTouchControls.ts b/src/game/shell/useGameTouchControls.ts index cef32dca..ff4044a9 100644 --- a/src/game/shell/useGameTouchControls.ts +++ b/src/game/shell/useGameTouchControls.ts @@ -9,16 +9,28 @@ import { bridgeActions } from '@/game/bridge/store'; import { reduceControlMatDragIndicator } from '@/shared/ui'; import { resolvePortraitCoverHorizontalAxis, - shouldPortraitCoverDragActivate + shouldPortraitCoverPointerTravelActivate } from './portraitCoverTouchInput'; interface UseGameTouchControlsOptions { isPaused: boolean; } +interface DragStartSnapshot { + clientX: number; + clientY: number; + containerX: number; + containerY: number; +} + export function useGameTouchControls({ isPaused }: UseGameTouchControlsOptions) { const activePointerIdRef = useRef(null); - const anchorRef = useRef({ x: 0, y: 0 }); + const dragStartRef = useRef({ + clientX: 0, + clientY: 0, + containerX: 0, + containerY: 0 + }); const hasDraggedRef = useRef(false); const [dragIndicator, dispatchDragIndicator] = useReducer(reduceControlMatDragIndicator, null); @@ -40,14 +52,6 @@ export function useGameTouchControls({ isPaused }: UseGameTouchControlsOptions) } }, [isPaused, releasePointer]); - const readPoint = useCallback((event: PointerEvent) => { - const rect = event.currentTarget.getBoundingClientRect(); - return { - x: event.clientX - rect.left, - y: event.clientY - rect.top - }; - }, []); - const stopPointer = useCallback((event: PointerEvent) => { event.preventDefault(); event.stopPropagation(); @@ -63,30 +67,49 @@ export function useGameTouchControls({ isPaused }: UseGameTouchControlsOptions) const onPointerDown = useCallback((event: PointerEvent) => { stopPointer(event); - if (isPaused) return; + if (isPaused || activePointerIdRef.current !== null) return; - const point = readPoint(event); + const rect = event.currentTarget.getBoundingClientRect(); + const containerX = event.clientX - rect.left; + const containerY = event.clientY - rect.top; + + dragStartRef.current = { + clientX: event.clientX, + clientY: event.clientY, + containerX, + containerY + }; activePointerIdRef.current = event.pointerId; - anchorRef.current = point; hasDraggedRef.current = false; event.currentTarget.setPointerCapture(event.pointerId); - dispatchDragIndicator({ type: 'begin', point }); + dispatchDragIndicator({ + type: 'begin', + point: { x: containerX, y: containerY } + }); clearMovement(); - }, [clearMovement, isPaused, readPoint, stopPointer]); + }, [clearMovement, isPaused, stopPointer]); const onPointerMove = useCallback((event: PointerEvent) => { if (activePointerIdRef.current !== event.pointerId) return; stopPointer(event); - const point = readPoint(event); - const deltaX = point.x - anchorRef.current.x; - if (shouldPortraitCoverDragActivate(deltaX)) { + const start = dragStartRef.current; + const deltaX = event.clientX - start.clientX; + const deltaY = event.clientY - start.clientY; + + if (shouldPortraitCoverPointerTravelActivate(deltaX, deltaY)) { hasDraggedRef.current = true; } - dispatchDragIndicator({ type: 'move', point }); + dispatchDragIndicator({ + type: 'move', + point: { + x: start.containerX + deltaX, + y: start.containerY + deltaY + } + }); applyDrag(deltaX); - }, [applyDrag, readPoint, stopPointer]); + }, [applyDrag, stopPointer]); const onPointerUp = useCallback((event: PointerEvent) => { if (activePointerIdRef.current !== event.pointerId) return; From b145224cf19f84056181377facf88c314f755972 Mon Sep 17 00:00:00 2001 From: DaniloNovakovic Date: Mon, 8 Jun 2026 07:42:24 +0200 Subject: [PATCH 4/4] fix: move drag indicator reducer out of component file Satisfies react-refresh/only-export-components by colocating reduceControlMatDragIndicator in controlMatDragIndicatorState.ts. Co-authored-by: Cursor --- .../chrome/ControlMatDragIndicator.tsx | 34 ++----------------- .../chrome/controlMatDragIndicatorState.ts | 32 +++++++++++++++++ src/shared/ui/NotebookShell/index.ts | 8 +++-- 3 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 src/shared/ui/NotebookShell/chrome/controlMatDragIndicatorState.ts diff --git a/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx b/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx index b2deb512..5afa86dd 100644 --- a/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx +++ b/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx @@ -1,38 +1,8 @@ import type { HTMLAttributes } from 'react'; import { cn } from '../../utils'; +import type { ControlMatDragIndicatorState } from './controlMatDragIndicatorState'; -export interface ControlMatDragIndicatorState { - anchorX: number; - anchorY: number; - currentX: number; - currentY: number; -} - -export type ControlMatDragIndicatorAction = - | { type: 'begin'; point: { x: number; y: number } } - | { type: 'move'; point: { x: number; y: number } } - | { type: 'clear' }; - -export function reduceControlMatDragIndicator( - state: ControlMatDragIndicatorState | null, - action: ControlMatDragIndicatorAction -): ControlMatDragIndicatorState | null { - switch (action.type) { - case 'begin': - return { - anchorX: action.point.x, - anchorY: action.point.y, - currentX: action.point.x, - currentY: action.point.y - }; - case 'move': - return state - ? { ...state, currentX: action.point.x, currentY: action.point.y } - : null; - case 'clear': - return null; - } -} +export type { ControlMatDragIndicatorState } from './controlMatDragIndicatorState'; interface ControlMatDragIndicatorProps extends HTMLAttributes { maxDistance?: number; diff --git a/src/shared/ui/NotebookShell/chrome/controlMatDragIndicatorState.ts b/src/shared/ui/NotebookShell/chrome/controlMatDragIndicatorState.ts new file mode 100644 index 00000000..5b82e336 --- /dev/null +++ b/src/shared/ui/NotebookShell/chrome/controlMatDragIndicatorState.ts @@ -0,0 +1,32 @@ +export interface ControlMatDragIndicatorState { + anchorX: number; + anchorY: number; + currentX: number; + currentY: number; +} + +export type ControlMatDragIndicatorAction = + | { type: 'begin'; point: { x: number; y: number } } + | { type: 'move'; point: { x: number; y: number } } + | { type: 'clear' }; + +export function reduceControlMatDragIndicator( + state: ControlMatDragIndicatorState | null, + action: ControlMatDragIndicatorAction +): ControlMatDragIndicatorState | null { + switch (action.type) { + case 'begin': + return { + anchorX: action.point.x, + anchorY: action.point.y, + currentX: action.point.x, + currentY: action.point.y + }; + case 'move': + return state + ? { ...state, currentX: action.point.x, currentY: action.point.y } + : null; + case 'clear': + return null; + } +} diff --git a/src/shared/ui/NotebookShell/index.ts b/src/shared/ui/NotebookShell/index.ts index 98e35897..b6c4c225 100644 --- a/src/shared/ui/NotebookShell/index.ts +++ b/src/shared/ui/NotebookShell/index.ts @@ -2,13 +2,15 @@ export { ControlMat } from './chrome/ControlMat'; export { - ControlMatDragIndicator, - reduceControlMatDragIndicator + ControlMatDragIndicator } from './chrome/ControlMatDragIndicator'; +export { + reduceControlMatDragIndicator +} from './chrome/controlMatDragIndicatorState'; export type { ControlMatDragIndicatorAction, ControlMatDragIndicatorState -} from './chrome/ControlMatDragIndicator'; +} from './chrome/controlMatDragIndicatorState'; export { NotebookHeaderChrome } from './chrome/NotebookHeaderChrome';