From 86fc382413a5ab881cc93079f3e4629f43e55064 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 10 May 2026 17:50:00 +0800 Subject: [PATCH] feat: add shared runtime input device layer --- .hermes/shared-memory/decision-log.md | 8 + ...UNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md | 12 + docs/technical/README.md | 1 + ...IME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md | 55 +++ .../PlatformEntryFlowShellImpl.tsx | 56 ++- .../PuzzleRuntimeShell.test.tsx | 146 +++++- .../puzzle-runtime/PuzzleRuntimeShell.tsx | 458 +++++++++++------- ...gEntryFlowShell.agent.interaction.test.tsx | 48 ++ src/services/input-devices/index.ts | 19 + .../runtimeDragInputController.test.ts | 161 ++++++ .../runtimeDragInputController.ts | 168 +++++++ .../input-devices/runtimeInputGeometry.ts | 142 ++++++ 12 files changed, 1095 insertions(+), 179 deletions(-) create mode 100644 docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md create mode 100644 src/services/input-devices/index.ts create mode 100644 src/services/input-devices/runtimeDragInputController.test.ts create mode 100644 src/services/input-devices/runtimeDragInputController.ts create mode 100644 src/services/input-devices/runtimeInputGeometry.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 54f6f6e6..26e2d732 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-10 运行态输入设备抽象层全项目通用化 + +- 背景:拼图运行态接入 mocap 后,鼠标/触控和 mocap 各自维护输入逻辑会导致合并大块、拖拽语义和取消会话行为不一致;后续其他玩法也需要复用体感、摇杆、键盘等设备输入。 +- 决策:前端运行态输入统一通过 `src/services/input-devices/` 承接,设备适配层只输出 `press / move / release / tap / drop` 等通用语义和通用坐标;玩法组件自己解释目标对象、落点和业务动作,输入层不得写拼图等玩法专用规则。 +- 影响范围:拼图运行态鼠标/触控/mocap 输入、后续运行态设备接入、运行态输入技术文档与相关前端回归测试。 +- 验证方式:执行 `npm run test -- src\services\input-devices\runtimeDragInputController.test.ts`、`npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx`、`npm run typecheck` 和编码检查。 +- 关联文档:`docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md`、`docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md`。 + ## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine - 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。 diff --git a/docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md b/docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md index f134fdc2..f1fddac1 100644 --- a/docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md +++ b/docs/technical/PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md @@ -23,6 +23,9 @@ 7. 当前作品没有下一关时,通关弹窗展示后端 handoff 返回的相似作品;用户点击具体候选作品时直接 `startPuzzleRun(profileId, null)`,从目标作品第 `1` 关重新开始。 8. 失败状态点击“重新开始”时,正式 run 使用当前关 `levelId` 重新 `startPuzzleRun`,草稿/本地 run 使用本地重建,二者都保留当前失败关卡。 9. 结果页草稿试玩没有正式后端 run 时,继续使用本地 run、local leaderboard 和本地下一关兜底。 +10. 运行态输入采用全项目通用的 `src/services/input-devices/` 抽象层承接,指针、触控、mocap 等设备都先归一为 `press / move / release / tap / drop` 拖拽语义,再由拼图运行态解析具体拼块和落点。 +11. mocap `grab` 不是点击选中语义,而是持续拖拽语义;松手时按当前棋盘归一坐标提交 drop。合并大块只需要提交其中任一成员拼块 `pieceId`,本地拼图运行时会按 `mergedGroupId` 解析整组平移。 +12. 拼图作品详情或开局遇到后端 `404 / NOT_FOUND / 资源不存在` 时,平台入口不再停留在空详情或运行态错误页,而是清理当前拼图详情/run 状态并返回首页。 ## 工程落点 @@ -38,6 +41,15 @@ 3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` - 公开拼图玩法交互测试断言前端本地交换函数被调用。 - 同时断言后端 `swap / drag` 不参与棋盘交互,后端 `leaderboard / next-level` 继续参与非即时链路。 +4. `src/services/input-devices/` + - `runtimeDragInputController` 提供设备无关的拖拽会话状态机。 + - `runtimeInputGeometry` 提供屏幕坐标、归一坐标和网格命中的通用转换能力。 + - 玩法组件只传入“这个点对应哪个目标”和“drop 到哪个目标”的玩法解释,不在输入层写拼图专用规则。 +5. `src/components/puzzle-runtime/PuzzleRuntimeShell.tsx` + - 鼠标/触控与 mocap 共用同一个 runtime drag controller。 + - 合并块成员不再被 mocap 路径过滤;mocap 可从合并块任一占用格抓取,并复用本地运行时的大块拖拽规则。 +6. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + - `openPuzzleDetail`、`openPuzzlePublicWorkDetail`、`startPuzzleRunFromProfile` 对拼图作品缺失统一回首页。 ## 边界 diff --git a/docs/technical/README.md b/docs/technical/README.md index 20d26357..09017a0b 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 +- [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md):记录运行态输入设备抽象层,明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 - [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。 - [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 diff --git a/docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md b/docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md new file mode 100644 index 00000000..01dada71 --- /dev/null +++ b/docs/technical/RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md @@ -0,0 +1,55 @@ +# 运行态输入设备抽象层 2026-05-10 + +## 背景 + +拼图运行态接入 mocap 后,鼠标/触控和 mocap 曾各自维护一套选择、坐标换算和拖拽提交逻辑。这样会让新设备只能在单个玩法里打补丁,也容易出现同一动作在不同设备下语义不一致的问题:例如 mocap `grab` 只触发选中,而不是像鼠标按住一样持续拖拽。 + +后续运行态还会接摇杆、键盘、体感、摄像头手势等输入来源,因此输入设备接入必须收口到全项目通用层。 + +## 决策 + +新增 `src/services/input-devices/` 作为前端运行态通用输入设备抽象层: + +1. `runtimeDragInputController` 只维护设备无关的拖拽会话状态机。 +2. `runtimeInputGeometry` 只处理 client 坐标、归一坐标、元素边界和网格命中换算。 +3. 设备适配层把鼠标、触控、mocap 等输入归一为 `press / move / release / tap / drop`。 +4. 玩法组件负责把通用输入点解释成自己的目标对象和落点,不把拼图、方洞或大鱼等玩法规则写进输入层。 + +## 当前接入 + +拼图运行态已接入该层: + +- 鼠标/触控 `pointerdown / pointermove / pointerup` 进入同一个 drag controller。 +- mocap `grab` 进入同一个 drag controller,并强制使用持续拖拽语义。 +- mocap 松手时按当前棋盘归一坐标提交 drop。 +- 合并大块由拼图运行态把手部坐标命中到任一成员拼块;本地拼图运行时再按 `mergedGroupId` 执行整组平移。 + +## 接入规则 + +新玩法或新设备接入时遵循以下边界: + +1. 输入层可以知道设备类型和几何换算,但不能知道玩法业务规则。 +2. 设备适配层只负责把原始输入转换成通用输入事件。 +3. 玩法壳层负责从通用输入点解析本玩法目标,例如拼块、洞口、角色或实体。 +4. 玩法壳层负责决定 drop 后调用哪个本地运行态函数或后端接口。 +5. 需要取消输入时优先按 `inputId` 取消,避免 mocap 丢帧误伤正在进行的鼠标/触控会话。 + +## 验证 + +基础抽象层验证: + +```bash +npm run test -- src\services\input-devices\runtimeDragInputController.test.ts +``` + +拼图接入验证: + +```bash +npm run test -- src\components\puzzle-runtime\PuzzleRuntimeShell.test.tsx +``` + +跨平台入口缺失作品兜底验证: + +```bash +npm run test -- src\components\rpg-entry\RpgEntryFlowShell.agent.interaction.test.tsx -t "missing puzzle public detail returns to platform home" +``` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 535c7ae9..d2aac4e1 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -857,6 +857,19 @@ function shouldUseLocalPuzzleOnboardingFallback(error: unknown) { ); } +function isMissingPuzzleWorkError(error: unknown) { + return ( + (error instanceof ApiClientError && + error.status === 404 && + (error.code === 'NOT_FOUND' || + error.message.includes('资源不存在') || + error.message.includes('未找到'))) || + (error instanceof Error && + (error.message.includes('资源不存在') || + error.message.includes('未找到拼图作品'))) + ); +} + function hasSeenPuzzleOnboarding() { if (typeof window === 'undefined') { return true; @@ -4062,6 +4075,19 @@ export function PlatformEntryFlowShellImpl({ } return true; } catch (error) { + if (isMissingPuzzleWorkError(error)) { + setSelectedPuzzleDetail(null); + setPuzzleDetailReturnTarget(null); + setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); + setPuzzleError(null); + setPublicWorkDetailError(null); + setPlatformTab('home'); + setSelectionStage('platform'); + pushAppHistoryPath('/'); + return false; + } + const message = resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'); setPuzzleError(message); if (mirrorErrorToPublicDetail) { @@ -4077,8 +4103,8 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, + setPlatformTab, setSelectionStage, - startPuzzleRun, ], ); @@ -5517,6 +5543,19 @@ export function PlatformEntryFlowShellImpl({ setPuzzleDetailReturnTarget(returnTarget); openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item)); } catch (error) { + if (isMissingPuzzleWorkError(error)) { + setSelectedPuzzleDetail(null); + setPuzzleDetailReturnTarget(null); + setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); + setPuzzleError(null); + setPublicWorkDetailError(null); + setPlatformTab('home'); + setSelectionStage('platform'); + pushAppHistoryPath('/'); + return; + } + setPublicWorkDetailError( resolvePuzzleErrorMessage(error, '读取拼图详情失败。'), ); @@ -5531,6 +5570,7 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, + setPlatformTab, setSelectionStage, ], ); @@ -5722,6 +5762,19 @@ export function PlatformEntryFlowShellImpl({ ), ); } catch (error) { + if (isMissingPuzzleWorkError(error)) { + setSelectedPuzzleDetail(null); + setPuzzleDetailReturnTarget(null); + setPuzzleRun(null); + setPuzzleRuntimeAuthMode('default'); + setPuzzleError(null); + setPublicWorkDetailError(null); + setPlatformTab('home'); + setSelectionStage('platform'); + pushAppHistoryPath('/'); + return; + } + setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。')); } finally { setIsPuzzleBusy(false); @@ -5732,6 +5785,7 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, + setPlatformTab, setSelectionStage, ], ); diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx index 9030b32a..0201dc0c 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx @@ -207,8 +207,11 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => { const cursor = screen.getByTestId('puzzle-mocap-cursor'); expect(cursor).toBeTruthy(); - expect(cursor).toHaveStyle({left: '42%', top: '58%'}); + expect(Number.parseFloat(cursor.style.left)).toBeCloseTo(42); + expect(Number.parseFloat(cursor.style.top)).toBeCloseTo(58); mocapMock.state = 'grab'; + mocapMock.x = 0.42; + mocapMock.y = 0.58; }); test('抓握时会触发拖拽提交并在松开时落子', () => { @@ -301,6 +304,144 @@ test('抓握时会触发拖拽提交并在松开时落子', () => { ); }); +test('mocap 抓握合并大块时按大块锚点提交拖拽', () => { + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + mocapMock.state = 'open_palm'; + mocapMock.x = 0.2; + mocapMock.y = 0.2; + const onDragPiece = vi.fn(); + const mergedRun: PuzzleRunSnapshot = { + ...clearedRun, + currentLevel: { + ...clearedRun.currentLevel!, + status: 'playing', + startedAtMs: Date.now(), + remainingMs: 300_000, + timeLimitMs: 300_000, + board: { + ...clearedRun.currentLevel!.board, + allTilesResolved: false, + mergedGroups: [ + { + groupId: 'group-large', + pieceIds: ['piece-0', 'piece-1', 'piece-3'], + occupiedCells: [ + { row: 0, col: 0 }, + { row: 0, col: 1 }, + { row: 1, col: 0 }, + ], + }, + ], + pieces: clearedRun.currentLevel!.board.pieces.map((piece) => + ['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId) + ? { ...piece, mergedGroupId: 'group-large' } + : piece, + ), + }, + }, + }; + + Object.defineProperty(window, 'requestAnimationFrame', { + configurable: true, + value: vi.fn(() => 1), + }); + Object.defineProperty(window, 'cancelAnimationFrame', { + configurable: true, + value: vi.fn(), + }); + + const { container, rerender, unmount } = renderPuzzleRuntime( + , + ); + const board = container.querySelector( + '[data-testid="puzzle-board"]', + ) as HTMLElement | null; + if (!board) { + throw new Error('缺少测试棋盘'); + } + board.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 300, + bottom: 300, + width: 300, + height: 300, + toJSON: () => ({}), + }) as DOMRect; + + mocapMock.state = 'grab'; + mocapMock.x = 0.2; + mocapMock.y = 0.2; + rerender( + + + , + ); + + mocapMock.x = 0.7; + mocapMock.y = 0.7; + rerender( + + + , + ); + + mocapMock.state = 'open_palm'; + rerender( + + + , + ); + + expect(onDragPiece).toHaveBeenCalledTimes(1); + expect(onDragPiece).toHaveBeenCalledWith({ + pieceId: 'piece-0', + targetRow: 2, + targetCol: 2, + }); + + unmount(); + Object.defineProperty(window, 'requestAnimationFrame', { + configurable: true, + value: originalRequestAnimationFrame, + }); + Object.defineProperty(window, 'cancelAnimationFrame', { + configurable: true, + value: originalCancelAnimationFrame, + }); + mocapMock.state = 'grab'; + mocapMock.x = 0.42; + mocapMock.y = 0.58; +}); + test('通关后显示结算弹窗、排行榜和下一关按钮', () => { vi.useFakeTimers(); const onAdvanceNextLevel = vi.fn(); @@ -820,6 +961,9 @@ test('移动端点击拼图片时立即触发一次震动反馈', () => { const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; const vibrate = vi.fn(); + mocapMock.state = 'open_palm'; + mocapMock.x = 0.42; + mocapMock.y = 0.58; const playingRun: PuzzleRunSnapshot = { ...clearedRun, currentLevel: { diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index 944d2a2c..bf276b2c 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -23,6 +23,15 @@ import type { SwapPuzzlePiecesRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import { + createRuntimeDragInputController, + createRuntimeInputPointFromClient, + createRuntimeInputPointFromNormalized, + readRuntimeInputElementBounds, + resolveRuntimeInputGridCell, + type RuntimeDragInputSession, + type RuntimeInputPoint, +} from '../../services/input-devices'; import { useMocapInput } from '../../services/useMocapInput'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -211,6 +220,7 @@ const PUZZLE_HINT_DEMO_DURATION_MS = 1_250; const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12; const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX = 'genarrative.puzzle-runtime.exit-remodel-prompt.v1'; +const PUZZLE_MOCAP_DRAG_INPUT_ID = 'mocap:primary-hand'; const shownExitRemodelPromptProfileIds = new Set(); @@ -290,6 +300,11 @@ type PuzzleMocapCursorState = { state: string; }; +type PuzzleRuntimeDragTargetState = { + pieceId: string; + groupId: string | null; +}; + function triggerPuzzlePiecePressHapticFeedback() { if (typeof navigator === 'undefined') { return; @@ -328,6 +343,8 @@ export function PuzzleRuntimeShell({ const mergedGroupSvgIdPrefix = sanitizeSvgId(useId()); const authUi = useAuthUi(); const [selectedPieceId, setSelectedPieceId] = useState(null); + const selectedPieceIdRef = useRef(null); + const selectedPieceBeforeInputRef = useRef(null); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = useState(false); @@ -354,7 +371,7 @@ export function PuzzleRuntimeShell({ const timeExpiredSyncKeyRef = useRef(null); const dragSessionRef = useRef<{ pieceId: string; - pointerId: number; + inputId: string; dragging: boolean; startX: number; startY: number; @@ -377,7 +394,10 @@ export function PuzzleRuntimeShell({ const [mocapCursor, setMocapCursor] = useState( null, ); - const mocapDragRef = useRef<{pieceId: string} | null>(null); + const runtimeDragInputControllerRef = useRef( + createRuntimeDragInputController(), + ); + const draggingTargetRef = useRef(null); const [dismissedClearKey, setDismissedClearKey] = useState( null, ); @@ -400,6 +420,8 @@ export function PuzzleRuntimeShell({ ? 'failed' : currentLevel.status : 'playing'; + const isInteractionLocked = + isBusy || runtimeStatus !== 'playing' || Boolean(propDialog); const clearResultKey = currentLevel ? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}` : null; @@ -409,12 +431,19 @@ export function PuzzleRuntimeShell({ currentLevel?.coverImageSrc ?? null, ); const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'}); + const primaryMocapHand = mocapInput.latestCommand?.primaryHand; + const primaryMocapHandState = primaryMocapHand?.state; + const primaryMocapHandX = primaryMocapHand?.x; + const primaryMocapHandY = primaryMocapHand?.y; const mocapActionsLabel = mocapInput.latestCommand?.actions.length ? mocapInput.latestCommand.actions.join(', ') : '无'; - const mocapHandLabel = mocapInput.latestCommand?.primaryHand - ? `${mocapInput.latestCommand.primaryHand.state} @ ${mocapInput.latestCommand.primaryHand.x.toFixed(2)}, ${mocapInput.latestCommand.primaryHand.y.toFixed(2)}` + const mocapHandLabel = + primaryMocapHandState && + typeof primaryMocapHandX === 'number' && + typeof primaryMocapHandY === 'number' + ? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}` : '无'; const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length ? mocapInput.latestCommand.parseWarnings.join(';') @@ -425,6 +454,11 @@ export function PuzzleRuntimeShell({ currentLevelRef.current = currentLevel; }, [currentLevel]); + const commitSelectedPieceId = (pieceId: string | null) => { + selectedPieceIdRef.current = pieceId; + setSelectedPieceId(pieceId); + }; + const pieces = useMemo(() => { if (!board) { return []; @@ -586,13 +620,18 @@ export function PuzzleRuntimeShell({ dragVisualFrameRef.current = null; }; - const resetDragInteraction = () => { + const resetDragInteractionState = () => { cancelDragVisualFrame(); dragOffsetRef.current = null; dragSessionRef.current = null; + draggingTargetRef.current = null; resetDragVisualTarget(); }; + const resetDragInteraction = () => { + runtimeDragInputControllerRef.current.cancel(); + }; + const flushDragVisual = () => { dragVisualFrameRef.current = null; const dragSession = dragSessionRef.current; @@ -602,7 +641,8 @@ export function PuzzleRuntimeShell({ } const piece = pieceById.get(dragSession.pieceId) ?? null; - const groupId = piece?.mergedGroupId ?? null; + const groupId = + draggingTargetRef.current?.groupId ?? piece?.mergedGroupId ?? null; const nextTarget = { pieceId: dragSession.pieceId, groupId, @@ -808,6 +848,221 @@ export function PuzzleRuntimeShell({ ]; }, [clearResultKey, currentLevel, dismissedClearKey]); + const handlePieceTap = ( + pieceId: string, + selectedPieceIdBeforeInput: string | null, + ) => { + if (isInteractionLocked) { + return; + } + + if (!selectedPieceIdBeforeInput) { + commitSelectedPieceId(pieceId); + return; + } + + if (selectedPieceIdBeforeInput === pieceId) { + commitSelectedPieceId(null); + return; + } + + onSwapPieces({ + firstPieceId: selectedPieceIdBeforeInput, + secondPieceId: pieceId, + }); + commitSelectedPieceId(null); + }; + + const resolvePuzzleRuntimeDragTarget = ( + pieceId: string, + ): PuzzleRuntimeDragTargetState | null => { + const sourcePiece = pieceById.get(pieceId) ?? null; + if (!sourcePiece) { + return null; + } + + return { + pieceId: sourcePiece.pieceId, + groupId: sourcePiece.mergedGroupId ?? null, + }; + }; + + const commitPuzzleRuntimeDrag = ( + target: PuzzleRuntimeDragTargetState | null, + point: RuntimeInputPoint, + ) => { + const dragSession = dragSessionRef.current; + if (!target || !dragSession) { + return; + } + + const targetCell = board + ? resolveRuntimeInputGridCell(point, board) + : null; + if (!targetCell) { + return; + } + + onDragPiece({ + pieceId: target.pieceId, + targetRow: targetCell.row, + targetCol: targetCell.col, + }); + }; + + const resolveBoardInputPointFromClient = ( + clientX: number, + clientY: number, + ) => + createRuntimeInputPointFromClient( + clientX, + clientY, + readRuntimeInputElementBounds(boardRef.current), + ); + + const resolveBoardInputPointFromNormalized = ( + normalizedX: number, + normalizedY: number, + ) => + createRuntimeInputPointFromNormalized( + normalizedX, + normalizedY, + readRuntimeInputElementBounds(boardRef.current), + ); + + const syncRuntimeDragFromController = ( + session: RuntimeDragInputSession | null, + ) => { + if (!session) { + return; + } + + draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId); + dragSessionRef.current = { + pieceId: session.targetId, + inputId: session.inputId, + dragging: session.dragging, + startX: session.startPoint.clientX, + startY: session.startPoint.clientY, + currentX: session.currentPoint.clientX, + currentY: session.currentPoint.clientY, + }; + + if (session.dragging) { + flushDragVisual(); + scheduleDragVisual(); + } + }; + + runtimeDragInputControllerRef.current.setOptions({ + dragThresholdPx: 8, + onPress: (session) => { + draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId); + syncRuntimeDragFromController(session); + selectedPieceBeforeInputRef.current = selectedPieceIdRef.current; + commitSelectedPieceId(session.targetId); + triggerPuzzlePiecePressHapticFeedback(); + }, + onDragStart: (session) => { + draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId); + syncRuntimeDragFromController(session); + }, + onDragMove: (session) => { + syncRuntimeDragFromController(session); + }, + onDrop: (session) => { + draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId); + syncRuntimeDragFromController(session); + commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint); + commitSelectedPieceId(null); + selectedPieceBeforeInputRef.current = null; + resetDragInteractionState(); + }, + onTap: (session) => { + handlePieceTap(session.targetId, selectedPieceBeforeInputRef.current); + selectedPieceBeforeInputRef.current = null; + resetDragInteractionState(); + }, + onCancel: () => { + commitSelectedPieceId(selectedPieceBeforeInputRef.current); + selectedPieceBeforeInputRef.current = null; + resetDragInteractionState(); + }, + }); + + useEffect(() => { + const activeSession = runtimeDragInputControllerRef.current.getSession(); + if (!board || runtimeStatus !== 'playing' || isInteractionLocked) { + runtimeDragInputControllerRef.current.cancel(); + setMocapCursor(null); + return; + } + if ( + !primaryMocapHandState || + typeof primaryMocapHandX !== 'number' || + typeof primaryMocapHandY !== 'number' + ) { + runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID); + setMocapCursor(null); + return; + } + + setMocapCursor({ + x: primaryMocapHandX, + y: primaryMocapHandY, + state: primaryMocapHandState, + }); + const handPoint = resolveBoardInputPointFromNormalized( + primaryMocapHandX, + primaryMocapHandY, + ); + if (primaryMocapHandState === 'grab') { + if (activeSession?.inputId !== PUZZLE_MOCAP_DRAG_INPUT_ID) { + const sourceCell = resolveRuntimeInputGridCell(handPoint, board); + const sourcePiece = sourceCell + ? pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null + : null; + if (!sourcePiece) { + runtimeDragInputControllerRef.current.cancel( + PUZZLE_MOCAP_DRAG_INPUT_ID, + ); + return; + } + + runtimeDragInputControllerRef.current.press({ + targetId: sourcePiece.pieceId, + inputId: PUZZLE_MOCAP_DRAG_INPUT_ID, + deviceKind: 'mocap', + point: handPoint, + }); + return; + } + + runtimeDragInputControllerRef.current.move({ + inputId: PUZZLE_MOCAP_DRAG_INPUT_ID, + point: handPoint, + forceDragging: true, + }); + return; + } + + if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) { + runtimeDragInputControllerRef.current.release({ + inputId: PUZZLE_MOCAP_DRAG_INPUT_ID, + point: handPoint, + forceDrop: activeSession.deviceKind === 'mocap', + }); + } + }, [ + board, + isInteractionLocked, + pieceByCell, + primaryMocapHandState, + primaryMocapHandX, + primaryMocapHandY, + runtimeStatus, + ]); + if (!run || !currentLevel || !board) { return (
{ - if (isInteractionLocked) { - return; - } - - if (!selectedPieceId) { - setSelectedPieceId(pieceId); - return; - } - - if (selectedPieceId === pieceId) { - setSelectedPieceId(null); - return; - } - - onSwapPieces({ - firstPieceId: selectedPieceId, - secondPieceId: pieceId, - }); - setSelectedPieceId(null); - }; - - const resolveBoardCellFromPointer = (clientX: number, clientY: number) => { - const boardElement = boardRef.current; - if (!boardElement) { - return null; - } - - const rect = boardElement.getBoundingClientRect(); - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - return null; - } - - const relativeX = clientX - rect.left; - const relativeY = clientY - rect.top; - const col = Math.min( - board.cols - 1, - Math.max(0, Math.floor((relativeX / rect.width) * board.cols)), - ); - const row = Math.min( - board.rows - 1, - Math.max(0, Math.floor((relativeY / rect.height) * board.rows)), - ); - - return { row, col }; - }; - - const resolveMocapTargetCell = (x: number, y: number) => ({ - row: Math.min(board.rows - 1, Math.max(0, Math.floor(y * board.rows))), - col: Math.min(board.cols - 1, Math.max(0, Math.floor(x * board.cols))), - }); - - const handleMocapInputCommand = () => { - const hand = mocapInput.latestCommand?.primaryHand; - if (runtimeStatus !== 'playing' || isInteractionLocked || !hand) { - mocapDragRef.current = null; - setMocapCursor(null); - return; - } - - setMocapCursor({x: hand.x, y: hand.y, state: hand.state}); - if (hand.state === 'grab') { - if (mocapDragRef.current) { - return; - } - const sourceCell = resolveMocapTargetCell(hand.x, hand.y); - const sourcePiece = pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null; - if (!sourcePiece || sourcePiece.mergedGroupId) { - return; - } - mocapDragRef.current = {pieceId: sourcePiece.pieceId}; - setSelectedPieceId(sourcePiece.pieceId); - triggerPuzzlePiecePressHapticFeedback(); - return; - } - - const draggingPiece = mocapDragRef.current; - if (!draggingPiece) { - return; - } - const targetCell = resolveMocapTargetCell(hand.x, hand.y); - mocapDragRef.current = null; - setSelectedPieceId(null); - onDragPiece({ - pieceId: draggingPiece.pieceId, - targetRow: targetCell.row, - targetCol: targetCell.col, - }); - }; - - const handlePiecePointerUp = ( - pieceId: string, - event: React.PointerEvent, - ) => { - const currentDragSession = dragSessionRef.current; - if (!currentDragSession || currentDragSession.pieceId !== pieceId) { - return; - } - + const handlePiecePointerUp = (event: React.PointerEvent) => { event.currentTarget.releasePointerCapture?.(event.pointerId); - - if (currentDragSession.dragging) { - const targetCell = resolveBoardCellFromPointer( - event.clientX, - event.clientY, - ); - resetDragInteraction(); - if (targetCell) { - onDragPiece({ - pieceId, - targetRow: targetCell.row, - targetCol: targetCell.col, - }); - } - setSelectedPieceId(null); - return; - } - - resetDragInteraction(); - handlePieceClick(pieceId); + runtimeDragInputControllerRef.current.release({ + inputId: `pointer:${event.pointerId}`, + point: resolveBoardInputPointFromClient(event.clientX, event.clientY), + }); }; const handlePiecePointerDown = ( @@ -958,46 +1094,20 @@ export function PuzzleRuntimeShell({ event.preventDefault(); resetDragInteraction(); event.currentTarget.setPointerCapture?.(event.pointerId); - // 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。 - triggerPuzzlePiecePressHapticFeedback(); - dragSessionRef.current = { - pieceId, - pointerId: event.pointerId, - dragging: false, - startX: event.clientX, - startY: event.clientY, - currentX: event.clientX, - currentY: event.clientY, - }; + runtimeDragInputControllerRef.current.press({ + targetId: pieceId, + inputId: `pointer:${event.pointerId}`, + deviceKind: 'pointer', + point: resolveBoardInputPointFromClient(event.clientX, event.clientY), + }); }; - const handlePiecePointerMove = ( - pieceId: string, - event: React.PointerEvent, - ) => { - const dragSession = dragSessionRef.current; - if ( - !dragSession || - dragSession.pieceId !== pieceId || - dragSession.pointerId !== event.pointerId - ) { - return; - } - + const handlePiecePointerMove = (event: React.PointerEvent) => { event.preventDefault(); - const deltaX = event.clientX - dragSession.startX; - const deltaY = event.clientY - dragSession.startY; - const dragging = dragSession.dragging || Math.hypot(deltaX, deltaY) >= 8; - dragSession.dragging = dragging; - dragSession.currentX = event.clientX; - dragSession.currentY = event.clientY; - if (!dragging) { - return; - } - - // 首帧拖拽反馈立即落到 DOM,确保层级提升不会滞后一帧;后续仍保留 raf 兜底连续刷新。 - flushDragVisual(); - scheduleDragVisual(); + runtimeDragInputControllerRef.current.move({ + inputId: `pointer:${event.pointerId}`, + point: resolveBoardInputPointFromClient(event.clientX, event.clientY), + }); }; const draggingPieceId = dragRenderTarget?.pieceId ?? null; @@ -1037,8 +1147,6 @@ export function PuzzleRuntimeShell({ currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey && isClearResultReady; - const isInteractionLocked = - isBusy || runtimeStatus !== 'playing' || Boolean(propDialog); const handleBackRequest = () => { if (hideExitControls) { return; @@ -1150,10 +1258,6 @@ export function PuzzleRuntimeShell({ } }; - useEffect(() => { - handleMocapInputCommand(); - }, [mocapInput.latestCommand?.primaryHand]); - return (
{ if (piece && !isMerged) { - handlePiecePointerUp(piece.pieceId, event); + handlePiecePointerUp(event); } }} onPointerCancel={() => { @@ -1461,10 +1565,10 @@ export function PuzzleRuntimeShell({ handlePiecePointerDown(piece.pieceId, event); }} onPointerMove={(event) => { - handlePiecePointerMove(piece.pieceId, event); + handlePiecePointerMove(event); }} onPointerUp={(event) => { - handlePiecePointerUp(piece.pieceId, event); + handlePiecePointerUp(event); }} onPointerCancel={() => { resetDragInteraction(); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 14397ddd..2edf0d55 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -4078,6 +4078,54 @@ test('public code search opens a published puzzle by PZ code', async () => { expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); +test('missing puzzle public detail returns to platform home', async () => { + const user = userEvent.setup(); + const missingPuzzleWork = { + workId: 'puzzle-work-missing-1', + profileId: 'puzzle-profile-missing-1', + ownerUserId: 'user-2', + sourceSessionId: null, + authorDisplayName: '拼图作者', + levelName: '失效拼图', + summary: '这个作品已经不可用。', + themeTags: ['失效'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + playCount: 1, + remixCount: 0, + likeCount: 0, + publishReady: true, + } satisfies PuzzleWorkSummary; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [missingPuzzleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockRejectedValueOnce( + new ApiClientError({ + message: '资源不存在', + status: 404, + code: 'NOT_FOUND', + }), + ); + + render(); + await openDiscoverHub(user); + + const workCards = await screen.findAllByRole('button', { name: /失效拼图/u }); + await user.click(workCards[0]!); + + await waitFor(() => { + expect(window.location.pathname).toBe('/'); + }); + expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false'); + expect(screen.queryByText('详情')).toBeNull(); + expect(screen.queryByText('资源不存在')).toBeNull(); + expect(startPuzzleRun).toHaveBeenCalledTimes(0); +}); + test('public code search opens a published big fish work by BF code', async () => { const user = userEvent.setup(); const bigFishWork: BigFishWorkSummary = { diff --git a/src/services/input-devices/index.ts b/src/services/input-devices/index.ts new file mode 100644 index 00000000..d1d7e760 --- /dev/null +++ b/src/services/input-devices/index.ts @@ -0,0 +1,19 @@ +export { + createRuntimeDragInputController, + type RuntimeDragInputControllerOptions, + type RuntimeDragInputMove, + type RuntimeDragInputPress, + type RuntimeDragInputRelease, + type RuntimeDragInputSession, + type RuntimeInputDeviceKind, + type RuntimeInputPoint, +} from './runtimeDragInputController'; +export { + createRuntimeInputPointFromClient, + createRuntimeInputPointFromNormalized, + readRuntimeInputElementBounds, + resolveRuntimeInputGridCell, + type RuntimeInputBounds, + type RuntimeInputGridCell, + type RuntimeInputGridSpec, +} from './runtimeInputGeometry'; diff --git a/src/services/input-devices/runtimeDragInputController.test.ts b/src/services/input-devices/runtimeDragInputController.test.ts new file mode 100644 index 00000000..61a3c4d4 --- /dev/null +++ b/src/services/input-devices/runtimeDragInputController.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + createRuntimeDragInputController, + createRuntimeInputPointFromNormalized, + resolveRuntimeInputGridCell, +} from './index'; + +describe('runtime drag input controller', () => { + test('pointer-like short press remains a tap', () => { + const onTap = vi.fn(); + const onDrop = vi.fn(); + const controller = createRuntimeDragInputController({ + dragThresholdPx: 12, + onTap, + onDrop, + }); + + controller.press({ + targetId: 'piece-1', + inputId: 'pointer:1', + deviceKind: 'pointer', + point: { clientX: 10, clientY: 10 }, + }); + controller.move({ + inputId: 'pointer:1', + point: { clientX: 14, clientY: 14 }, + }); + controller.release({ + inputId: 'pointer:1', + point: { clientX: 14, clientY: 14 }, + }); + + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTap).toHaveBeenCalledWith( + expect.objectContaining({ targetId: 'piece-1', dragging: false }), + ); + expect(onDrop).not.toHaveBeenCalled(); + }); + + test('device adapters can force continuous drag semantics', () => { + const onDragStart = vi.fn(); + const onDragMove = vi.fn(); + const onDrop = vi.fn(); + const controller = createRuntimeDragInputController({ + dragThresholdPx: 100, + onDragStart, + onDragMove, + onDrop, + }); + + controller.press({ + targetId: 'piece-1', + inputId: 'mocap:hand', + deviceKind: 'mocap', + point: { clientX: 10, clientY: 10 }, + }); + controller.move({ + inputId: 'mocap:hand', + point: { clientX: 11, clientY: 11 }, + forceDragging: true, + }); + controller.release({ + inputId: 'mocap:hand', + point: { clientX: 12, clientY: 12 }, + }); + + expect(onDragStart).toHaveBeenCalledTimes(1); + expect(onDragMove).toHaveBeenCalledTimes(1); + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDrop).toHaveBeenCalledWith( + expect.objectContaining({ deviceKind: 'mocap', dragging: true }), + ); + }); + + test('device adapters can force drop on release without converting to tap', () => { + const onTap = vi.fn(); + const onDrop = vi.fn(); + const controller = createRuntimeDragInputController({ + dragThresholdPx: 100, + onTap, + onDrop, + }); + + controller.press({ + targetId: 'piece-1', + inputId: 'mocap:hand', + deviceKind: 'mocap', + point: { clientX: 10, clientY: 10 }, + }); + controller.release({ + inputId: 'mocap:hand', + point: { clientX: 10, clientY: 10 }, + forceDrop: true, + }); + + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + targetId: 'piece-1', + forceDrop: true, + dragging: false, + }), + ); + expect(onTap).not.toHaveBeenCalled(); + }); + + test('input-scoped cancel keeps unrelated active sessions alive', () => { + const onCancel = vi.fn(); + const onDrop = vi.fn(); + const controller = createRuntimeDragInputController({ + dragThresholdPx: 1, + onCancel, + onDrop, + }); + + controller.press({ + targetId: 'piece-1', + inputId: 'pointer:1', + deviceKind: 'pointer', + point: { clientX: 10, clientY: 10 }, + }); + controller.cancel('mocap:hand'); + controller.move({ + inputId: 'pointer:1', + point: { clientX: 20, clientY: 20 }, + }); + controller.release({ + inputId: 'pointer:1', + point: { clientX: 20, clientY: 20 }, + }); + + expect(onCancel).not.toHaveBeenCalled(); + expect(onDrop).toHaveBeenCalledTimes(1); + expect(onDrop).toHaveBeenCalledWith( + expect.objectContaining({ inputId: 'pointer:1', targetId: 'piece-1' }), + ); + }); +}); + +describe('runtime input geometry', () => { + test('normalised device coordinates map into client coordinates and grid cells', () => { + const point = createRuntimeInputPointFromNormalized(0.75, 0.25, { + left: 20, + top: 10, + width: 200, + height: 100, + }); + + expect(point).toEqual({ + clientX: 170, + clientY: 35, + normalizedX: 0.75, + normalizedY: 0.25, + }); + expect(resolveRuntimeInputGridCell(point, { rows: 4, cols: 4 })).toEqual({ + row: 1, + col: 3, + }); + }); +}); diff --git a/src/services/input-devices/runtimeDragInputController.ts b/src/services/input-devices/runtimeDragInputController.ts new file mode 100644 index 00000000..cbaf1fc3 --- /dev/null +++ b/src/services/input-devices/runtimeDragInputController.ts @@ -0,0 +1,168 @@ +export type RuntimeInputDeviceKind = 'pointer' | 'mocap' | 'keyboard' | 'unknown'; + +export type RuntimeInputPoint = { + clientX: number; + clientY: number; + normalizedX?: number; + normalizedY?: number; +}; + +export type RuntimeDragInputSession = { + targetId: TTargetId; + inputId: string; + deviceKind: RuntimeInputDeviceKind; + startPoint: RuntimeInputPoint; + currentPoint: RuntimeInputPoint; + dragging: boolean; + forceDrop: boolean; +}; + +export type RuntimeDragInputPress = { + targetId: TTargetId; + inputId: string; + deviceKind: RuntimeInputDeviceKind; + point: RuntimeInputPoint; +}; + +export type RuntimeDragInputMove = { + inputId: string; + point: RuntimeInputPoint; + forceDragging?: boolean; +}; + +export type RuntimeDragInputRelease = { + inputId: string; + point: RuntimeInputPoint; + forceDrop?: boolean; +}; + +export type RuntimeDragInputControllerOptions< + TTargetId extends string = string, +> = { + dragThresholdPx?: number; + onPress?: (session: RuntimeDragInputSession) => void; + onDragStart?: (session: RuntimeDragInputSession) => void; + onDragMove?: (session: RuntimeDragInputSession) => void; + onDrop?: (session: RuntimeDragInputSession) => void; + onTap?: (session: RuntimeDragInputSession) => void; + onCancel?: (session: RuntimeDragInputSession) => void; +}; + +const DEFAULT_DRAG_THRESHOLD_PX = 8; + +function clonePoint(point: RuntimeInputPoint): RuntimeInputPoint { + return { ...point }; +} + +function shouldStartDragging( + session: RuntimeDragInputSession, + point: RuntimeInputPoint, + thresholdPx: number, + forceDragging = false, +) { + if (session.dragging || forceDragging) { + return true; + } + + return ( + Math.hypot( + point.clientX - session.startPoint.clientX, + point.clientY - session.startPoint.clientY, + ) >= thresholdPx + ); +} + +export function createRuntimeDragInputController< + TTargetId extends string = string, +>(initialOptions: RuntimeDragInputControllerOptions = {}) { + let options = initialOptions; + let session: RuntimeDragInputSession | null = null; + + const setOptions = ( + nextOptions: RuntimeDragInputControllerOptions, + ) => { + options = nextOptions; + }; + + const cancel = (inputId?: string) => { + if (inputId && session?.inputId !== inputId) { + return; + } + + const activeSession = session; + session = null; + if (activeSession) { + options.onCancel?.(activeSession); + } + }; + + const press = (input: RuntimeDragInputPress) => { + cancel(); + session = { + targetId: input.targetId, + inputId: input.inputId, + deviceKind: input.deviceKind, + startPoint: clonePoint(input.point), + currentPoint: clonePoint(input.point), + dragging: false, + forceDrop: false, + }; + options.onPress?.(session); + return session; + }; + + const move = (input: RuntimeDragInputMove) => { + if (!session || session.inputId !== input.inputId) { + return null; + } + + const wasDragging = session.dragging; + session = { + ...session, + currentPoint: clonePoint(input.point), + dragging: shouldStartDragging( + session, + input.point, + options.dragThresholdPx ?? DEFAULT_DRAG_THRESHOLD_PX, + input.forceDragging, + ), + }; + + if (!wasDragging && session.dragging) { + options.onDragStart?.(session); + } + if (session.dragging) { + options.onDragMove?.(session); + } + + return session; + }; + + const release = (input: RuntimeDragInputRelease) => { + if (!session || session.inputId !== input.inputId) { + return null; + } + + const completedSession = { + ...session, + currentPoint: clonePoint(input.point), + forceDrop: input.forceDrop === true, + }; + session = null; + if (completedSession.dragging || completedSession.forceDrop) { + options.onDrop?.(completedSession); + } else { + options.onTap?.(completedSession); + } + return completedSession; + }; + + return { + cancel, + getSession: () => session, + move, + press, + release, + setOptions, + }; +} diff --git a/src/services/input-devices/runtimeInputGeometry.ts b/src/services/input-devices/runtimeInputGeometry.ts new file mode 100644 index 00000000..c430d6fd --- /dev/null +++ b/src/services/input-devices/runtimeInputGeometry.ts @@ -0,0 +1,142 @@ +import type { RuntimeInputPoint } from './runtimeDragInputController'; + +export type RuntimeInputBounds = { + left: number; + top: number; + width: number; + height: number; +}; + +export type RuntimeInputGridSpec = { + rows: number; + cols: number; +}; + +export type RuntimeInputGridCell = { + row: number; + col: number; +}; + +function isFiniteNumber(value: number) { + return Number.isFinite(value); +} + +function clamp01(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function hasUsableBounds( + bounds: RuntimeInputBounds | null | undefined, +): bounds is RuntimeInputBounds { + return Boolean( + bounds && + isFiniteNumber(bounds.left) && + isFiniteNumber(bounds.top) && + isFiniteNumber(bounds.width) && + isFiniteNumber(bounds.height) && + bounds.width > 0 && + bounds.height > 0, + ); +} + +export function readRuntimeInputElementBounds( + element: Element | null | undefined, +): RuntimeInputBounds | null { + if (!element) { + return null; + } + + const rect = element.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return null; + } + + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; +} + +export function createRuntimeInputPointFromClient( + clientX: number, + clientY: number, + bounds?: RuntimeInputBounds | null, +): RuntimeInputPoint { + if (!hasUsableBounds(bounds)) { + return { clientX, clientY }; + } + + return { + clientX, + clientY, + normalizedX: (clientX - bounds.left) / bounds.width, + normalizedY: (clientY - bounds.top) / bounds.height, + }; +} + +export function createRuntimeInputPointFromNormalized( + normalizedX: number, + normalizedY: number, + bounds?: RuntimeInputBounds | null, +): RuntimeInputPoint { + const x = clamp01(normalizedX); + const y = clamp01(normalizedY); + if (!hasUsableBounds(bounds)) { + return { + clientX: x, + clientY: y, + normalizedX: x, + normalizedY: y, + }; + } + + return { + clientX: bounds.left + x * bounds.width, + clientY: bounds.top + y * bounds.height, + normalizedX: x, + normalizedY: y, + }; +} + +export function resolveRuntimeInputGridCell( + point: RuntimeInputPoint, + grid: RuntimeInputGridSpec, + bounds?: RuntimeInputBounds | null, +): RuntimeInputGridCell | null { + if (grid.rows <= 0 || grid.cols <= 0) { + return null; + } + + const normalizedX = + typeof point.normalizedX === 'number' + ? point.normalizedX + : hasUsableBounds(bounds) + ? (point.clientX - bounds.left) / bounds.width + : null; + const normalizedY = + typeof point.normalizedY === 'number' + ? point.normalizedY + : hasUsableBounds(bounds) + ? (point.clientY - bounds.top) / bounds.height + : null; + + if ( + normalizedX === null || + normalizedY === null || + !isFiniteNumber(normalizedX) || + !isFiniteNumber(normalizedY) || + normalizedX < 0 || + normalizedX > 1 || + normalizedY < 0 || + normalizedY > 1 + ) { + return null; + } + + return { + row: Math.min(grid.rows - 1, Math.floor(normalizedY * grid.rows)), + col: Math.min(grid.cols - 1, Math.floor(normalizedX * grid.cols)), + }; +}