feat: add shared runtime input device layer
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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
|
## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine
|
||||||
|
|
||||||
- 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。
|
- 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
7. 当前作品没有下一关时,通关弹窗展示后端 handoff 返回的相似作品;用户点击具体候选作品时直接 `startPuzzleRun(profileId, null)`,从目标作品第 `1` 关重新开始。
|
7. 当前作品没有下一关时,通关弹窗展示后端 handoff 返回的相似作品;用户点击具体候选作品时直接 `startPuzzleRun(profileId, null)`,从目标作品第 `1` 关重新开始。
|
||||||
8. 失败状态点击“重新开始”时,正式 run 使用当前关 `levelId` 重新 `startPuzzleRun`,草稿/本地 run 使用本地重建,二者都保留当前失败关卡。
|
8. 失败状态点击“重新开始”时,正式 run 使用当前关 `levelId` 重新 `startPuzzleRun`,草稿/本地 run 使用本地重建,二者都保留当前失败关卡。
|
||||||
9. 结果页草稿试玩没有正式后端 run 时,继续使用本地 run、local leaderboard 和本地下一关兜底。
|
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`
|
3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
|
||||||
- 公开拼图玩法交互测试断言前端本地交换函数被调用。
|
- 公开拼图玩法交互测试断言前端本地交换函数被调用。
|
||||||
- 同时断言后端 `swap / drag` 不参与棋盘交互,后端 `leaderboard / next-level` 继续参与非即时链路。
|
- 同时断言后端 `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` 对拼图作品缺失统一回首页。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
|
|||||||
@@ -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 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。
|
- [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 差异。
|
- [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 排障命令。
|
- [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` 的接口、环境变量、尺寸映射、错误口径和验收命令。
|
- [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` 的接口、环境变量、尺寸映射、错误口径和验收命令。
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -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() {
|
function hasSeenPuzzleOnboarding() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return true;
|
return true;
|
||||||
@@ -4062,6 +4075,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} 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, '启动拼图玩法失败。');
|
const message = resolvePuzzleErrorMessage(error, '启动拼图玩法失败。');
|
||||||
setPuzzleError(message);
|
setPuzzleError(message);
|
||||||
if (mirrorErrorToPublicDetail) {
|
if (mirrorErrorToPublicDetail) {
|
||||||
@@ -4077,8 +4103,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
resolvePuzzleErrorMessage,
|
resolvePuzzleErrorMessage,
|
||||||
setIsPuzzleBusy,
|
setIsPuzzleBusy,
|
||||||
setPuzzleError,
|
setPuzzleError,
|
||||||
|
setPlatformTab,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
startPuzzleRun,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5517,6 +5543,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleDetailReturnTarget(returnTarget);
|
setPuzzleDetailReturnTarget(returnTarget);
|
||||||
openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item));
|
openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isMissingPuzzleWorkError(error)) {
|
||||||
|
setSelectedPuzzleDetail(null);
|
||||||
|
setPuzzleDetailReturnTarget(null);
|
||||||
|
setPuzzleRun(null);
|
||||||
|
setPuzzleRuntimeAuthMode('default');
|
||||||
|
setPuzzleError(null);
|
||||||
|
setPublicWorkDetailError(null);
|
||||||
|
setPlatformTab('home');
|
||||||
|
setSelectionStage('platform');
|
||||||
|
pushAppHistoryPath('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setPublicWorkDetailError(
|
setPublicWorkDetailError(
|
||||||
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
|
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
|
||||||
);
|
);
|
||||||
@@ -5531,6 +5570,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
resolvePuzzleErrorMessage,
|
resolvePuzzleErrorMessage,
|
||||||
setIsPuzzleBusy,
|
setIsPuzzleBusy,
|
||||||
setPuzzleError,
|
setPuzzleError,
|
||||||
|
setPlatformTab,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -5722,6 +5762,19 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} 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, '读取拼图详情失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsPuzzleBusy(false);
|
setIsPuzzleBusy(false);
|
||||||
@@ -5732,6 +5785,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
resolvePuzzleErrorMessage,
|
resolvePuzzleErrorMessage,
|
||||||
setIsPuzzleBusy,
|
setIsPuzzleBusy,
|
||||||
setPuzzleError,
|
setPuzzleError,
|
||||||
|
setPlatformTab,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -207,8 +207,11 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
|||||||
|
|
||||||
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
||||||
expect(cursor).toBeTruthy();
|
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.state = 'grab';
|
||||||
|
mocapMock.x = 0.42;
|
||||||
|
mocapMock.y = 0.58;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('抓握时会触发拖拽提交并在松开时落子', () => {
|
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('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const onAdvanceNextLevel = vi.fn();
|
const onAdvanceNextLevel = vi.fn();
|
||||||
@@ -820,6 +961,9 @@ test('移动端点击拼图片时立即触发一次震动反馈', () => {
|
|||||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
const vibrate = vi.fn();
|
const vibrate = vi.fn();
|
||||||
|
mocapMock.state = 'open_palm';
|
||||||
|
mocapMock.x = 0.42;
|
||||||
|
mocapMock.y = 0.58;
|
||||||
const playingRun: PuzzleRunSnapshot = {
|
const playingRun: PuzzleRunSnapshot = {
|
||||||
...clearedRun,
|
...clearedRun,
|
||||||
currentLevel: {
|
currentLevel: {
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ import type {
|
|||||||
SwapPuzzlePiecesRequest,
|
SwapPuzzlePiecesRequest,
|
||||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
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 { useMocapInput } from '../../services/useMocapInput';
|
||||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
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_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
|
||||||
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
|
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
|
||||||
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
|
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
|
||||||
|
const PUZZLE_MOCAP_DRAG_INPUT_ID = 'mocap:primary-hand';
|
||||||
|
|
||||||
const shownExitRemodelPromptProfileIds = new Set<string>();
|
const shownExitRemodelPromptProfileIds = new Set<string>();
|
||||||
|
|
||||||
@@ -290,6 +300,11 @@ type PuzzleMocapCursorState = {
|
|||||||
state: string;
|
state: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PuzzleRuntimeDragTargetState = {
|
||||||
|
pieceId: string;
|
||||||
|
groupId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function triggerPuzzlePiecePressHapticFeedback() {
|
function triggerPuzzlePiecePressHapticFeedback() {
|
||||||
if (typeof navigator === 'undefined') {
|
if (typeof navigator === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -328,6 +343,8 @@ export function PuzzleRuntimeShell({
|
|||||||
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
|
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
|
||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
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 [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||||
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
|
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -354,7 +371,7 @@ export function PuzzleRuntimeShell({
|
|||||||
const timeExpiredSyncKeyRef = useRef<string | null>(null);
|
const timeExpiredSyncKeyRef = useRef<string | null>(null);
|
||||||
const dragSessionRef = useRef<{
|
const dragSessionRef = useRef<{
|
||||||
pieceId: string;
|
pieceId: string;
|
||||||
pointerId: number;
|
inputId: string;
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
@@ -377,7 +394,10 @@ export function PuzzleRuntimeShell({
|
|||||||
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
|
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
|
||||||
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>(
|
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -400,6 +420,8 @@ export function PuzzleRuntimeShell({
|
|||||||
? 'failed'
|
? 'failed'
|
||||||
: currentLevel.status
|
: currentLevel.status
|
||||||
: 'playing';
|
: 'playing';
|
||||||
|
const isInteractionLocked =
|
||||||
|
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||||
const clearResultKey = currentLevel
|
const clearResultKey = currentLevel
|
||||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||||
: null;
|
: null;
|
||||||
@@ -409,12 +431,19 @@ export function PuzzleRuntimeShell({
|
|||||||
currentLevel?.coverImageSrc ?? null,
|
currentLevel?.coverImageSrc ?? null,
|
||||||
);
|
);
|
||||||
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
|
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 =
|
const mocapActionsLabel =
|
||||||
mocapInput.latestCommand?.actions.length
|
mocapInput.latestCommand?.actions.length
|
||||||
? mocapInput.latestCommand.actions.join(', ')
|
? mocapInput.latestCommand.actions.join(', ')
|
||||||
: '无';
|
: '无';
|
||||||
const mocapHandLabel = mocapInput.latestCommand?.primaryHand
|
const mocapHandLabel =
|
||||||
? `${mocapInput.latestCommand.primaryHand.state} @ ${mocapInput.latestCommand.primaryHand.x.toFixed(2)}, ${mocapInput.latestCommand.primaryHand.y.toFixed(2)}`
|
primaryMocapHandState &&
|
||||||
|
typeof primaryMocapHandX === 'number' &&
|
||||||
|
typeof primaryMocapHandY === 'number'
|
||||||
|
? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}`
|
||||||
: '无';
|
: '无';
|
||||||
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
|
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
|
||||||
? mocapInput.latestCommand.parseWarnings.join(';')
|
? mocapInput.latestCommand.parseWarnings.join(';')
|
||||||
@@ -425,6 +454,11 @@ export function PuzzleRuntimeShell({
|
|||||||
currentLevelRef.current = currentLevel;
|
currentLevelRef.current = currentLevel;
|
||||||
}, [currentLevel]);
|
}, [currentLevel]);
|
||||||
|
|
||||||
|
const commitSelectedPieceId = (pieceId: string | null) => {
|
||||||
|
selectedPieceIdRef.current = pieceId;
|
||||||
|
setSelectedPieceId(pieceId);
|
||||||
|
};
|
||||||
|
|
||||||
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
|
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
|
||||||
if (!board) {
|
if (!board) {
|
||||||
return [];
|
return [];
|
||||||
@@ -586,13 +620,18 @@ export function PuzzleRuntimeShell({
|
|||||||
dragVisualFrameRef.current = null;
|
dragVisualFrameRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetDragInteraction = () => {
|
const resetDragInteractionState = () => {
|
||||||
cancelDragVisualFrame();
|
cancelDragVisualFrame();
|
||||||
dragOffsetRef.current = null;
|
dragOffsetRef.current = null;
|
||||||
dragSessionRef.current = null;
|
dragSessionRef.current = null;
|
||||||
|
draggingTargetRef.current = null;
|
||||||
resetDragVisualTarget();
|
resetDragVisualTarget();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetDragInteraction = () => {
|
||||||
|
runtimeDragInputControllerRef.current.cancel();
|
||||||
|
};
|
||||||
|
|
||||||
const flushDragVisual = () => {
|
const flushDragVisual = () => {
|
||||||
dragVisualFrameRef.current = null;
|
dragVisualFrameRef.current = null;
|
||||||
const dragSession = dragSessionRef.current;
|
const dragSession = dragSessionRef.current;
|
||||||
@@ -602,7 +641,8 @@ export function PuzzleRuntimeShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const piece = pieceById.get(dragSession.pieceId) ?? null;
|
const piece = pieceById.get(dragSession.pieceId) ?? null;
|
||||||
const groupId = piece?.mergedGroupId ?? null;
|
const groupId =
|
||||||
|
draggingTargetRef.current?.groupId ?? piece?.mergedGroupId ?? null;
|
||||||
const nextTarget = {
|
const nextTarget = {
|
||||||
pieceId: dragSession.pieceId,
|
pieceId: dragSession.pieceId,
|
||||||
groupId,
|
groupId,
|
||||||
@@ -808,6 +848,221 @@ export function PuzzleRuntimeShell({
|
|||||||
];
|
];
|
||||||
}, [clearResultKey, currentLevel, dismissedClearKey]);
|
}, [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) {
|
if (!run || !currentLevel || !board) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -821,131 +1076,12 @@ export function PuzzleRuntimeShell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePieceClick = (pieceId: string) => {
|
const handlePiecePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||||
|
runtimeDragInputControllerRef.current.release({
|
||||||
if (currentDragSession.dragging) {
|
inputId: `pointer:${event.pointerId}`,
|
||||||
const targetCell = resolveBoardCellFromPointer(
|
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
|
||||||
event.clientX,
|
});
|
||||||
event.clientY,
|
|
||||||
);
|
|
||||||
resetDragInteraction();
|
|
||||||
if (targetCell) {
|
|
||||||
onDragPiece({
|
|
||||||
pieceId,
|
|
||||||
targetRow: targetCell.row,
|
|
||||||
targetCol: targetCell.col,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setSelectedPieceId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetDragInteraction();
|
|
||||||
handlePieceClick(pieceId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePiecePointerDown = (
|
const handlePiecePointerDown = (
|
||||||
@@ -958,46 +1094,20 @@ export function PuzzleRuntimeShell({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
resetDragInteraction();
|
resetDragInteraction();
|
||||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
// 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。
|
runtimeDragInputControllerRef.current.press({
|
||||||
triggerPuzzlePiecePressHapticFeedback();
|
targetId: pieceId,
|
||||||
dragSessionRef.current = {
|
inputId: `pointer:${event.pointerId}`,
|
||||||
pieceId,
|
deviceKind: 'pointer',
|
||||||
pointerId: event.pointerId,
|
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
|
||||||
dragging: false,
|
});
|
||||||
startX: event.clientX,
|
|
||||||
startY: event.clientY,
|
|
||||||
currentX: event.clientX,
|
|
||||||
currentY: event.clientY,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePiecePointerMove = (
|
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
pieceId: string,
|
|
||||||
event: React.PointerEvent<HTMLDivElement>,
|
|
||||||
) => {
|
|
||||||
const dragSession = dragSessionRef.current;
|
|
||||||
if (
|
|
||||||
!dragSession ||
|
|
||||||
dragSession.pieceId !== pieceId ||
|
|
||||||
dragSession.pointerId !== event.pointerId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const deltaX = event.clientX - dragSession.startX;
|
runtimeDragInputControllerRef.current.move({
|
||||||
const deltaY = event.clientY - dragSession.startY;
|
inputId: `pointer:${event.pointerId}`,
|
||||||
const dragging = dragSession.dragging || Math.hypot(deltaX, deltaY) >= 8;
|
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
|
||||||
dragSession.dragging = dragging;
|
});
|
||||||
dragSession.currentX = event.clientX;
|
|
||||||
dragSession.currentY = event.clientY;
|
|
||||||
if (!dragging) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 首帧拖拽反馈立即落到 DOM,确保层级提升不会滞后一帧;后续仍保留 raf 兜底连续刷新。
|
|
||||||
flushDragVisual();
|
|
||||||
scheduleDragVisual();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
||||||
@@ -1037,8 +1147,6 @@ export function PuzzleRuntimeShell({
|
|||||||
currentLevel.status === 'cleared' &&
|
currentLevel.status === 'cleared' &&
|
||||||
dismissedClearKey !== clearResultKey &&
|
dismissedClearKey !== clearResultKey &&
|
||||||
isClearResultReady;
|
isClearResultReady;
|
||||||
const isInteractionLocked =
|
|
||||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
|
||||||
const handleBackRequest = () => {
|
const handleBackRequest = () => {
|
||||||
if (hideExitControls) {
|
if (hideExitControls) {
|
||||||
return;
|
return;
|
||||||
@@ -1150,10 +1258,6 @@ export function PuzzleRuntimeShell({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleMocapInputCommand();
|
|
||||||
}, [mocapInput.latestCommand?.primaryHand]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
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) {
|
if (!piece || isMerged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handlePiecePointerMove(piece.pieceId, event);
|
handlePiecePointerMove(event);
|
||||||
}}
|
}}
|
||||||
onPointerUp={(event) => {
|
onPointerUp={(event) => {
|
||||||
if (piece && !isMerged) {
|
if (piece && !isMerged) {
|
||||||
handlePiecePointerUp(piece.pieceId, event);
|
handlePiecePointerUp(event);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => {
|
onPointerCancel={() => {
|
||||||
@@ -1461,10 +1565,10 @@ export function PuzzleRuntimeShell({
|
|||||||
handlePiecePointerDown(piece.pieceId, event);
|
handlePiecePointerDown(piece.pieceId, event);
|
||||||
}}
|
}}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
handlePiecePointerMove(piece.pieceId, event);
|
handlePiecePointerMove(event);
|
||||||
}}
|
}}
|
||||||
onPointerUp={(event) => {
|
onPointerUp={(event) => {
|
||||||
handlePiecePointerUp(piece.pieceId, event);
|
handlePiecePointerUp(event);
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => {
|
onPointerCancel={() => {
|
||||||
resetDragInteraction();
|
resetDragInteraction();
|
||||||
|
|||||||
@@ -4078,6 +4078,54 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
|||||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
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 () => {
|
test('public code search opens a published big fish work by BF code', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const bigFishWork: BigFishWorkSummary = {
|
const bigFishWork: BigFishWorkSummary = {
|
||||||
|
|||||||
19
src/services/input-devices/index.ts
Normal file
19
src/services/input-devices/index.ts
Normal 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';
|
||||||
161
src/services/input-devices/runtimeDragInputController.test.ts
Normal file
161
src/services/input-devices/runtimeDragInputController.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
168
src/services/input-devices/runtimeDragInputController.ts
Normal file
168
src/services/input-devices/runtimeDragInputController.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
142
src/services/input-devices/runtimeInputGeometry.ts
Normal file
142
src/services/input-devices/runtimeInputGeometry.ts
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user