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..aa24710b 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,24 @@ 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..a797d6d6 --- /dev/null +++ b/src/game/shell/portraitCoverTouchInput.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + resolvePortraitCoverHorizontalAxis, + shouldPortraitCoverDragActivate, + shouldPortraitCoverPointerTravelActivate +} 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); + }); + + 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 new file mode 100644 index 00000000..e7d67bf6 --- /dev/null +++ b/src/game/shell/portraitCoverTouchInput.ts @@ -0,0 +1,28 @@ +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, + 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; +} + +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 c17f18cc..ff4044a9 100644 --- a/src/game/shell/useGameTouchControls.ts +++ b/src/game/shell/useGameTouchControls.ts @@ -1,163 +1,145 @@ -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 { reduceControlMatDragIndicator } from '@/shared/ui'; +import { + resolvePortraitCoverHorizontalAxis, + shouldPortraitCoverPointerTravelActivate +} 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; +interface DragStartSnapshot { + clientX: number; + clientY: number; + containerX: number; + containerY: number; +} export function useGameTouchControls({ isPaused }: UseGameTouchControlsOptions) { - const joystickPointerId = useRef(null); - const [joystickOffset, setJoystickOffset] = useState({ x: 0, y: 0 }); - - function setDirection(direction: TouchDirection, intensity: number): void { - if (isPaused && intensity > 0) return; - bridgeActions.setTouchDirectional(direction, intensity); - } - - 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)); - } - - function stopPointer(e: PointerEvent): void { - e.preventDefault(); - e.stopPropagation(); - } - - 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: () => { - if (isPaused) return; - bridgeActions.tapInteract(); - } + const activePointerIdRef = useRef(null); + const dragStartRef = useRef({ + clientX: 0, + clientY: 0, + containerX: 0, + containerY: 0 }); - - 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 hasDraggedRef = useRef(false); + const [dragIndicator, dispatchDragIndicator] = useReducer(reduceControlMatDragIndicator, 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]); + + const stopPointer = useCallback((event: PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + const applyDrag = useCallback((deltaX: number) => { + if (isPaused) return; + + const axisX = resolvePortraitCoverHorizontalAxis(deltaX); + bridgeActions.setTouchDirectional('left', Math.max(0, -axisX)); + bridgeActions.setTouchDirectional('right', Math.max(0, axisX)); + }, [isPaused]); + + const onPointerDown = useCallback((event: PointerEvent) => { + stopPointer(event); + if (isPaused || activePointerIdRef.current !== null) return; + + 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 }; - } - - 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 + activePointerIdRef.current = event.pointerId; + hasDraggedRef.current = false; + event.currentTarget.setPointerCapture(event.pointerId); + dispatchDragIndicator({ + type: 'begin', + point: { x: containerX, y: containerY } }); - } - - function releaseJoystick(): void { - joystickPointerId.current = null; - setDirectionalAxes(0, 0); - setJoystickOffset({ x: 0, y: 0 }); - } - - 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(); - } - }; + clearMovement(); + }, [clearMovement, isPaused, stopPointer]); + + const onPointerMove = useCallback((event: PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) return; + stopPointer(event); + + const start = dragStartRef.current; + const deltaX = event.clientX - start.clientX; + const deltaY = event.clientY - start.clientY; - const jumpButtonHandlers = { - onPointerDown: (e: PointerEvent) => { - stopPointer(e); - if (!isPaused) bridgeActions.queueJump(); + if (shouldPortraitCoverPointerTravelActivate(deltaX, deltaY)) { + hasDraggedRef.current = true; } - }; - const interactButtonHandlers = { - onPointerDown: (e: PointerEvent) => { - stopPointer(e); - if (!isPaused) bridgeActions.tapInteract(); + dispatchDragIndicator({ + type: 'move', + point: { + x: start.containerX + deltaX, + y: start.containerY + deltaY + } + }); + applyDrag(deltaX); + }, [applyDrag, 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 { - gestureHandlers, - getDirectionalButtonHandlers, - joystickHandlers, - joystickOffset, - jumpButtonHandlers, - interactButtonHandlers + dragIndicator, + 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/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", diff --git a/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx b/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx index 64e7540c..5afa86dd 100644 --- a/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx +++ b/src/shared/ui/NotebookShell/chrome/ControlMatDragIndicator.tsx @@ -1,12 +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 { 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 7a618b71..b6c4c225 100644 --- a/src/shared/ui/NotebookShell/index.ts +++ b/src/shared/ui/NotebookShell/index.ts @@ -4,9 +4,13 @@ export { export { ControlMatDragIndicator } from './chrome/ControlMatDragIndicator'; +export { + reduceControlMatDragIndicator +} from './chrome/controlMatDragIndicatorState'; export type { + ControlMatDragIndicatorAction, ControlMatDragIndicatorState -} from './chrome/ControlMatDragIndicator'; +} from './chrome/controlMatDragIndicatorState'; export { NotebookHeaderChrome } from './chrome/NotebookHeaderChrome'; 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,