master #14

Merged
kdletters merged 226 commits from master into release 2026-05-13 13:23:09 +08:00
12 changed files with 1095 additions and 179 deletions
Showing only changes of commit 86fc382413 - Show all commits

View File

@@ -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 图片生成。

View File

@@ -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` 对拼图作品缺失统一回首页。
## 边界

View File

@@ -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` 的接口、环境变量、尺寸映射、错误口径和验收命令。

View File

@@ -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"
```

View File

@@ -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,
],
);

View File

@@ -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(
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={onDragPiece}
onAdvanceNextLevel={vi.fn()}
/>,
);
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(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={onDragPiece}
onAdvanceNextLevel={vi.fn()}
/>
</AuthUiContext.Provider>,
);
mocapMock.x = 0.7;
mocapMock.y = 0.7;
rerender(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={onDragPiece}
onAdvanceNextLevel={vi.fn()}
/>
</AuthUiContext.Provider>,
);
mocapMock.state = 'open_palm';
rerender(
<AuthUiContext.Provider value={createAuthValue()}>
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={onDragPiece}
onAdvanceNextLevel={vi.fn()}
/>
</AuthUiContext.Provider>,
);
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: {

View File

@@ -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<string>();
@@ -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<string | null>(null);
const selectedPieceIdRef = useRef<string | null>(null);
const selectedPieceBeforeInputRef = useRef<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
useState(false);
@@ -354,7 +371,7 @@ export function PuzzleRuntimeShell({
const timeExpiredSyncKeyRef = useRef<string | null>(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<PuzzleMocapCursorState | null>(
null,
);
const mocapDragRef = useRef<{pieceId: string} | null>(null);
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController<string>(),
);
const draggingTargetRef = useRef<PuzzleRuntimeDragTargetState | null>(null);
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
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<PuzzleBoardPieceViewModel[]>(() => {
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<string> | 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 (
<div
@@ -821,131 +1076,12 @@ export function PuzzleRuntimeShell({
);
}
const handlePieceClick = (pieceId: string) => {
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<HTMLDivElement>,
) => {
const currentDragSession = dragSessionRef.current;
if (!currentDragSession || currentDragSession.pieceId !== pieceId) {
return;
}
const handlePiecePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>,
) => {
const dragSession = dragSessionRef.current;
if (
!dragSession ||
dragSession.pieceId !== pieceId ||
dragSession.pointerId !== event.pointerId
) {
return;
}
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
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 (
<div
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
@@ -1311,11 +1415,11 @@ export function PuzzleRuntimeShell({
if (!piece || isMerged) {
return;
}
handlePiecePointerMove(piece.pieceId, event);
handlePiecePointerMove(event);
}}
onPointerUp={(event) => {
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();

View File

@@ -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(<TestWrapper />);
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 = {

View File

@@ -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';

View File

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

View File

@@ -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<TTargetId extends string = string> = {
targetId: TTargetId;
inputId: string;
deviceKind: RuntimeInputDeviceKind;
startPoint: RuntimeInputPoint;
currentPoint: RuntimeInputPoint;
dragging: boolean;
forceDrop: boolean;
};
export type RuntimeDragInputPress<TTargetId extends string = string> = {
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<TTargetId>) => void;
onDragStart?: (session: RuntimeDragInputSession<TTargetId>) => void;
onDragMove?: (session: RuntimeDragInputSession<TTargetId>) => void;
onDrop?: (session: RuntimeDragInputSession<TTargetId>) => void;
onTap?: (session: RuntimeDragInputSession<TTargetId>) => void;
onCancel?: (session: RuntimeDragInputSession<TTargetId>) => 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<TTargetId> = {}) {
let options = initialOptions;
let session: RuntimeDragInputSession<TTargetId> | null = null;
const setOptions = (
nextOptions: RuntimeDragInputControllerOptions<TTargetId>,
) => {
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<TTargetId>) => {
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,
};
}

View File

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