) => {
event.preventDefault();
- const deltaX = event.clientX - dragSession.startX;
- const deltaY = event.clientY - dragSession.startY;
- const dragging = dragSession.dragging || Math.hypot(deltaX, deltaY) >= 8;
- dragSession.dragging = dragging;
- dragSession.currentX = event.clientX;
- dragSession.currentY = event.clientY;
- if (!dragging) {
- return;
- }
-
- // 首帧拖拽反馈立即落到 DOM,确保层级提升不会滞后一帧;后续仍保留 raf 兜底连续刷新。
- flushDragVisual();
- scheduleDragVisual();
+ runtimeDragInputControllerRef.current.move({
+ inputId: `pointer:${event.pointerId}`,
+ point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
+ });
};
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
@@ -1037,8 +1147,6 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
- const isInteractionLocked =
- isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const handleBackRequest = () => {
if (hideExitControls) {
return;
@@ -1150,10 +1258,6 @@ export function PuzzleRuntimeShell({
}
};
- useEffect(() => {
- handleMocapInputCommand();
- }, [mocapInput.latestCommand?.primaryHand]);
-
return (
{
if (piece && !isMerged) {
- handlePiecePointerUp(piece.pieceId, event);
+ handlePiecePointerUp(event);
}
}}
onPointerCancel={() => {
@@ -1461,10 +1565,10 @@ export function PuzzleRuntimeShell({
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
- handlePiecePointerMove(piece.pieceId, event);
+ handlePiecePointerMove(event);
}}
onPointerUp={(event) => {
- handlePiecePointerUp(piece.pieceId, event);
+ handlePiecePointerUp(event);
}}
onPointerCancel={() => {
resetDragInteraction();
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
index 14397ddd..2edf0d55 100644
--- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
+++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
@@ -4078,6 +4078,54 @@ test('public code search opens a published puzzle by PZ code', async () => {
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
+test('missing puzzle public detail returns to platform home', async () => {
+ const user = userEvent.setup();
+ const missingPuzzleWork = {
+ workId: 'puzzle-work-missing-1',
+ profileId: 'puzzle-profile-missing-1',
+ ownerUserId: 'user-2',
+ sourceSessionId: null,
+ authorDisplayName: '拼图作者',
+ levelName: '失效拼图',
+ summary: '这个作品已经不可用。',
+ themeTags: ['失效'],
+ coverImageSrc: null,
+ coverAssetId: null,
+ publicationStatus: 'published',
+ updatedAt: '2026-04-25T09:00:00.000Z',
+ publishedAt: '2026-04-25T09:00:00.000Z',
+ playCount: 1,
+ remixCount: 0,
+ likeCount: 0,
+ publishReady: true,
+ } satisfies PuzzleWorkSummary;
+
+ vi.mocked(listPuzzleGallery).mockResolvedValue({
+ items: [missingPuzzleWork],
+ });
+ vi.mocked(getPuzzleGalleryDetail).mockRejectedValueOnce(
+ new ApiClientError({
+ message: '资源不存在',
+ status: 404,
+ code: 'NOT_FOUND',
+ }),
+ );
+
+ render();
+ await openDiscoverHub(user);
+
+ const workCards = await screen.findAllByRole('button', { name: /失效拼图/u });
+ await user.click(workCards[0]!);
+
+ await waitFor(() => {
+ expect(window.location.pathname).toBe('/');
+ });
+ expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false');
+ expect(screen.queryByText('详情')).toBeNull();
+ expect(screen.queryByText('资源不存在')).toBeNull();
+ expect(startPuzzleRun).toHaveBeenCalledTimes(0);
+});
+
test('public code search opens a published big fish work by BF code', async () => {
const user = userEvent.setup();
const bigFishWork: BigFishWorkSummary = {
diff --git a/src/services/input-devices/index.ts b/src/services/input-devices/index.ts
new file mode 100644
index 00000000..d1d7e760
--- /dev/null
+++ b/src/services/input-devices/index.ts
@@ -0,0 +1,19 @@
+export {
+ createRuntimeDragInputController,
+ type RuntimeDragInputControllerOptions,
+ type RuntimeDragInputMove,
+ type RuntimeDragInputPress,
+ type RuntimeDragInputRelease,
+ type RuntimeDragInputSession,
+ type RuntimeInputDeviceKind,
+ type RuntimeInputPoint,
+} from './runtimeDragInputController';
+export {
+ createRuntimeInputPointFromClient,
+ createRuntimeInputPointFromNormalized,
+ readRuntimeInputElementBounds,
+ resolveRuntimeInputGridCell,
+ type RuntimeInputBounds,
+ type RuntimeInputGridCell,
+ type RuntimeInputGridSpec,
+} from './runtimeInputGeometry';
diff --git a/src/services/input-devices/runtimeDragInputController.test.ts b/src/services/input-devices/runtimeDragInputController.test.ts
new file mode 100644
index 00000000..61a3c4d4
--- /dev/null
+++ b/src/services/input-devices/runtimeDragInputController.test.ts
@@ -0,0 +1,161 @@
+import { describe, expect, test, vi } from 'vitest';
+
+import {
+ createRuntimeDragInputController,
+ createRuntimeInputPointFromNormalized,
+ resolveRuntimeInputGridCell,
+} from './index';
+
+describe('runtime drag input controller', () => {
+ test('pointer-like short press remains a tap', () => {
+ const onTap = vi.fn();
+ const onDrop = vi.fn();
+ const controller = createRuntimeDragInputController({
+ dragThresholdPx: 12,
+ onTap,
+ onDrop,
+ });
+
+ controller.press({
+ targetId: 'piece-1',
+ inputId: 'pointer:1',
+ deviceKind: 'pointer',
+ point: { clientX: 10, clientY: 10 },
+ });
+ controller.move({
+ inputId: 'pointer:1',
+ point: { clientX: 14, clientY: 14 },
+ });
+ controller.release({
+ inputId: 'pointer:1',
+ point: { clientX: 14, clientY: 14 },
+ });
+
+ expect(onTap).toHaveBeenCalledTimes(1);
+ expect(onTap).toHaveBeenCalledWith(
+ expect.objectContaining({ targetId: 'piece-1', dragging: false }),
+ );
+ expect(onDrop).not.toHaveBeenCalled();
+ });
+
+ test('device adapters can force continuous drag semantics', () => {
+ const onDragStart = vi.fn();
+ const onDragMove = vi.fn();
+ const onDrop = vi.fn();
+ const controller = createRuntimeDragInputController({
+ dragThresholdPx: 100,
+ onDragStart,
+ onDragMove,
+ onDrop,
+ });
+
+ controller.press({
+ targetId: 'piece-1',
+ inputId: 'mocap:hand',
+ deviceKind: 'mocap',
+ point: { clientX: 10, clientY: 10 },
+ });
+ controller.move({
+ inputId: 'mocap:hand',
+ point: { clientX: 11, clientY: 11 },
+ forceDragging: true,
+ });
+ controller.release({
+ inputId: 'mocap:hand',
+ point: { clientX: 12, clientY: 12 },
+ });
+
+ expect(onDragStart).toHaveBeenCalledTimes(1);
+ expect(onDragMove).toHaveBeenCalledTimes(1);
+ expect(onDrop).toHaveBeenCalledTimes(1);
+ expect(onDrop).toHaveBeenCalledWith(
+ expect.objectContaining({ deviceKind: 'mocap', dragging: true }),
+ );
+ });
+
+ test('device adapters can force drop on release without converting to tap', () => {
+ const onTap = vi.fn();
+ const onDrop = vi.fn();
+ const controller = createRuntimeDragInputController({
+ dragThresholdPx: 100,
+ onTap,
+ onDrop,
+ });
+
+ controller.press({
+ targetId: 'piece-1',
+ inputId: 'mocap:hand',
+ deviceKind: 'mocap',
+ point: { clientX: 10, clientY: 10 },
+ });
+ controller.release({
+ inputId: 'mocap:hand',
+ point: { clientX: 10, clientY: 10 },
+ forceDrop: true,
+ });
+
+ expect(onDrop).toHaveBeenCalledTimes(1);
+ expect(onDrop).toHaveBeenCalledWith(
+ expect.objectContaining({
+ targetId: 'piece-1',
+ forceDrop: true,
+ dragging: false,
+ }),
+ );
+ expect(onTap).not.toHaveBeenCalled();
+ });
+
+ test('input-scoped cancel keeps unrelated active sessions alive', () => {
+ const onCancel = vi.fn();
+ const onDrop = vi.fn();
+ const controller = createRuntimeDragInputController({
+ dragThresholdPx: 1,
+ onCancel,
+ onDrop,
+ });
+
+ controller.press({
+ targetId: 'piece-1',
+ inputId: 'pointer:1',
+ deviceKind: 'pointer',
+ point: { clientX: 10, clientY: 10 },
+ });
+ controller.cancel('mocap:hand');
+ controller.move({
+ inputId: 'pointer:1',
+ point: { clientX: 20, clientY: 20 },
+ });
+ controller.release({
+ inputId: 'pointer:1',
+ point: { clientX: 20, clientY: 20 },
+ });
+
+ expect(onCancel).not.toHaveBeenCalled();
+ expect(onDrop).toHaveBeenCalledTimes(1);
+ expect(onDrop).toHaveBeenCalledWith(
+ expect.objectContaining({ inputId: 'pointer:1', targetId: 'piece-1' }),
+ );
+ });
+});
+
+describe('runtime input geometry', () => {
+ test('normalised device coordinates map into client coordinates and grid cells', () => {
+ const point = createRuntimeInputPointFromNormalized(0.75, 0.25, {
+ left: 20,
+ top: 10,
+ width: 200,
+ height: 100,
+ });
+
+ expect(point).toEqual({
+ clientX: 170,
+ clientY: 35,
+ normalizedX: 0.75,
+ normalizedY: 0.25,
+ });
+ expect(resolveRuntimeInputGridCell(point, { rows: 4, cols: 4 })).toEqual({
+ row: 1,
+ col: 3,
+ });
+ });
+});
diff --git a/src/services/input-devices/runtimeDragInputController.ts b/src/services/input-devices/runtimeDragInputController.ts
new file mode 100644
index 00000000..cbaf1fc3
--- /dev/null
+++ b/src/services/input-devices/runtimeDragInputController.ts
@@ -0,0 +1,168 @@
+export type RuntimeInputDeviceKind = 'pointer' | 'mocap' | 'keyboard' | 'unknown';
+
+export type RuntimeInputPoint = {
+ clientX: number;
+ clientY: number;
+ normalizedX?: number;
+ normalizedY?: number;
+};
+
+export type RuntimeDragInputSession = {
+ targetId: TTargetId;
+ inputId: string;
+ deviceKind: RuntimeInputDeviceKind;
+ startPoint: RuntimeInputPoint;
+ currentPoint: RuntimeInputPoint;
+ dragging: boolean;
+ forceDrop: boolean;
+};
+
+export type RuntimeDragInputPress = {
+ targetId: TTargetId;
+ inputId: string;
+ deviceKind: RuntimeInputDeviceKind;
+ point: RuntimeInputPoint;
+};
+
+export type RuntimeDragInputMove = {
+ inputId: string;
+ point: RuntimeInputPoint;
+ forceDragging?: boolean;
+};
+
+export type RuntimeDragInputRelease = {
+ inputId: string;
+ point: RuntimeInputPoint;
+ forceDrop?: boolean;
+};
+
+export type RuntimeDragInputControllerOptions<
+ TTargetId extends string = string,
+> = {
+ dragThresholdPx?: number;
+ onPress?: (session: RuntimeDragInputSession) => void;
+ onDragStart?: (session: RuntimeDragInputSession) => void;
+ onDragMove?: (session: RuntimeDragInputSession) => void;
+ onDrop?: (session: RuntimeDragInputSession) => void;
+ onTap?: (session: RuntimeDragInputSession) => void;
+ onCancel?: (session: RuntimeDragInputSession) => void;
+};
+
+const DEFAULT_DRAG_THRESHOLD_PX = 8;
+
+function clonePoint(point: RuntimeInputPoint): RuntimeInputPoint {
+ return { ...point };
+}
+
+function shouldStartDragging(
+ session: RuntimeDragInputSession,
+ point: RuntimeInputPoint,
+ thresholdPx: number,
+ forceDragging = false,
+) {
+ if (session.dragging || forceDragging) {
+ return true;
+ }
+
+ return (
+ Math.hypot(
+ point.clientX - session.startPoint.clientX,
+ point.clientY - session.startPoint.clientY,
+ ) >= thresholdPx
+ );
+}
+
+export function createRuntimeDragInputController<
+ TTargetId extends string = string,
+>(initialOptions: RuntimeDragInputControllerOptions = {}) {
+ let options = initialOptions;
+ let session: RuntimeDragInputSession | null = null;
+
+ const setOptions = (
+ nextOptions: RuntimeDragInputControllerOptions,
+ ) => {
+ options = nextOptions;
+ };
+
+ const cancel = (inputId?: string) => {
+ if (inputId && session?.inputId !== inputId) {
+ return;
+ }
+
+ const activeSession = session;
+ session = null;
+ if (activeSession) {
+ options.onCancel?.(activeSession);
+ }
+ };
+
+ const press = (input: RuntimeDragInputPress) => {
+ cancel();
+ session = {
+ targetId: input.targetId,
+ inputId: input.inputId,
+ deviceKind: input.deviceKind,
+ startPoint: clonePoint(input.point),
+ currentPoint: clonePoint(input.point),
+ dragging: false,
+ forceDrop: false,
+ };
+ options.onPress?.(session);
+ return session;
+ };
+
+ const move = (input: RuntimeDragInputMove) => {
+ if (!session || session.inputId !== input.inputId) {
+ return null;
+ }
+
+ const wasDragging = session.dragging;
+ session = {
+ ...session,
+ currentPoint: clonePoint(input.point),
+ dragging: shouldStartDragging(
+ session,
+ input.point,
+ options.dragThresholdPx ?? DEFAULT_DRAG_THRESHOLD_PX,
+ input.forceDragging,
+ ),
+ };
+
+ if (!wasDragging && session.dragging) {
+ options.onDragStart?.(session);
+ }
+ if (session.dragging) {
+ options.onDragMove?.(session);
+ }
+
+ return session;
+ };
+
+ const release = (input: RuntimeDragInputRelease) => {
+ if (!session || session.inputId !== input.inputId) {
+ return null;
+ }
+
+ const completedSession = {
+ ...session,
+ currentPoint: clonePoint(input.point),
+ forceDrop: input.forceDrop === true,
+ };
+ session = null;
+ if (completedSession.dragging || completedSession.forceDrop) {
+ options.onDrop?.(completedSession);
+ } else {
+ options.onTap?.(completedSession);
+ }
+ return completedSession;
+ };
+
+ return {
+ cancel,
+ getSession: () => session,
+ move,
+ press,
+ release,
+ setOptions,
+ };
+}
diff --git a/src/services/input-devices/runtimeInputGeometry.ts b/src/services/input-devices/runtimeInputGeometry.ts
new file mode 100644
index 00000000..c430d6fd
--- /dev/null
+++ b/src/services/input-devices/runtimeInputGeometry.ts
@@ -0,0 +1,142 @@
+import type { RuntimeInputPoint } from './runtimeDragInputController';
+
+export type RuntimeInputBounds = {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+};
+
+export type RuntimeInputGridSpec = {
+ rows: number;
+ cols: number;
+};
+
+export type RuntimeInputGridCell = {
+ row: number;
+ col: number;
+};
+
+function isFiniteNumber(value: number) {
+ return Number.isFinite(value);
+}
+
+function clamp01(value: number) {
+ return Math.min(1, Math.max(0, value));
+}
+
+function hasUsableBounds(
+ bounds: RuntimeInputBounds | null | undefined,
+): bounds is RuntimeInputBounds {
+ return Boolean(
+ bounds &&
+ isFiniteNumber(bounds.left) &&
+ isFiniteNumber(bounds.top) &&
+ isFiniteNumber(bounds.width) &&
+ isFiniteNumber(bounds.height) &&
+ bounds.width > 0 &&
+ bounds.height > 0,
+ );
+}
+
+export function readRuntimeInputElementBounds(
+ element: Element | null | undefined,
+): RuntimeInputBounds | null {
+ if (!element) {
+ return null;
+ }
+
+ const rect = element.getBoundingClientRect();
+ if (!rect.width || !rect.height) {
+ return null;
+ }
+
+ return {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ };
+}
+
+export function createRuntimeInputPointFromClient(
+ clientX: number,
+ clientY: number,
+ bounds?: RuntimeInputBounds | null,
+): RuntimeInputPoint {
+ if (!hasUsableBounds(bounds)) {
+ return { clientX, clientY };
+ }
+
+ return {
+ clientX,
+ clientY,
+ normalizedX: (clientX - bounds.left) / bounds.width,
+ normalizedY: (clientY - bounds.top) / bounds.height,
+ };
+}
+
+export function createRuntimeInputPointFromNormalized(
+ normalizedX: number,
+ normalizedY: number,
+ bounds?: RuntimeInputBounds | null,
+): RuntimeInputPoint {
+ const x = clamp01(normalizedX);
+ const y = clamp01(normalizedY);
+ if (!hasUsableBounds(bounds)) {
+ return {
+ clientX: x,
+ clientY: y,
+ normalizedX: x,
+ normalizedY: y,
+ };
+ }
+
+ return {
+ clientX: bounds.left + x * bounds.width,
+ clientY: bounds.top + y * bounds.height,
+ normalizedX: x,
+ normalizedY: y,
+ };
+}
+
+export function resolveRuntimeInputGridCell(
+ point: RuntimeInputPoint,
+ grid: RuntimeInputGridSpec,
+ bounds?: RuntimeInputBounds | null,
+): RuntimeInputGridCell | null {
+ if (grid.rows <= 0 || grid.cols <= 0) {
+ return null;
+ }
+
+ const normalizedX =
+ typeof point.normalizedX === 'number'
+ ? point.normalizedX
+ : hasUsableBounds(bounds)
+ ? (point.clientX - bounds.left) / bounds.width
+ : null;
+ const normalizedY =
+ typeof point.normalizedY === 'number'
+ ? point.normalizedY
+ : hasUsableBounds(bounds)
+ ? (point.clientY - bounds.top) / bounds.height
+ : null;
+
+ if (
+ normalizedX === null ||
+ normalizedY === null ||
+ !isFiniteNumber(normalizedX) ||
+ !isFiniteNumber(normalizedY) ||
+ normalizedX < 0 ||
+ normalizedX > 1 ||
+ normalizedY < 0 ||
+ normalizedY > 1
+ ) {
+ return null;
+ }
+
+ return {
+ row: Math.min(grid.rows - 1, Math.floor(normalizedY * grid.rows)),
+ col: Math.min(grid.cols - 1, Math.floor(normalizedX * grid.cols)),
+ };
+}