This commit is contained in:
2026-05-10 22:28:43 +08:00
46 changed files with 5894 additions and 341 deletions

View File

@@ -0,0 +1,5 @@
import { ChildMotionWarmupDemo } from './components/child-motion-demo/ChildMotionWarmupDemo';
export default function ChildMotionDemoApp() {
return <ChildMotionWarmupDemo />;
}

View File

@@ -0,0 +1,364 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo';
import {
markChildMotionWarmupCompletedInRuntime,
resetChildMotionWarmupRuntimeSession,
} from './childMotionWarmupModel';
const mocapMock = vi.hoisted(() => ({
status: 'connected' as 'idle' | 'connecting' | 'connected' | 'error',
command: null as null | {
actions: string[];
hands?: Array<{ x: number; y: number; state: string; side: string }>;
primaryHand?: { x: number; y: number; state: string; side: string } | null;
leftHand?: { x: number; y: number; state: string; side: string } | null;
rightHand?: { x: number; y: number; state: string; side: string } | null;
bodyCenter?: { x: number; y: number } | null;
},
receivedAtMs: 1,
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: ({ enabled }: { enabled: boolean }) => ({
status: enabled ? mocapMock.status : 'idle',
latestCommand: enabled ? mocapMock.command : null,
rawPacketPreview:
enabled && mocapMock.command
? {
text: JSON.stringify(mocapMock.command),
receivedAtMs: mocapMock.receivedAtMs,
}
: null,
error: null,
}),
}));
beforeEach(() => {
resetChildMotionWarmupRuntimeSession();
vi.restoreAllMocks();
mocapMock.status = 'connected';
mocapMock.command = null;
mocapMock.receivedAtMs = 1;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: undefined,
});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test('renders the warmup stage and starts with the center ring step', () => {
render(<ChildMotionWarmupDemo />);
expect(screen.getByTestId('child-motion-demo')).toBeTruthy();
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
expect(screen.getByLabelText('绿色圆环')).toBeTruthy();
expect(screen.getByText('请横屏体验')).toBeTruthy();
});
test('re-entering within the same runtime session opens the start button', () => {
markChildMotionWarmupCompletedInRuntime();
render(<ChildMotionWarmupDemo />);
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
});
test('developer keyboard input moves the avatar and triggers jump state', () => {
render(<ChildMotionWarmupDemo />);
const avatar = screen.getByTestId('child-motion-avatar');
fireEvent.keyDown(window, { key: 'a', code: 'KeyA' });
expect(avatar.getAttribute('style')).toContain('left: 34%');
fireEvent.keyDown(window, { key: 'd', code: 'KeyD' });
expect(avatar.getAttribute('style')).toContain('left: 66%');
fireEvent.keyUp(window, { key: 'd', code: 'KeyD' });
expect(avatar.getAttribute('style')).toContain('left: 50%');
fireEvent.keyDown(window, { key: ' ', code: 'Space' });
expect(avatar.className).toContain('child-motion-avatar--jumping');
});
test('mocap body center keeps the warmup flow on the motion data source', async () => {
vi.useFakeTimers();
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull();
expect(screen.queryByText('动作数据已连接,等待识别')).toBeNull();
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 50%',
);
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
mocapMock.command = {
actions: ['open_palm'],
bodyCenter: { x: 0.5, y: 0.6 },
hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
await act(async () => {
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '向左一步' })).toBeTruthy();
});
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.34, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await vi.waitFor(() => {
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 34%',
);
});
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '回到中间来' })).toBeTruthy();
});
await act(async () => {
unmount();
});
vi.useRealTimers();
});
test('mocap open palm completes the greeting wave step', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
mocapMock.command = {
actions: ['open_palm'],
hands: [{ x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.46, y: 0.34, state: 'open_palm', side: 'left' },
leftHand: { x: 0.46, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
await act(async () => {
unmount();
});
vi.useRealTimers();
});
test('mocap hand tracks complete left and right wave steps only after movement is visible', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
const advancePositionStep = async (key: string, code: string) => {
await act(async () => {
fireEvent.keyDown(window, { key, code });
});
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await act(async () => {
fireEvent.keyUp(window, { key, code });
});
};
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
mocapMock.command = {
actions: ['open_palm'],
hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await act(async () => {
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await advancePositionStep('a', 'KeyA');
await act(async () => {
vi.advanceTimersByTime(120);
await vi.runOnlyPendingTimersAsync();
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await advancePositionStep('d', 'KeyD');
await act(async () => {
vi.advanceTimersByTime(120);
await vi.runOnlyPendingTimersAsync();
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' },
primaryHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' },
primaryHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' },
primaryHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy();
});
mocapMock.command = {
actions: ['right_hand_wave'],
leftHand: null,
primaryHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' },
rightHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' },
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy();
});
await act(async () => {
vi.advanceTimersByTime(720);
await vi.runOnlyPendingTimersAsync();
unmount();
});
vi.useRealTimers();
});
test('connects camera stream and releases it on unmount', async () => {
const stopTrack = vi.fn();
const stream = {
getTracks: () => [
{
stop: stopTrack,
},
],
} as unknown as MediaStream;
const getUserMedia = vi.fn().mockResolvedValue(stream);
const play = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia,
},
});
const { unmount } = render(<ChildMotionWarmupDemo />);
expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull();
expect(screen.getByText('动作数据已连接,等待识别')).toBeTruthy();
await vi.waitFor(() => {
expect(getUserMedia).toHaveBeenCalledWith({
audio: false,
video: {
facingMode: 'user',
},
});
expect(play).toHaveBeenCalled();
});
unmount();
expect(stopTrack).toHaveBeenCalled();
});

View File

@@ -0,0 +1,870 @@
import type {
CSSProperties,
PointerEvent as ReactPointerEvent,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
MocapConnectionStatus,
MocapHandInput,
MocapInputCommand,
} from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput';
import {
applyChildMotionWarmupCompletion,
CHILD_MOTION_CENTER_X,
CHILD_MOTION_FINISH_DURATION_MS,
CHILD_MOTION_HOLD_DURATION_MS,
CHILD_MOTION_NARRATION_DURATION_MS,
type ChildMotionPoint,
type ChildMotionWarmupCalibration,
type ChildMotionWarmupStepId,
createEmptyChildMotionCalibration,
getChildMotionTargetX,
getChildMotionWarmupStep,
hasCompletedChildMotionWarmupInRuntime,
isAvatarOnWarmupTarget,
markChildMotionWarmupCompletedInRuntime,
resolveNextChildMotionWarmupStep,
} from './childMotionWarmupModel';
type DragHand = 'left' | 'right';
type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump';
const WARMUP_MOCAP_WAVE_MIN_POINTS = 3;
const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055;
function clampMotionUnit(value: number) {
return Math.max(0, Math.min(1, value));
}
function normalizePointerPoint(
event: ReactPointerEvent<HTMLElement>,
element: HTMLElement,
): ChildMotionPoint {
const rect = element.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
return {
x: clampMotionUnit((event.clientX - rect.left) / width),
y: clampMotionUnit((event.clientY - rect.top) / height),
};
}
function formatPercent(value: number | null) {
if (value === null) {
return '--';
}
return `${Math.round(value * 100)}%`;
}
function mocapHandToChildMotionPoint(
hand: MocapHandInput | null | undefined,
): ChildMotionPoint | null {
if (!hand) {
return null;
}
return {
x: clampMotionUnit(hand.x),
y: clampMotionUnit(hand.y),
};
}
function appendWarmupMocapPoint(
points: ChildMotionPoint[],
point: ChildMotionPoint,
) {
return [...points, point].slice(-16);
}
function getMotionSourceState(
mocapStatus: MocapConnectionStatus,
latestCommand: MocapInputCommand | null,
): MotionSourceState {
if (mocapStatus === 'connecting' || mocapStatus === 'idle') {
return 'connecting';
}
if (mocapStatus === 'connected') {
return latestCommand &&
(Boolean(latestCommand.bodyCenter) ||
Boolean(latestCommand.hands?.length) ||
latestCommand.actions.length > 0)
? 'ready'
: 'waiting';
}
return 'offline';
}
function getMotionSourceText(state: MotionSourceState) {
if (state === 'ready') {
return '动作数据已连接';
}
if (state === 'waiting') {
return '动作数据已连接,等待识别';
}
if (state === 'offline') {
return '动作数据暂不可用,已保留本地调试';
}
return '正在连接动作数据';
}
function hasWarmupMocapAction(
command: MocapInputCommand,
expectedActions: string[],
) {
return command.actions.some((action) => expectedActions.includes(action));
}
function hasWarmupMocapWavePath(points: ChildMotionPoint[]) {
if (points.length < WARMUP_MOCAP_WAVE_MIN_POINTS) {
return false;
}
const xValues = points.map((point) => point.x);
return (
Math.max(...xValues) - Math.min(...xValues) >=
WARMUP_MOCAP_WAVE_MIN_X_RANGE
);
}
function resolveAvatarXFromMocap(command: MocapInputCommand) {
return command.bodyCenter?.x ?? null;
}
function resolveWarmupMocapGestureIntent(
stepId: ChildMotionWarmupStepId,
command: MocapInputCommand,
paths: {
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
primaryHandPath: ChildMotionPoint[];
},
): WarmupMocapGestureIntent | null {
if (stepId === 'wave_greeting') {
if (
hasWarmupMocapAction(command, [
'wave',
'wave_greeting',
'hand_wave',
'hello',
'greeting',
'open_palm',
'handwave',
'wavehand',
'招手',
'挥手',
]) ||
command.hands?.some((hand) => hand.state === 'open_palm') ||
hasWarmupMocapWavePath(paths.leftHandPath) ||
hasWarmupMocapWavePath(paths.rightHandPath) ||
hasWarmupMocapWavePath(paths.primaryHandPath)
) {
return 'greeting';
}
}
if (
stepId === 'wave_left_hand' &&
(hasWarmupMocapAction(command, [
'left_wave',
'wave_left',
'left_hand_wave',
'wave_left_hand',
'left_handwave',
'lefthand_wave',
'lefthandwave',
'左手挥手',
'挥动左手',
]) ||
hasWarmupMocapWavePath(paths.leftHandPath))
) {
return 'left-hand';
}
if (
stepId === 'wave_right_hand' &&
(hasWarmupMocapAction(command, [
'right_wave',
'wave_right',
'right_hand_wave',
'wave_right_hand',
'right_handwave',
'righthand_wave',
'righthandwave',
'右手挥手',
'挥动右手',
]) ||
hasWarmupMocapWavePath(paths.rightHandPath))
) {
return 'right-hand';
}
if (
stepId === 'jump_once' &&
hasWarmupMocapAction(command, ['jump', 'jump_once', 'hop', '跳跃', '原地跳'])
) {
return 'jump';
}
return null;
}
function getHoldProgress(
stepId: ChildMotionWarmupStepId,
avatarX: number,
holdStartedAt: number | null,
nowMs: number,
) {
const step = getChildMotionWarmupStep(stepId);
if (!isAvatarOnWarmupTarget(step, avatarX) || holdStartedAt === null) {
return 0;
}
return Math.min(1, (nowMs - holdStartedAt) / CHILD_MOTION_HOLD_DURATION_MS);
}
function getStepIndex(stepId: ChildMotionWarmupStepId) {
const order: ChildMotionWarmupStepId[] = [
'center_arrive',
'wave_greeting',
'warmup_intro',
'move_left',
'return_center_1',
'move_right',
'return_center_2',
'wave_left_hand',
'wave_right_hand',
'jump_once',
'warmup_finish',
'level_select',
'play_placeholder',
];
return Math.max(0, order.indexOf(stepId));
}
function ChildMotionAvatar({
avatarX,
isJumping,
}: {
avatarX: number;
isJumping: boolean;
}) {
return (
<div
className={`child-motion-avatar ${isJumping ? 'child-motion-avatar--jumping' : ''}`}
data-testid="child-motion-avatar"
style={{
left: `${avatarX * 100}%`,
}}
aria-label="用户角色剪影"
>
<span className="child-motion-avatar__head" />
<span className="child-motion-avatar__body" />
<span className="child-motion-avatar__arm child-motion-avatar__arm--left" />
<span className="child-motion-avatar__arm child-motion-avatar__arm--right" />
<span className="child-motion-avatar__leg child-motion-avatar__leg--left" />
<span className="child-motion-avatar__leg child-motion-avatar__leg--right" />
</div>
);
}
function ChildMotionRing({
targetX,
progress,
}: {
targetX: number;
progress: number;
}) {
return (
<div
className={`child-motion-ring ${progress > 0 ? 'child-motion-ring--active' : ''}`}
data-testid="child-motion-ring"
style={{
left: `${targetX * 100}%`,
'--child-motion-ring-progress': `${Math.round(progress * 360)}deg`,
} as CSSProperties}
aria-label="绿色圆环"
>
<span className="child-motion-ring__core" />
</div>
);
}
function ChildMotionGestureGuide({
stepId,
leftHandPath,
rightHandPath,
}: {
stepId: ChildMotionWarmupStepId;
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
}) {
const isLeft = stepId === 'wave_left_hand';
const isRight = stepId === 'wave_right_hand';
const isGreeting = stepId === 'wave_greeting';
const isJump = stepId === 'jump_once';
const activePath = isLeft ? leftHandPath : isRight ? rightHandPath : [];
return (
<div className="child-motion-gesture-guide" aria-hidden="true">
{isGreeting ? (
<span className="child-motion-gesture-guide__wave"></span>
) : null}
{isLeft || isRight ? (
<>
<span
className={`child-motion-gesture-guide__hand child-motion-gesture-guide__hand--${isLeft ? 'left' : 'right'}`}
/>
{activePath.map((point, index) => (
<span
key={`${isLeft ? 'left' : 'right'}-${index}`}
className="child-motion-gesture-guide__trail"
style={{
left: `${point.x * 100}%`,
top: `${point.y * 100}%`,
opacity: 0.22 + (index / Math.max(1, activePath.length)) * 0.58,
}}
/>
))}
</>
) : null}
{isJump ? <span className="child-motion-gesture-guide__jump"></span> : null}
</div>
);
}
function ChildMotionCalibrationPanel({
calibration,
}: {
calibration: ChildMotionWarmupCalibration;
}) {
return (
<div className="child-motion-calibration" aria-label="热身记录">
<div>
<span></span>
<strong>{formatPercent(calibration.leftBoundary)}</strong>
</div>
<div>
<span></span>
<strong>{formatPercent(calibration.rightBoundary)}</strong>
</div>
<div>
<span></span>
<strong>{calibration.leftHandPath.length}</strong>
</div>
<div>
<span></span>
<strong>{calibration.rightHandPath.length}</strong>
</div>
<div>
<span></span>
<strong>{formatPercent(calibration.jumpSpace)}</strong>
</div>
</div>
);
}
export function ChildMotionWarmupDemo() {
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
);
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
const [calibration, setCalibration] = useState(
createEmptyChildMotionCalibration,
);
const [holdStartedAt, setHoldStartedAt] = useState<number | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const [leftHandPath, setLeftHandPath] = useState<ChildMotionPoint[]>([]);
const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]);
const [activeHand, setActiveHand] = useState<DragHand | null>(null);
const [isJumping, setIsJumping] = useState(false);
const [justCompletedText, setJustCompletedText] = useState<string | null>(null);
const [cameraAccessState, setCameraAccessState] =
useState<CameraAccessState>(() =>
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
? 'blocked'
: 'idle',
);
const holdCompletionRef = useRef(false);
const cameraVideoRef = useRef<HTMLVideoElement | null>(null);
const cameraStreamRef = useRef<MediaStream | null>(null);
const handledMocapPacketKeyRef = useRef<string | null>(null);
const step = getChildMotionWarmupStep(stepId);
const mocapInput = useMocapInput({
enabled:
step.kind === 'position' ||
step.kind === 'gesture' ||
step.kind === 'narration' ||
step.kind === 'finish',
});
const stepIndex = getStepIndex(stepId);
const progressPercent = Math.round((stepIndex / 12) * 100);
const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs);
const targetX = step.target ? getChildMotionTargetX(step.target) : null;
const motionSourceState = getMotionSourceState(
mocapInput.status,
mocapInput.latestCommand,
);
const motionSourceText = getMotionSourceText(motionSourceState);
const completeStep = useCallback(
(completion: Parameters<typeof applyChildMotionWarmupCompletion>[2]) => {
setCalibration((current) =>
applyChildMotionWarmupCompletion(stepId, current, completion),
);
const nextStep = resolveNextChildMotionWarmupStep(stepId);
if (stepId === 'jump_once') {
markChildMotionWarmupCompletedInRuntime();
}
setJustCompletedText(
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒',
);
window.setTimeout(() => setJustCompletedText(null), 720);
setStepId(nextStep);
setHoldStartedAt(null);
holdCompletionRef.current = false;
},
[stepId],
);
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 120);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
const videoElement = cameraVideoRef.current;
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia ||
!videoElement
) {
return;
}
let isMounted = true;
const startCamera = async () => {
if (!navigator.mediaDevices?.getUserMedia) {
if (isMounted) {
setCameraAccessState('blocked');
}
return;
}
try {
setCameraAccessState('requesting');
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: 'user',
},
});
if (!isMounted) {
stream.getTracks().forEach((track) => track.stop());
return;
}
cameraStreamRef.current?.getTracks().forEach((track) => track.stop());
cameraStreamRef.current = stream;
videoElement.srcObject = stream;
await videoElement.play();
setCameraAccessState('ready');
} catch {
cameraStreamRef.current?.getTracks().forEach((track) => track.stop());
cameraStreamRef.current = null;
videoElement.srcObject = null;
if (isMounted) {
setCameraAccessState('blocked');
}
}
};
void startCamera();
return () => {
isMounted = false;
const stream = cameraStreamRef.current;
cameraStreamRef.current = null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
videoElement.srcObject = null;
};
}, []);
useEffect(() => {
const stream = cameraStreamRef.current;
const videoElement = cameraVideoRef.current;
if (stream && videoElement && videoElement.srcObject !== stream) {
videoElement.srcObject = stream;
}
}, [cameraAccessState]);
useEffect(() => {
holdCompletionRef.current = false;
setHoldStartedAt(null);
setLeftHandPath([]);
setRightHandPath([]);
}, [stepId]);
useEffect(() => {
if (step.kind !== 'position') {
return;
}
if (!isAvatarOnWarmupTarget(step, avatarX)) {
setHoldStartedAt(null);
holdCompletionRef.current = false;
return;
}
setHoldStartedAt((current) => current ?? Date.now());
}, [avatarX, step]);
useEffect(() => {
if (
step.kind !== 'position' ||
holdStartedAt === null ||
holdCompletionRef.current ||
nowMs - holdStartedAt < CHILD_MOTION_HOLD_DURATION_MS
) {
return;
}
holdCompletionRef.current = true;
completeStep({ type: 'position', avatarX });
}, [avatarX, completeStep, holdStartedAt, nowMs, step.kind]);
useEffect(() => {
if (step.kind !== 'narration' && step.kind !== 'finish') {
return;
}
const timeout = window.setTimeout(
() => completeStep({ type: 'narration' }),
step.kind === 'finish'
? CHILD_MOTION_FINISH_DURATION_MS
: CHILD_MOTION_NARRATION_DURATION_MS,
);
return () => window.clearTimeout(timeout);
}, [completeStep, step.kind]);
useEffect(() => {
if (step.kind !== 'gesture' || !mocapInput.latestCommand) {
return;
}
const command = mocapInput.latestCommand;
const packetKey =
mocapInput.rawPacketPreview?.receivedAtMs !== undefined
? `${mocapInput.rawPacketPreview.receivedAtMs}:${mocapInput.rawPacketPreview.text}`
: JSON.stringify(command);
if (handledMocapPacketKeyRef.current === packetKey) {
return;
}
const primaryPoint = mocapHandToChildMotionPoint(command.primaryHand);
const primaryHandSide = command.primaryHand?.side ?? 'unknown';
const fallbackPrimaryToLeft =
Boolean(primaryPoint) &&
!command.leftHand &&
(primaryHandSide === 'left' ||
primaryHandSide === 'unknown' ||
stepId === 'wave_left_hand' ||
stepId === 'wave_greeting');
const fallbackPrimaryToRight =
Boolean(primaryPoint) &&
!command.rightHand &&
(primaryHandSide === 'right' ||
stepId === 'wave_right_hand');
const leftPoint =
mocapHandToChildMotionPoint(command.leftHand) ??
(fallbackPrimaryToLeft ? primaryPoint : null);
const rightPoint =
mocapHandToChildMotionPoint(command.rightHand) ??
(fallbackPrimaryToRight ? primaryPoint : null);
const nextLeftHandPath = leftPoint
? appendWarmupMocapPoint(leftHandPath, leftPoint)
: leftHandPath;
const nextRightHandPath = rightPoint
? appendWarmupMocapPoint(rightHandPath, rightPoint)
: rightHandPath;
const nextPrimaryHandPath = primaryPoint
? command.primaryHand?.side === 'right'
? nextRightHandPath
: nextLeftHandPath
: [];
handledMocapPacketKeyRef.current = packetKey;
if (leftPoint) {
setLeftHandPath(nextLeftHandPath);
}
if (rightPoint) {
setRightHandPath(nextRightHandPath);
}
const intent = resolveWarmupMocapGestureIntent(stepId, command, {
leftHandPath: nextLeftHandPath,
rightHandPath: nextRightHandPath,
primaryHandPath: nextPrimaryHandPath,
});
if (!intent) {
return;
}
if (intent === 'jump') {
setIsJumping(true);
window.setTimeout(() => setIsJumping(false), 360);
completeStep({ type: 'jump', jumpSpace: 0.14 });
return;
}
if (intent === 'right-hand') {
const path = [...nextRightHandPath, rightPoint ?? primaryPoint].filter(
(point): point is ChildMotionPoint => Boolean(point),
);
completeStep({ type: 'right-hand', path: path.slice(-16) });
return;
}
const path = [...nextLeftHandPath, leftPoint ?? primaryPoint].filter(
(point): point is ChildMotionPoint => Boolean(point),
);
completeStep({ type: 'left-hand', path: path.slice(-16) });
}, [
completeStep,
leftHandPath,
mocapInput.latestCommand,
mocapInput.rawPacketPreview?.receivedAtMs,
mocapInput.rawPacketPreview?.text,
rightHandPath,
step.kind,
stepId,
]);
useEffect(() => {
if (!mocapInput.latestCommand) {
return;
}
const nextAvatarX = resolveAvatarXFromMocap(mocapInput.latestCommand);
if (nextAvatarX === null) {
return;
}
setAvatarX(nextAvatarX);
}, [
mocapInput.latestCommand,
mocapInput.rawPacketPreview?.receivedAtMs,
mocapInput.rawPacketPreview?.text,
]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat) {
return;
}
const key = event.key.toLowerCase();
if (key === 'a') {
setAvatarX(0.34);
return;
}
if (key === 'd') {
setAvatarX(0.66);
return;
}
if (event.code === 'Space') {
event.preventDefault();
setIsJumping(true);
window.setTimeout(() => setIsJumping(false), 360);
if (stepId === 'jump_once') {
completeStep({ type: 'jump', jumpSpace: 0.14 });
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [completeStep, stepId]);
useEffect(() => {
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if (key === 'a' || key === 'd' || event.code === 'KeyA' || event.code === 'KeyD') {
setAvatarX(CHILD_MOTION_CENTER_X);
}
};
window.addEventListener('keyup', handleKeyUp);
return () => window.removeEventListener('keyup', handleKeyUp);
}, []);
const handleStagePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0 && event.button !== 2) {
return;
}
event.preventDefault();
const nextHand: DragHand = event.button === 2 ? 'right' : 'left';
setActiveHand(nextHand);
const point = normalizePointerPoint(event, event.currentTarget);
if (nextHand === 'left') {
setLeftHandPath([point]);
} else {
setRightHandPath([point]);
}
event.currentTarget.setPointerCapture(event.pointerId);
};
const handleStagePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (!activeHand) {
return;
}
const point = normalizePointerPoint(event, event.currentTarget);
const appendPoint = (points: ChildMotionPoint[]) =>
[...points, point].slice(-16);
if (activeHand === 'left') {
setLeftHandPath(appendPoint);
} else {
setRightHandPath(appendPoint);
}
};
const handleStagePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
if (!activeHand) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const hand = activeHand;
const point = normalizePointerPoint(event, event.currentTarget);
const completedPath =
hand === 'left'
? [...leftHandPath, point].slice(-16)
: [...rightHandPath, point].slice(-16);
setActiveHand(null);
if (stepId === 'wave_greeting') {
completeStep({ type: 'left-hand', path: completedPath });
return;
}
if (stepId === 'wave_left_hand' && hand === 'left') {
completeStep({ type: 'left-hand', path: completedPath });
return;
}
if (stepId === 'wave_right_hand' && hand === 'right') {
completeStep({ type: 'right-hand', path: completedPath });
}
};
const handleStartPlaceholderLevel = () => {
setStepId('play_placeholder');
};
const handleReturnToStart = () => {
setStepId('level_select');
};
const lineText = useMemo(() => step.spokenLines.join(''), [step.spokenLines]);
return (
<main className="child-motion-demo" data-testid="child-motion-demo">
<div className="child-motion-orientation-tip" role="status">
</div>
<section
className="child-motion-stage"
data-testid="child-motion-stage"
onPointerDown={handleStagePointerDown}
onPointerMove={handleStagePointerMove}
onPointerUp={handleStagePointerUp}
onPointerCancel={handleStagePointerUp}
onContextMenu={(event) => event.preventDefault()}
>
<video
ref={cameraVideoRef}
className="child-motion-camera-layer"
aria-hidden="true"
autoPlay
muted
playsInline
/>
{motionSourceState !== 'ready' ? (
<div
className={`child-motion-camera-state child-motion-camera-state--${motionSourceState}`}
aria-live="polite"
>
{motionSourceText}
</div>
) : null}
<div className="child-motion-floor" aria-hidden="true" />
{targetX !== null && step.kind === 'position' ? (
<ChildMotionRing targetX={targetX} progress={holdProgress} />
) : null}
{step.kind === 'gesture' ? (
<ChildMotionGestureGuide
stepId={stepId}
leftHandPath={leftHandPath}
rightHandPath={rightHandPath}
/>
) : null}
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
{justCompletedText ? (
<div className="child-motion-floating-reward">{justCompletedText}</div>
) : null}
<div className="child-motion-hud child-motion-hud--top">
<span className="child-motion-step-count">{`${Math.min(stepIndex + 1, 12)}/12`}</span>
<div>
<h1>{step.title}</h1>
<p>{lineText}</p>
</div>
<span className="child-motion-progress">{progressPercent}%</span>
</div>
{step.kind === 'levelSelect' ? (
<div className="child-motion-start-panel">
<button type="button" onClick={handleStartPlaceholderLevel}>
</button>
</div>
) : null}
{step.kind === 'placeholder' ? (
<div className="child-motion-start-panel">
<span></span>
<button type="button" onClick={handleReturnToStart}>
</button>
</div>
) : null}
<ChildMotionCalibrationPanel calibration={calibration} />
</section>
</main>
);
}
export default ChildMotionWarmupDemo;

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
applyChildMotionWarmupCompletion,
CHILD_MOTION_CENTER_X,
CHILD_MOTION_WARMUP_STEPS,
createEmptyChildMotionCalibration,
getChildMotionWarmupStep,
isAvatarOnWarmupTarget,
resolveNextChildMotionWarmupStep,
} from './childMotionWarmupModel';
describe('childMotionWarmupModel', () => {
it('keeps the confirmed warmup order as a strict state chain', () => {
expect(CHILD_MOTION_WARMUP_STEPS.map((step) => step.id)).toEqual([
'center_arrive',
'wave_greeting',
'warmup_intro',
'move_left',
'return_center_1',
'move_right',
'return_center_2',
'wave_left_hand',
'wave_right_hand',
'jump_once',
'warmup_finish',
'level_select',
'play_placeholder',
]);
expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe(
'wave_greeting',
);
expect(resolveNextChildMotionWarmupStep('level_select')).toBe(
'play_placeholder',
);
});
it('checks position completion against the active green ring target', () => {
expect(
isAvatarOnWarmupTarget(
getChildMotionWarmupStep('center_arrive'),
CHILD_MOTION_CENTER_X,
),
).toBe(true);
expect(
isAvatarOnWarmupTarget(getChildMotionWarmupStep('move_left'), 0.66),
).toBe(false);
});
it('records session-only calibration values from completed steps', () => {
const empty = createEmptyChildMotionCalibration();
const withLeft = applyChildMotionWarmupCompletion('move_left', empty, {
type: 'position',
avatarX: 0.34,
});
const withRight = applyChildMotionWarmupCompletion('move_right', withLeft, {
type: 'position',
avatarX: 0.66,
});
const withLeftHand = applyChildMotionWarmupCompletion(
'wave_left_hand',
withRight,
{
type: 'left-hand',
path: [
{ x: 0.3, y: 0.4 },
{ x: 0.34, y: 0.32 },
],
},
);
const completed = applyChildMotionWarmupCompletion(
'jump_once',
withLeftHand,
{
type: 'jump',
jumpSpace: 0.14,
},
);
expect(completed.leftBoundary).toBeCloseTo(0.16);
expect(completed.rightBoundary).toBeCloseTo(0.16);
expect(completed.leftHandPath).toHaveLength(2);
expect(completed.jumpSpace).toBe(0.14);
});
});

View File

@@ -0,0 +1,274 @@
export type ChildMotionWarmupStepId =
| 'center_arrive'
| 'wave_greeting'
| 'warmup_intro'
| 'move_left'
| 'return_center_1'
| 'move_right'
| 'return_center_2'
| 'wave_left_hand'
| 'wave_right_hand'
| 'jump_once'
| 'warmup_finish'
| 'level_select'
| 'play_placeholder';
export type ChildMotionWarmupTarget = 'center' | 'left' | 'right';
export type ChildMotionWarmupStepKind =
| 'position'
| 'gesture'
| 'narration'
| 'finish'
| 'levelSelect'
| 'placeholder';
export type ChildMotionWarmupStep = {
id: ChildMotionWarmupStepId;
kind: ChildMotionWarmupStepKind;
title: string;
spokenLines: string[];
target?: ChildMotionWarmupTarget;
};
export type ChildMotionPoint = {
x: number;
y: number;
};
export type ChildMotionWarmupCalibration = {
leftBoundary: number | null;
rightBoundary: number | null;
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
jumpSpace: number | null;
};
export type ChildMotionWarmupCompletion =
| {
type: 'position';
avatarX: number;
}
| {
type: 'left-hand';
path: ChildMotionPoint[];
}
| {
type: 'right-hand';
path: ChildMotionPoint[];
}
| {
type: 'jump';
jumpSpace: number;
}
| {
type: 'narration';
};
export const CHILD_MOTION_CENTER_X = 0.5;
export const CHILD_MOTION_LEFT_X = 0.34;
export const CHILD_MOTION_RIGHT_X = 0.66;
export const CHILD_MOTION_POSITION_EPSILON = 0.045;
export const CHILD_MOTION_HOLD_DURATION_MS = 2000;
export const CHILD_MOTION_NARRATION_DURATION_MS = 900;
export const CHILD_MOTION_FINISH_DURATION_MS = 1200;
export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
{
id: 'center_arrive',
kind: 'position',
title: '来到圆圈这里',
spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'],
target: 'center',
},
{
id: 'wave_greeting',
kind: 'gesture',
title: '打个招呼',
spokenLines: ['请你来到圆圈这里和我打个招呼吧'],
},
{
id: 'warmup_intro',
kind: 'narration',
title: '准备热身',
spokenLines: ['你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧'],
},
{
id: 'move_left',
kind: 'position',
title: '向左一步',
spokenLines: ['向左一步'],
target: 'left',
},
{
id: 'return_center_1',
kind: 'position',
title: '回到中间来',
spokenLines: ['回到中间来'],
target: 'center',
},
{
id: 'move_right',
kind: 'position',
title: '向右一步',
spokenLines: ['向右一步'],
target: 'right',
},
{
id: 'return_center_2',
kind: 'position',
title: '回到中间来',
spokenLines: ['回到中间来'],
target: 'center',
},
{
id: 'wave_left_hand',
kind: 'gesture',
title: '挥动左手',
spokenLines: ['挥动左手'],
},
{
id: 'wave_right_hand',
kind: 'gesture',
title: '挥动右手',
spokenLines: ['挥动右手'],
},
{
id: 'jump_once',
kind: 'gesture',
title: '原地跳一下',
spokenLines: ['原地跳一下'],
},
{
id: 'warmup_finish',
kind: 'finish',
title: '热身完成',
spokenLines: ['真厉害,你是我见过最聪明的小朋友', '别走开,现在开始我们的游戏吧'],
},
{
id: 'level_select',
kind: 'levelSelect',
title: '准备开始',
spokenLines: ['现在开始我们的游戏吧'],
},
{
id: 'play_placeholder',
kind: 'placeholder',
title: '下一关',
spokenLines: ['游戏关卡正在准备中'],
},
];
const STEP_BY_ID = new Map(
CHILD_MOTION_WARMUP_STEPS.map((step) => [step.id, step]),
);
const NEXT_STEP_BY_ID = new Map<ChildMotionWarmupStepId, ChildMotionWarmupStepId>(
CHILD_MOTION_WARMUP_STEPS.slice(0, -1).map((step, index) => [
step.id,
CHILD_MOTION_WARMUP_STEPS[index + 1]!.id,
]),
);
let childMotionWarmupCompletedInRuntime = false;
export function getChildMotionWarmupStep(id: ChildMotionWarmupStepId) {
return STEP_BY_ID.get(id) ?? CHILD_MOTION_WARMUP_STEPS[0]!;
}
export function getChildMotionTargetX(target: ChildMotionWarmupTarget) {
if (target === 'left') {
return CHILD_MOTION_LEFT_X;
}
if (target === 'right') {
return CHILD_MOTION_RIGHT_X;
}
return CHILD_MOTION_CENTER_X;
}
export function isAvatarOnWarmupTarget(
step: ChildMotionWarmupStep,
avatarX: number,
) {
if (step.kind !== 'position' || !step.target) {
return false;
}
return (
Math.abs(avatarX - getChildMotionTargetX(step.target)) <=
CHILD_MOTION_POSITION_EPSILON
);
}
export function resolveNextChildMotionWarmupStep(
stepId: ChildMotionWarmupStepId,
) {
return NEXT_STEP_BY_ID.get(stepId) ?? stepId;
}
export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibration {
return {
leftBoundary: null,
rightBoundary: null,
leftHandPath: [],
rightHandPath: [],
jumpSpace: null,
};
}
export function applyChildMotionWarmupCompletion(
stepId: ChildMotionWarmupStepId,
calibration: ChildMotionWarmupCalibration,
completion: ChildMotionWarmupCompletion,
): ChildMotionWarmupCalibration {
if (stepId === 'move_left' && completion.type === 'position') {
return {
...calibration,
leftBoundary: Math.max(0, CHILD_MOTION_CENTER_X - completion.avatarX),
};
}
if (stepId === 'move_right' && completion.type === 'position') {
return {
...calibration,
rightBoundary: Math.max(0, completion.avatarX - CHILD_MOTION_CENTER_X),
};
}
if (stepId === 'wave_left_hand' && completion.type === 'left-hand') {
return {
...calibration,
leftHandPath: completion.path,
};
}
if (stepId === 'wave_right_hand' && completion.type === 'right-hand') {
return {
...calibration,
rightHandPath: completion.path,
};
}
if (stepId === 'jump_once' && completion.type === 'jump') {
return {
...calibration,
jumpSpace: completion.jumpSpace,
};
}
return calibration;
}
export function hasCompletedChildMotionWarmupInRuntime() {
return childMotionWarmupCompletedInRuntime;
}
export function markChildMotionWarmupCompletedInRuntime() {
childMotionWarmupCompletedInRuntime = true;
}
export function resetChildMotionWarmupRuntimeSession() {
childMotionWarmupCompletedInRuntime = false;
}

View File

@@ -291,6 +291,11 @@ import {
type VisualNovelEntryFormPayload,
} from '../visual-novel-creation/VisualNovelAgentWorkspace';
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
import {
canExposePublicWork,
EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -865,6 +870,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;
@@ -2165,7 +2183,10 @@ export function PlatformEntryFlowShellImpl({
const recommendRuntimeEntries = useMemo(
() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredGalleryEntries, ...latestGalleryEntries].forEach((entry) => {
filterGeneralPublicWorks([
...featuredGalleryEntries,
...latestGalleryEntries,
]).forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values());
@@ -4244,6 +4265,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) {
@@ -4259,8 +4293,8 @@ export function PlatformEntryFlowShellImpl({
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
setPlatformTab,
setSelectionStage,
startPuzzleRun,
],
);
@@ -5421,6 +5455,13 @@ export function PlatformEntryFlowShellImpl({
const openPublicWorkDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (!canExposePublicWork(entry)) {
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setSelectedPublicWorkDetail(entry);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
@@ -5648,6 +5689,13 @@ export function PlatformEntryFlowShellImpl({
const openRpgPublicWorkDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
if (!canExposePublicWork(entry)) {
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
clearSelectedPublicWorkAuthor();
@@ -5659,6 +5707,14 @@ export function PlatformEntryFlowShellImpl({
await detailNavigation.loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
const detailCard = mapRpgGalleryCardToPublicWorkDetail(detailEntry);
if (!canExposePublicWork(detailCard)) {
setSelectedDetailEntry(null);
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setSelectedPublicWorkDetail(detailCard);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
@@ -5697,10 +5753,31 @@ export function PlatformEntryFlowShellImpl({
try {
const { item } = await getPuzzleGalleryDetail(profileId);
const detailEntry = mapPuzzleWorkToPublicWorkDetail(item);
if (!canExposePublicWork(detailEntry)) {
setSelectedPuzzleDetail(null);
setPublicWorkDetailError(EDUTAINMENT_HIDDEN_MESSAGE);
setSelectionStage('platform');
return;
}
setSelectedPuzzleDetail(item);
setPuzzleDetailReturnTarget(returnTarget);
openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item));
openPublicWorkDetail(detailEntry);
} 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, '读取拼图详情失败。'),
);
@@ -5715,6 +5792,7 @@ export function PlatformEntryFlowShellImpl({
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
setPlatformTab,
setSelectionStage,
],
);
@@ -5906,6 +5984,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);
@@ -5916,6 +6007,7 @@ export function PlatformEntryFlowShellImpl({
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
setPlatformTab,
setSelectionStage,
],
);
@@ -6710,11 +6802,14 @@ export function PlatformEntryFlowShellImpl({
match3dError,
match3dFlow,
match3dRun,
platformBootstrap.platformTab,
platformThemeClass,
puzzleError,
puzzleRun,
recommendRuntimeEntries,
remodelCurrentPuzzleRuntimeWork,
resolveMatch3DErrorMessage,
resolveSquareHoleErrorMessage,
reportBigFishObservedPlayTime,
restartBigFishRun,
selectedPuzzleDetail,
@@ -7063,6 +7158,10 @@ export function PlatformEntryFlowShellImpl({
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
} satisfies CustomWorldGalleryCard;
if (!canExposePublicWork(card)) {
throw new Error(EDUTAINMENT_HIDDEN_MESSAGE);
}
setSelectedDetailEntry(entry);
openPublicWorkDetail(card);
};
@@ -7071,9 +7170,12 @@ export function PlatformEntryFlowShellImpl({
puzzleGalleryEntries.length > 0
? puzzleGalleryEntries
: await refreshPuzzleGallery();
const matchedEntry = entries.find((entry) =>
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries
.map(mapPuzzleWorkToPublicWorkDetail)
.filter(canExposePublicWork)
.find((entry) =>
isSamePuzzlePublicWorkCode(normalizedKeyword, entry.profileId),
);
if (!matchedEntry) {
throw new Error('未找到拼图作品。');
@@ -7088,9 +7190,13 @@ export function PlatformEntryFlowShellImpl({
bigFishGalleryEntries.length > 0
? bigFishGalleryEntries
: await refreshBigFishGallery();
const matchedEntry = entries.find((entry) =>
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapBigFishWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId)
);
});
if (!matchedEntry) {
throw new Error('未找到大鱼吃小鱼作品。');
@@ -7103,9 +7209,13 @@ export function PlatformEntryFlowShellImpl({
match3dGalleryEntries.length > 0
? match3dGalleryEntries
: await refreshMatch3DGallery();
const matchedEntry = entries.find((entry) =>
isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId)
);
});
if (!matchedEntry) {
throw new Error('未找到抓大鹅作品。');
@@ -7118,9 +7228,13 @@ export function PlatformEntryFlowShellImpl({
squareHoleGalleryEntries.length > 0
? squareHoleGalleryEntries
: await refreshSquareHoleGallery();
const matchedEntry = entries.find((entry) =>
isSameSquareHolePublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapSquareHoleWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameSquareHolePublicWorkCode(normalizedKeyword, entry.profileId)
);
});
if (!matchedEntry) {
throw new Error('未找到方洞挑战作品。');
@@ -7133,9 +7247,13 @@ export function PlatformEntryFlowShellImpl({
visualNovelGalleryEntries.length > 0
? visualNovelGalleryEntries
: await refreshVisualNovelGallery();
const matchedEntry = entries.find((entry) =>
isSameVisualNovelPublicWorkCode(normalizedKeyword, entry.profileId),
);
const matchedEntry = entries.find((entry) => {
const detailEntry = mapVisualNovelWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameVisualNovelPublicWorkCode(normalizedKeyword, entry.profileId)
);
});
if (!matchedEntry) {
throw new Error('未找到视觉小说作品。');

View File

@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import {
canExposePublicWork,
filterEdutainmentPublicWorks,
filterGeneralPublicWorks,
isEdutainmentEntryEnabled,
isEdutainmentPublicWork,
} from './platformEdutainmentVisibility';
function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard {
return {
sourceType: 'puzzle',
workId: 'puzzle-work-education-demo',
profileId: 'puzzle-profile-education-demo',
publicWorkCode: 'PZ-EDUDEMO',
ownerUserId: 'user-education',
authorDisplayName: '动作 Demo 作者',
worldName: '儿童动作热身 Demo',
subtitle: '拼图关卡',
summaryText: '本地动作 Demo。',
coverImageSrc: null,
themeTags,
visibility: 'published',
publishedAt: '2026-05-09T10:00:00.000Z',
updatedAt: '2026-05-09T10:00:00.000Z',
};
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe('platformEdutainmentVisibility', () => {
test('matches only the exact edutainment tag from full work tags', () => {
const exact = buildPuzzleCard(['运动', '安全', '拼图', '寓教于乐']);
const fuzzy = buildPuzzleCard(['儿童教育', '寓教于乐 ']);
expect(isEdutainmentPublicWork(exact)).toBe(true);
expect(isEdutainmentPublicWork(fuzzy)).toBe(false);
expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]);
expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]);
});
test('defaults to enabled and blocks exact edutainment works only when disabled', () => {
const exact = buildPuzzleCard(['寓教于乐']);
const general = buildPuzzleCard(['儿童教育']);
expect(isEdutainmentEntryEnabled()).toBe(true);
expect(canExposePublicWork(exact)).toBe(true);
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
expect(isEdutainmentEntryEnabled()).toBe(false);
expect(canExposePublicWork(exact)).toBe(false);
expect(canExposePublicWork(general)).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import type { PlatformBrowseHistoryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
export const EDUTAINMENT_WORK_TAG = '寓教于乐';
export const EDUTAINMENT_HIDDEN_MESSAGE = '该内容暂不可见。';
const EDUTAINMENT_ENTRY_DISABLED_VALUES = new Set(['false', '0', 'off', 'no']);
// 中文注释:入口默认开启;只有明确写入关闭值时才完全隐藏寓教于乐内容。
export function isEdutainmentEntryEnabled(
rawValue = import.meta.env.VITE_ENABLE_EDUTAINMENT_ENTRY,
) {
const normalized = (rawValue ?? '').trim().toLowerCase();
return !EDUTAINMENT_ENTRY_DISABLED_VALUES.has(normalized);
}
function getPlatformPublicWorkTags(entry: PlatformPublicGalleryCard) {
if ('themeTags' in entry) {
return entry.themeTags;
}
return [];
}
export function isEdutainmentPublicWork(entry: PlatformPublicGalleryCard) {
return getPlatformPublicWorkTags(entry).some(
(tag) => tag === EDUTAINMENT_WORK_TAG,
);
}
export function canExposePublicWork(entry: PlatformPublicGalleryCard) {
return isEdutainmentEntryEnabled() || !isEdutainmentPublicWork(entry);
}
export function filterGeneralPublicWorks(entries: PlatformPublicGalleryCard[]) {
return entries.filter((entry) => !isEdutainmentPublicWork(entry));
}
export function filterEdutainmentPublicWorks(
entries: PlatformPublicGalleryCard[],
) {
return entries.filter(isEdutainmentPublicWork);
}
export function filterVisiblePublicWorks(entries: PlatformPublicGalleryCard[]) {
return entries.filter(canExposePublicWork);
}
export function findPublicWorkForHistoryEntry(
historyEntry: PlatformBrowseHistoryEntry,
entries: PlatformPublicGalleryCard[],
) {
return entries.find(
(entry) =>
entry.ownerUserId === historyEntry.ownerUserId &&
entry.profileId === historyEntry.profileId,
);
}

View File

@@ -36,7 +36,7 @@ vi.mock('../../services/useMocapInput', () => ({
status: 'connected',
latestCommand: {
actions: [mocapMock.state],
primaryHand: {x: mocapMock.x, y: mocapMock.y, state: mocapMock.state},
primaryHand: {x: mocapMock.x, y: mocapMock.y, state: mocapMock.state, source: 'palm_center'},
parseWarnings: [],
},
rawPacketPreview: {text: '{"hands":[{"state":"grab"}]}', receivedAtMs: 1},
@@ -207,9 +207,11 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => {
const cursor = screen.getByTestId('puzzle-mocap-cursor');
expect(cursor).toBeTruthy();
expect(cursor.style.left).toBe('42%');
expect(cursor.style.top).toBe('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('抓握时会触发拖拽提交并在松开时落子', () => {
@@ -302,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();
@@ -822,6 +962,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,8 @@ 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 PUZZLE_MOCAP_CURSOR_FRAME_MS = 1000 / 60;
const shownExitRemodelPromptProfileIds = new Set<string>();
@@ -290,6 +301,15 @@ type PuzzleMocapCursorState = {
state: string;
};
type PuzzleMocapCursorSample = PuzzleMocapCursorState & {
receivedAtMs: number;
};
type PuzzleRuntimeDragTargetState = {
pieceId: string;
groupId: string | null;
};
function triggerPuzzlePiecePressHapticFeedback() {
if (typeof navigator === 'undefined') {
return;
@@ -328,6 +348,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 +376,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 +399,18 @@ export function PuzzleRuntimeShell({
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
null,
);
const mocapDragRef = useRef<{pieceId: string} | null>(null);
const mocapCursorPreviousSampleRef = useRef<PuzzleMocapCursorSample | null>(
null,
);
const mocapCursorTargetSampleRef = useRef<PuzzleMocapCursorSample | null>(null);
const mocapCursorIntervalRef = useRef<number | null>(null);
const updateMocapCursorSampleRef = useRef<(
nextSample: PuzzleMocapCursorSample,
) => void>(() => {});
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController<string>(),
);
const draggingTargetRef = useRef<PuzzleRuntimeDragTargetState | null>(null);
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
null,
);
@@ -400,6 +433,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 +444,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 +467,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 +633,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 +654,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 +861,293 @@ 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 resetMocapCursorInterpolation = () => {
mocapCursorPreviousSampleRef.current = null;
mocapCursorTargetSampleRef.current = null;
setMocapCursor(null);
};
updateMocapCursorSampleRef.current = (nextSample: PuzzleMocapCursorSample) => {
const previousTarget = mocapCursorTargetSampleRef.current;
mocapCursorPreviousSampleRef.current = previousTarget ?? nextSample;
mocapCursorTargetSampleRef.current = nextSample;
if (!previousTarget) {
setMocapCursor(nextSample);
}
};
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();
resetMocapCursorInterpolation();
return;
}
if (
!primaryMocapHandState ||
typeof primaryMocapHandX !== 'number' ||
typeof primaryMocapHandY !== 'number'
) {
runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID);
resetMocapCursorInterpolation();
return;
}
const nextSample = {
x: primaryMocapHandX,
y: primaryMocapHandY,
state: primaryMocapHandState,
receivedAtMs: performance.now(),
};
updateMocapCursorSampleRef.current(nextSample);
const handPoint = resolveBoardInputPointFromNormalized(nextSample.x, nextSample.y);
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,
]);
useEffect(() => {
if (!board || runtimeStatus !== 'playing') {
if (mocapCursorIntervalRef.current !== null) {
window.clearInterval(mocapCursorIntervalRef.current);
mocapCursorIntervalRef.current = null;
}
return;
}
const tickMocapCursor = () => {
const targetSample = mocapCursorTargetSampleRef.current;
if (!targetSample) {
return;
}
const previousSample = mocapCursorPreviousSampleRef.current ?? targetSample;
const durationMs = Math.max(
PUZZLE_MOCAP_CURSOR_FRAME_MS,
targetSample.receivedAtMs - previousSample.receivedAtMs,
);
const progress = targetSample.receivedAtMs === previousSample.receivedAtMs
? 1
: Math.min(
1,
Math.max(0, (performance.now() - targetSample.receivedAtMs) / durationMs),
);
const nextCursor = {
x: previousSample.x + (targetSample.x - previousSample.x) * progress,
y: previousSample.y + (targetSample.y - previousSample.y) * progress,
state: targetSample.state,
};
const nextPoint = resolveBoardInputPointFromNormalized(
nextCursor.x,
nextCursor.y,
);
setMocapCursor(nextCursor);
const activeSession = runtimeDragInputControllerRef.current.getSession();
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
runtimeDragInputControllerRef.current.move({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: nextPoint,
forceDragging: true,
});
}
};
tickMocapCursor();
mocapCursorIntervalRef.current = window.setInterval(
tickMocapCursor,
PUZZLE_MOCAP_CURSOR_FRAME_MS,
);
return () => {
if (mocapCursorIntervalRef.current !== null) {
window.clearInterval(mocapCursorIntervalRef.current);
mocapCursorIntervalRef.current = null;
}
};
}, [board, runtimeStatus]);
if (!run || !currentLevel || !board) {
return (
<div
@@ -821,131 +1161,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 +1179,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 +1232,6 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const handleBackRequest = () => {
if (hideExitControls) {
return;
@@ -1150,10 +1343,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 +1500,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={() => {
@@ -1460,10 +1649,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

@@ -3,7 +3,7 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
@@ -2378,6 +2378,10 @@ beforeEach(() => {
);
});
afterEach(() => {
vi.unstubAllEnvs();
});
test('create tab shows template tabs and embeds puzzle form by default', async () => {
const user = userEvent.setup();
@@ -2413,6 +2417,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.getByRole('tab', { name: //u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
@@ -3098,6 +3103,50 @@ test('logged out public detail gates big fish start before local runtime', async
expect(recordBigFishPlay).not.toHaveBeenCalled();
});
test('public code search blocks edutainment work when entry switch is disabled', async () => {
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
const user = userEvent.setup();
const edutainmentPuzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-edutainment-1',
profileId: 'puzzle-profile-edutainment-1',
ownerUserId: 'user-2',
sourceSessionId: 'puzzle-session-edutainment-1',
authorDisplayName: '动作 Demo 作者',
levelName: '儿童动作热身 Demo',
summary: '寓教于乐专属动作 Demo。',
themeTags: ['运动', '安全', '拼图', '寓教于乐'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-05-09T10:00:00.000Z',
publishedAt: '2026-05-09T10:00:00.000Z',
playCount: 3,
remixCount: 0,
likeCount: 0,
publishReady: true,
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [edutainmentPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: edutainmentPuzzleWork,
});
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-TMENT1');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('未找到结果')).toBeTruthy();
expect(screen.queryByText('儿童动作热身 Demo')).toBeNull();
expect(getPuzzleGalleryDetail).not.toHaveBeenCalled();
});
test('creation hub clears all private work shelves immediately after logout state', async () => {
const user = userEvent.setup();
const loggedInAuth = createAuthValue();
@@ -4294,6 +4343,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

@@ -25,7 +25,10 @@ import {
RpgEntryHomeView,
type RpgEntryHomeViewProps,
} from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
import type {
PlatformPublicGalleryCard,
PlatformPuzzleGalleryCard,
} from './rpgEntryWorldPresentation';
const {
mockBuildReferralCenter,
@@ -425,6 +428,23 @@ const longTextRankEntry = {
updatedAt: '2026-04-29T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function buildTaggedPuzzleEntry(
id: string,
worldName: string,
themeTags: string[],
overrides: Partial<PlatformPuzzleGalleryCard> = {},
) {
return {
...puzzlePublicEntry,
workId: `puzzle-work-${id}`,
profileId: `puzzle-profile-${id}`,
publicWorkCode: `PZ-${id.toUpperCase()}`,
worldName,
themeTags,
...overrides,
} satisfies PlatformPuzzleGalleryCard;
}
function mockDesktopLayout() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
@@ -688,6 +708,7 @@ function renderStatefulLoggedOutHomeView(
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllEnvs();
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(),
);
@@ -1097,6 +1118,108 @@ test('discover search fuzzy matches public work id, name, author and description
expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]);
});
test('mobile discover keeps edutainment works in the last dedicated channel only', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
const generalEntry = buildTaggedPuzzleEntry('normal01', '普通拼图作品', [
'儿童教育',
]);
const edutainmentEntry = buildTaggedPuzzleEntry(
'edu001',
'儿童动作热身 Demo',
['运动', '安全', '拼图', '寓教于乐'],
{
playCount: 99,
remixCount: 30,
likeCount: 50,
recentPlayCount7d: 88,
publishedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
summaryText: '寓教于乐专属内容',
},
);
renderStatefulLoggedOutHomeView({
latestEntries: [edutainmentEntry, generalEntry],
onSearchPublicCode,
});
await user.click(screen.getByRole('button', { name: '发现' }));
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
const channels = Array.from(
discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
).map((button) => button.textContent);
expect(channels).toEqual(['推荐', '今日', '分类', '排行', '寓教于乐']);
expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
await user.click(screen.getByRole('button', { name: '今日' }));
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
await user.click(screen.getByRole('button', { name: '分类' }));
expect(screen.getByRole('button', { name: '儿童教育' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '寓教于乐' })).toBeTruthy();
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
await user.click(screen.getByRole('button', { name: '排行' }));
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
expect(
within(discoverPanel).getByRole('button', {
name: / Demo/u,
}),
).toBeTruthy();
expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, '儿童动作热身{enter}');
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
expect(within(discoverPanel).queryByText('儿童动作热身 Demo')).toBeNull();
expect(onSearchPublicCode).not.toHaveBeenCalled();
});
test('mobile discover hides edutainment channel and work when switch is disabled', async () => {
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
const edutainmentEntry = buildTaggedPuzzleEntry(
'eduoff1',
'关闭后隐藏的热身 Demo',
['寓教于乐'],
{
summaryText: '关闭后不可见',
publishedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
);
renderStatefulLoggedOutHomeView({
latestEntries: [edutainmentEntry],
onSearchPublicCode,
});
await user.click(screen.getByRole('button', { name: '发现' }));
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
const channels = Array.from(
discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
).map((button) => button.textContent);
expect(channels).toEqual(['推荐', '今日', '分类', '排行']);
expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull();
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'PZ-EDUOFF1{enter}');
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
expect(within(discoverPanel).queryByText('关闭后隐藏的热身 Demo')).toBeNull();
expect(onSearchPublicCode).not.toHaveBeenCalled();
});
test('discover search keeps public code fallback when local works do not match', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();

View File

@@ -75,6 +75,14 @@ import {
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import {
canExposePublicWork,
EDUTAINMENT_WORK_TAG,
filterEdutainmentPublicWorks,
filterGeneralPublicWorks,
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
@@ -183,7 +191,12 @@ const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
type DiscoverChannel =
| 'recommend'
| 'today'
| 'category'
| 'ranking'
| 'edutainment';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
const COMMUNITY_QR_CODES = [
@@ -208,6 +221,10 @@ const DISCOVER_CHANNELS: Array<{
{ id: 'category', label: '分类' },
{ id: 'ranking', label: '排行' },
];
const EDUTAINMENT_DISCOVER_CHANNEL = {
id: 'edutainment',
label: EDUTAINMENT_WORK_TAG,
} as const;
const PLATFORM_RANKING_TABS: Array<{
id: PlatformRankingTab;
@@ -1313,9 +1330,11 @@ function buildPublicCategoryGroups(
) {
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
});
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => {
@@ -1346,6 +1365,21 @@ function getPlatformPublicEntries(
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
return Array.from(entryMap.values());
}
function getAllPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
@@ -3148,21 +3182,62 @@ export function RpgEntryHomeView({
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
const isDesktopLayout = usePlatformDesktopLayout();
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const featuredShelf = useMemo(
() => featuredEntries.slice(0, 6),
const generalFeaturedEntries = useMemo(
() => filterGeneralPublicWorks(featuredEntries),
[featuredEntries],
);
const categoryGroups = useMemo(
() => buildPublicCategoryGroups(featuredEntries, latestEntries),
const featuredShelf = useMemo(
() => generalFeaturedEntries.slice(0, 6),
[generalFeaturedEntries],
);
const generalLatestEntries = useMemo(
() => filterGeneralPublicWorks(latestEntries),
[latestEntries],
);
const allEdutainmentEntries = useMemo(
() => filterEdutainmentPublicWorks([...featuredEntries, ...latestEntries]),
[featuredEntries, latestEntries],
);
const edutainmentEntries = useMemo(
() => (edutainmentEntryEnabled ? allEdutainmentEntries : []),
[allEdutainmentEntries, edutainmentEntryEnabled],
);
const visibleDiscoverChannels = useMemo(
() =>
edutainmentEntryEnabled
? [...DISCOVER_CHANNELS, EDUTAINMENT_DISCOVER_CHANNEL]
: DISCOVER_CHANNELS,
[edutainmentEntryEnabled],
);
const categoryGroups = useMemo(
() =>
buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries),
[generalFeaturedEntries, generalLatestEntries],
);
const publicEntries = useMemo(
() => getPlatformPublicEntries(featuredEntries, latestEntries),
() =>
getPlatformPublicEntries(generalFeaturedEntries, generalLatestEntries),
[generalFeaturedEntries, generalLatestEntries],
);
const allPublicEntries = useMemo(
() => getAllPlatformPublicEntries(featuredEntries, latestEntries),
[featuredEntries, latestEntries],
);
const visibleHistoryEntries = useMemo(
() =>
historyEntries.filter((entry) => {
const matchedPublicWork = findPublicWorkForHistoryEntry(
entry,
allPublicEntries,
);
return !matchedPublicWork || canExposePublicWork(matchedPublicWork);
}),
[allPublicEntries, historyEntries],
);
const workSearchResults = useMemo(
() =>
filterPlatformWorkSearchResults(publicEntries, activeWorkSearchKeyword),
@@ -3257,6 +3332,12 @@ export function RpgEntryHomeView({
}
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
useEffect(() => {
if (!visibleDiscoverChannels.some((channel) => channel.id === discoverChannel)) {
setDiscoverChannel('recommend');
}
}, [discoverChannel, visibleDiscoverChannels]);
useEffect(() => {
setVisitedTabs((currentTabs) => {
if (currentTabs.has(activeTab)) {
@@ -3739,6 +3820,10 @@ export function RpgEntryHomeView({
publicEntries,
trimmedKeyword,
);
const hiddenEdutainmentMatches = filterPlatformWorkSearchResults(
allEdutainmentEntries,
trimmedKeyword,
);
if (
matchedEntries.length > 0 &&
isExactPublicWorkCodeSearch(matchedEntries, trimmedKeyword) &&
@@ -3755,6 +3840,11 @@ export function RpgEntryHomeView({
return;
}
if (hiddenEdutainmentMatches.length > 0) {
setActiveWorkSearchKeyword(trimmedKeyword);
return;
}
setActiveWorkSearchKeyword('');
if (!onSearchPublicCode || isSearchingPublicCode) {
return;
@@ -3769,50 +3859,58 @@ export function RpgEntryHomeView({
submitWorkSearch(mobileSearchKeyword);
};
const desktopHeroEntry =
featuredShelf[0] ?? latestEntries[0] ?? myEntries[0] ?? null;
featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null;
const desktopHeroCover = desktopHeroEntry
? resolvePlatformWorldCoverImage(desktopHeroEntry)
: null;
const desktopHeroStripEntries = (
featuredShelf.length > 0 ? featuredShelf : latestEntries
featuredShelf.length > 0 ? featuredShelf : generalLatestEntries
).slice(0, 5);
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
const desktopRecommendEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...latestEntries].forEach((entry) => {
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries]);
}, [featuredShelf, generalLatestEntries]);
const desktopTodayEntries = useMemo(
() => filterTodayPublishedEntries(latestEntries),
[latestEntries],
() => filterTodayPublishedEntries(generalLatestEntries),
[generalLatestEntries],
);
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const recommendedFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...latestEntries].forEach((entry) => {
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries]);
}, [featuredShelf, generalLatestEntries]);
const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
discoverChannel === 'recommend'
? recommendedFeedEntries
: filterTodayPublishedEntries(latestEntries);
: filterTodayPublishedEntries(generalLatestEntries);
sourceEntries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [discoverChannel, latestEntries, recommendedFeedEntries]);
}, [discoverChannel, generalLatestEntries, recommendedFeedEntries]);
const edutainmentFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
edutainmentEntries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [edutainmentEntries]);
const mobileFeedCarouselEnabled =
!isDesktopLayout &&
activeTab === 'category' &&
@@ -4125,7 +4223,7 @@ export function RpgEntryHomeView({
isAuthenticated,
openRecommendGalleryDetail,
]);
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
openRecommendGalleryDetail(leadPublicEntry);
@@ -4324,7 +4422,7 @@ export function RpgEntryHomeView({
) : (
<>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{DISCOVER_CHANNELS.map((channel) => {
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
@@ -4403,6 +4501,31 @@ export function RpgEntryHomeView({
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{edutainmentFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-edutainment`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
feedCardKey={cardKey}
/>
);
})}
</div>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />
)}
</section>
) : (
<section
ref={mobileDiscoverFeedRef}
@@ -4439,8 +4562,122 @@ export function RpgEntryHomeView({
</div>
);
const desktopDiscoverContent: ReactNode = (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
key={`desktop-${channel.id}`}
type="button"
onClick={() => setDiscoverChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取作品分类..." />
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={`${group.tag}:desktop-discover-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-category:${activeCategoryGroup.tag}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
))}
</div>
</>
) : (
<EmptyShelf text="暂时还没有可分类的作品。" />
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : edutainmentFeedEntries.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{edutainmentFeedEntries.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-edutainment`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />
)}
</section>
) : (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={discoverChannel === 'today' ? '今日游戏' : '推荐'}
detail={discoverChannel === 'today' ? 'TODAY GAMES' : 'RECOMMENDED'}
/>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : discoverFeedEntries.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{discoverFeedEntries.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-feed:${discoverChannel}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</section>
)}
</div>
);
const categoryContent: ReactNode = isDesktopLayout ? (
<div className={DESKTOP_PAGE_STAGE_CLASS}>{mobileRankingPanel}</div>
desktopDiscoverContent
) : (
mobileDiscoverContent
);
@@ -4880,7 +5117,7 @@ export function RpgEntryHomeView({
</div>
<div
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="推荐" detail="RECOMMENDED" />
@@ -4903,7 +5140,7 @@ export function RpgEntryHomeView({
)}
</section>
{desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? (
{desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
@@ -4948,7 +5185,7 @@ export function RpgEntryHomeView({
</div>
) : (
<div className="mt-3 space-y-3">
{historyEntries.slice(0, 2).map((entry) => {
{visibleHistoryEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);

View File

@@ -5695,6 +5695,473 @@ button {
color: rgba(255, 255, 255, 0.9) !important;
}
.child-motion-demo {
--child-motion-bg: #07151c;
--child-motion-panel: rgba(6, 24, 30, 0.64);
--child-motion-panel-border: rgba(178, 239, 220, 0.25);
--child-motion-text: #eefcf7;
--child-motion-soft: rgba(238, 252, 247, 0.7);
--child-motion-green: #5ff08f;
--child-motion-sky: #8fd8ff;
display: grid;
width: 100%;
min-width: 0;
height: 100vh;
min-height: 100vh;
place-items: center;
overflow: hidden;
background:
radial-gradient(circle at 18% 14%, rgba(143, 216, 255, 0.24), transparent 32%),
radial-gradient(circle at 82% 22%, rgba(95, 240, 143, 0.18), transparent 30%),
linear-gradient(180deg, #092433 0%, var(--child-motion-bg) 54%, #0a1f18 100%);
color: var(--child-motion-text);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
@supports (height: 100dvh) {
.child-motion-demo {
height: 100dvh;
min-height: 100dvh;
}
}
.child-motion-stage {
position: relative;
width: min(100vw, calc(100vh * 16 / 9));
height: min(100vh, calc(100vw * 9 / 16));
overflow: hidden;
background:
linear-gradient(180deg, rgba(16, 64, 86, 0.86), rgba(9, 42, 39, 0.9)),
var(--child-motion-bg);
box-shadow: 0 30px 100px rgba(0, 0, 0, 0.38);
touch-action: none;
user-select: none;
}
@supports (height: 100dvh) {
.child-motion-stage {
width: min(100vw, calc(100dvh * 16 / 9));
height: min(100dvh, calc(100vw * 9 / 16));
}
}
.child-motion-camera-layer {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
background:
radial-gradient(circle at 50% 33%, rgba(255, 255, 255, 0.12), transparent 28%),
linear-gradient(110deg, rgba(255, 255, 255, 0.06) 0 12%, transparent 12% 20%, rgba(255, 255, 255, 0.04) 20% 31%, transparent 31% 100%);
filter: blur(7px) saturate(0.8);
opacity: 0.62;
transform: scale(1.05);
}
.child-motion-camera-state {
position: absolute;
top: 18%;
left: 50%;
z-index: 7;
transform: translateX(-50%);
border: 1px solid rgba(238, 252, 247, 0.2);
border-radius: 999px;
background: rgba(6, 24, 30, 0.52);
color: rgba(238, 252, 247, 0.82);
padding: 0.45rem 0.9rem;
font-size: clamp(0.68rem, 1.35vw, 0.84rem);
font-weight: 800;
backdrop-filter: blur(12px);
}
.child-motion-floor {
position: absolute;
right: -8%;
bottom: -19%;
left: -8%;
height: 47%;
border-radius: 50% 50% 0 0;
background:
radial-gradient(ellipse at 50% 8%, rgba(190, 255, 220, 0.22), transparent 36%),
linear-gradient(180deg, rgba(24, 86, 67, 0.84), rgba(7, 43, 34, 0.96));
box-shadow: inset 0 22px 70px rgba(255, 255, 255, 0.07);
}
.child-motion-hud {
position: absolute;
z-index: 8;
display: flex;
align-items: center;
gap: clamp(0.6rem, 1.8vw, 1rem);
border: 1px solid var(--child-motion-panel-border);
border-radius: clamp(0.75rem, 2vw, 1.25rem);
background: var(--child-motion-panel);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(14px);
}
.child-motion-hud--top {
top: 4.2%;
left: 50%;
width: min(72%, 48rem);
min-height: clamp(4.2rem, 11vh, 6.25rem);
transform: translateX(-50%);
padding: clamp(0.65rem, 1.8vw, 1rem) clamp(0.8rem, 2.2vw, 1.25rem);
}
.child-motion-hud h1 {
margin: 0;
color: var(--child-motion-text);
font-size: clamp(1.2rem, 3.2vw, 2rem);
font-weight: 900;
line-height: 1.08;
}
.child-motion-hud p {
margin: 0.28rem 0 0;
color: var(--child-motion-soft);
font-size: clamp(0.72rem, 1.45vw, 0.98rem);
font-weight: 700;
line-height: 1.45;
}
.child-motion-step-count,
.child-motion-progress {
display: inline-flex;
width: clamp(2.7rem, 7vw, 4rem);
height: clamp(2.7rem, 7vw, 4rem);
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid rgba(238, 252, 247, 0.2);
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
color: var(--child-motion-text);
font-size: clamp(0.72rem, 1.45vw, 0.95rem);
font-weight: 900;
}
.child-motion-ring {
position: absolute;
bottom: 20.5%;
z-index: 3;
width: clamp(5.8rem, 13vw, 9rem);
aspect-ratio: 1;
transform: translateX(-50%) rotateX(62deg);
border-radius: 999px;
background:
conic-gradient(
from -90deg,
rgba(255, 255, 255, 0.95) 0 var(--child-motion-ring-progress),
rgba(95, 240, 143, 0.18) var(--child-motion-ring-progress) 360deg
);
box-shadow:
0 0 28px rgba(95, 240, 143, 0.42),
inset 0 0 26px rgba(255, 255, 255, 0.18);
}
.child-motion-ring::before {
position: absolute;
inset: 14%;
border-radius: inherit;
background: rgba(8, 44, 36, 0.94);
content: '';
}
.child-motion-ring__core {
position: absolute;
inset: 34%;
border-radius: 999px;
background: var(--child-motion-green);
opacity: 0.28;
}
.child-motion-ring--active {
animation: child-motion-ring-pulse 0.78s ease-in-out infinite alternate;
}
@keyframes child-motion-ring-pulse {
from {
filter: brightness(1);
}
to {
filter: brightness(1.25);
}
}
.child-motion-avatar {
position: absolute;
bottom: 24%;
z-index: 5;
width: clamp(3.4rem, 7vw, 5.6rem);
height: clamp(6rem, 13vw, 10rem);
transform: translateX(-50%);
transition: left 260ms ease, transform 220ms ease;
}
.child-motion-avatar--jumping {
transform: translate(-50%, -14%);
}
.child-motion-avatar__head,
.child-motion-avatar__body,
.child-motion-avatar__arm,
.child-motion-avatar__leg {
position: absolute;
display: block;
background: rgba(7, 18, 24, 0.82);
box-shadow: 0 0 24px rgba(143, 216, 255, 0.18);
}
.child-motion-avatar__head {
top: 0;
left: 50%;
width: 34%;
aspect-ratio: 1;
transform: translateX(-50%);
border-radius: 999px;
}
.child-motion-avatar__body {
top: 27%;
left: 50%;
width: 42%;
height: 36%;
transform: translateX(-50%);
border-radius: 999px 999px 45% 45%;
}
.child-motion-avatar__arm {
top: 33%;
width: 15%;
height: 34%;
border-radius: 999px;
}
.child-motion-avatar__arm--left {
left: 17%;
transform: rotate(18deg);
}
.child-motion-avatar__arm--right {
right: 17%;
transform: rotate(-18deg);
}
.child-motion-avatar__leg {
bottom: 0;
width: 15%;
height: 34%;
border-radius: 999px;
}
.child-motion-avatar__leg--left {
left: 36%;
transform: rotate(7deg);
}
.child-motion-avatar__leg--right {
right: 36%;
transform: rotate(-7deg);
}
.child-motion-gesture-guide {
position: absolute;
inset: 20% 22% 19%;
z-index: 4;
pointer-events: none;
}
.child-motion-gesture-guide__wave,
.child-motion-gesture-guide__jump {
position: absolute;
left: 50%;
top: 38%;
display: inline-flex;
width: clamp(4.5rem, 11vw, 8rem);
aspect-ratio: 1;
transform: translate(-50%, -50%);
align-items: center;
justify-content: center;
border: 2px solid rgba(95, 240, 143, 0.64);
border-radius: 999px;
background: rgba(95, 240, 143, 0.1);
color: var(--child-motion-text);
font-size: clamp(1rem, 2.4vw, 1.55rem);
font-weight: 900;
}
.child-motion-gesture-guide__hand {
position: absolute;
top: 28%;
width: clamp(4rem, 9vw, 7rem);
aspect-ratio: 1;
border: 2px dashed rgba(95, 240, 143, 0.58);
border-radius: 999px;
animation: child-motion-hand-guide 1.1s ease-in-out infinite alternate;
}
.child-motion-gesture-guide__hand--left {
left: 22%;
}
.child-motion-gesture-guide__hand--right {
right: 22%;
}
@keyframes child-motion-hand-guide {
from {
transform: translateY(0);
}
to {
transform: translateY(-10%);
}
}
.child-motion-gesture-guide__trail {
position: absolute;
width: 0.8rem;
height: 0.8rem;
transform: translate(-50%, -50%);
border-radius: 999px;
background: #b9ffd0;
box-shadow: 0 0 16px rgba(95, 240, 143, 0.56);
}
.child-motion-floating-reward {
position: absolute;
left: 50%;
top: 34%;
z-index: 9;
transform: translateX(-50%);
color: #ffffff;
font-size: clamp(1.4rem, 4vw, 2.4rem);
font-weight: 900;
text-shadow: 0 4px 26px rgba(0, 0, 0, 0.42);
animation: child-motion-reward-rise 0.72s ease-out forwards;
}
@keyframes child-motion-reward-rise {
from {
opacity: 0;
transform: translate(-50%, 22%);
}
to {
opacity: 1;
transform: translate(-50%, -18%);
}
}
.child-motion-calibration {
position: absolute;
right: 3.2%;
bottom: 4%;
z-index: 8;
display: grid;
grid-template-columns: repeat(5, minmax(0, auto));
gap: 0.45rem;
max-width: 82%;
border: 1px solid var(--child-motion-panel-border);
border-radius: 999px;
background: var(--child-motion-panel);
padding: 0.45rem;
backdrop-filter: blur(14px);
}
.child-motion-calibration div {
display: grid;
min-width: clamp(3.2rem, 7vw, 4.8rem);
gap: 0.08rem;
justify-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
padding: 0.36rem 0.55rem;
}
.child-motion-calibration span {
color: var(--child-motion-soft);
font-size: clamp(0.55rem, 1.2vw, 0.72rem);
font-weight: 800;
}
.child-motion-calibration strong {
color: var(--child-motion-text);
font-size: clamp(0.72rem, 1.5vw, 0.95rem);
font-weight: 900;
}
.child-motion-start-panel {
position: absolute;
left: 50%;
top: 53%;
z-index: 10;
display: flex;
transform: translate(-50%, -50%);
align-items: center;
gap: 0.85rem;
border: 1px solid rgba(178, 239, 220, 0.32);
border-radius: 1.4rem;
background: rgba(6, 24, 30, 0.7);
padding: clamp(0.85rem, 2vw, 1.15rem);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
.child-motion-start-panel button {
min-width: clamp(8rem, 18vw, 12rem);
min-height: clamp(3rem, 7vw, 4.2rem);
border: 0;
border-radius: 999px;
background: linear-gradient(135deg, #5ff08f, #8fd8ff);
color: #062018;
font-size: clamp(1rem, 2.5vw, 1.4rem);
font-weight: 950;
cursor: pointer;
box-shadow: 0 16px 44px rgba(95, 240, 143, 0.28);
}
.child-motion-start-panel span {
color: var(--child-motion-text);
font-size: clamp(1rem, 2vw, 1.25rem);
font-weight: 900;
}
.child-motion-orientation-tip {
position: fixed;
inset: 0;
z-index: 30;
display: none;
place-items: center;
background: #07151c;
color: var(--child-motion-text);
font-size: 1.25rem;
font-weight: 900;
}
@media (orientation: portrait) and (max-width: 920px) {
.child-motion-orientation-tip {
display: grid;
}
}
@media (max-width: 760px) {
.child-motion-hud--top {
width: 88%;
}
.child-motion-calibration {
left: 50%;
right: auto;
grid-template-columns: repeat(5, minmax(0, 1fr));
width: min(92%, 35rem);
transform: translateX(-50%);
}
}
@media (min-width: 768px) {
.platform-work-detail {
border-radius: 1.2rem;

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { matchAppRoute } from './appRoutes';
afterEach(() => {
vi.unstubAllEnvs();
});
describe('matchAppRoute', () => {
it('routes the main app by default', () => {
expect(matchAppRoute('/')).toEqual({
@@ -27,6 +31,20 @@ describe('matchAppRoute', () => {
});
});
it('routes child motion demo path to the standalone warmup demo', () => {
expect(matchAppRoute('/CHILD-MOTION-DEMO/')).toEqual({
kind: 'child-motion-demo',
});
});
it('blocks direct child motion demo path when edutainment entry is disabled', () => {
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
expect(matchAppRoute('/child-motion-demo')).toEqual({
kind: 'game',
});
});
it('routes former standalone editor paths back to the main game', () => {
expect(matchAppRoute('/item-editor/tools')).toEqual({
kind: 'game',

View File

@@ -2,6 +2,7 @@
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
import { isEdutainmentEntryEnabled } from '../components/platform-entry/platformEdutainmentVisibility';
import { normalizeAppPath } from './appPageRoutes';
type AppRouteComponent = LazyExoticComponent<
@@ -18,6 +19,9 @@ export type AppRouteMatch =
| {
kind: 'match3d-playground';
}
| {
kind: 'child-motion-demo';
}
| {
kind: 'game';
};
@@ -34,6 +38,7 @@ const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
const ChildMotionDemoApp = lazy(() => import('../ChildMotionDemoApp')) as AppRouteComponent;
function normalizeRoutePath(pathname: string) {
return normalizeAppPath(pathname);
@@ -60,6 +65,15 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
};
}
if (
normalizedPath === '/child-motion-demo' &&
isEdutainmentEntryEnabled()
) {
return {
kind: 'child-motion-demo',
};
}
return {
kind: 'game',
};
@@ -95,6 +109,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
};
}
if (matchedRoute.kind === 'child-motion-demo') {
return {
kind: 'child-motion-demo',
loadingEyebrow: '正在载入热身关',
loadingText: '正在进入寓教于乐 Demo...',
Component: ChildMotionDemoApp,
};
}
return {
kind: 'game',
loadingEyebrow: '正在载入游戏',

View File

@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
type ChildMotionDebugAction,
createChildMotionDebugInputController,
resolveKeyboardDebugAction,
} from './childMotionDebugInput';
let mountedTargets: HTMLElement[] = [];
afterEach(() => {
mountedTargets.forEach((target) => target.remove());
mountedTargets = [];
});
function createTarget() {
const target = document.createElement('div');
document.body.appendChild(target);
mountedTargets.push(target);
return target;
}
function dispatchKeyboard(
target: HTMLElement,
options: { key: string; code?: string; repeat?: boolean },
) {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: options.key,
code: options.code ?? '',
repeat: options.repeat ?? false,
});
target.dispatchEvent(event);
return event;
}
function dispatchPointer(
target: HTMLElement,
type: string,
options: {
button?: number;
clientX: number;
clientY: number;
pointerId?: number;
},
) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
button: options.button ?? 0,
clientX: options.clientX,
clientY: options.clientY,
});
Object.assign(event, { pointerId: options.pointerId ?? 1 });
target.dispatchEvent(event);
return event;
}
describe('childMotionDebugInput', () => {
test('maps A, D and Space keys to movement and jump actions', () => {
const target = createTarget();
const actions: ChildMotionDebugAction[] = [];
const controller = createChildMotionDebugInputController({
target,
onAction: (action) => actions.push(action),
now: () => 120,
});
const leftEvent = dispatchKeyboard(target, { key: 'a', code: 'KeyA' });
dispatchKeyboard(target, { key: 'D', code: 'KeyD' });
dispatchKeyboard(target, { key: ' ', code: 'Space' });
expect(leftEvent.defaultPrevented).toBe(true);
expect(actions).toEqual([
{
kind: 'move',
direction: 'left',
source: 'keyboard',
occurredAtMs: 120,
},
{
kind: 'move',
direction: 'right',
source: 'keyboard',
occurredAtMs: 120,
},
{
kind: 'jump',
source: 'keyboard',
occurredAtMs: 120,
},
]);
controller.dispose();
});
test('ignores repeated or unrelated keyboard events', () => {
const unrelatedEvent = new KeyboardEvent('keydown', {
key: 'x',
code: 'KeyX',
});
const repeatEvent = new KeyboardEvent('keydown', {
key: 'a',
code: 'KeyA',
repeat: true,
});
expect(resolveKeyboardDebugAction(unrelatedEvent)).toBeNull();
expect(resolveKeyboardDebugAction(repeatEvent)).toBeNull();
});
test('maps left mouse drag to a left hand trajectory', () => {
const target = createTarget();
const actions: ChildMotionDebugAction[] = [];
const controller = createChildMotionDebugInputController({
target,
onAction: (action) => actions.push(action),
now: () => 240,
});
dispatchPointer(target, 'pointerdown', {
button: 0,
clientX: 10,
clientY: 20,
pointerId: 7,
});
dispatchPointer(target, 'pointermove', {
clientX: 18,
clientY: 24,
pointerId: 7,
});
dispatchPointer(target, 'pointerup', {
clientX: 22,
clientY: 28,
pointerId: 7,
});
expect(actions).toEqual([
{
kind: 'hand_trace',
hand: 'left',
phase: 'start',
pointerId: 7,
point: { x: 10, y: 20 },
path: [{ x: 10, y: 20 }],
source: 'pointer',
occurredAtMs: 240,
},
{
kind: 'hand_trace',
hand: 'left',
phase: 'move',
pointerId: 7,
point: { x: 18, y: 24 },
path: [
{ x: 10, y: 20 },
{ x: 18, y: 24 },
],
source: 'pointer',
occurredAtMs: 240,
},
{
kind: 'hand_trace',
hand: 'left',
phase: 'end',
pointerId: 7,
point: { x: 22, y: 28 },
path: [
{ x: 10, y: 20 },
{ x: 18, y: 24 },
{ x: 22, y: 28 },
],
source: 'pointer',
occurredAtMs: 240,
},
]);
controller.dispose();
});
test('maps right mouse drag to a right hand trajectory and prevents context menu', () => {
const target = createTarget();
const actions: ChildMotionDebugAction[] = [];
const controller = createChildMotionDebugInputController({
target,
onAction: (action) => actions.push(action),
now: () => 360,
});
const pointerDown = dispatchPointer(target, 'pointerdown', {
button: 2,
clientX: 30,
clientY: 40,
pointerId: 9,
});
dispatchPointer(target, 'pointermove', {
clientX: 44,
clientY: 48,
pointerId: 9,
});
dispatchPointer(target, 'pointercancel', {
clientX: 48,
clientY: 52,
pointerId: 9,
});
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2,
});
target.dispatchEvent(contextMenuEvent);
expect(pointerDown.defaultPrevented).toBe(true);
expect(contextMenuEvent.defaultPrevented).toBe(true);
expect(actions.map((action) => action.kind)).toEqual([
'hand_trace',
'hand_trace',
'hand_trace',
]);
expect(actions[0]).toMatchObject({
hand: 'right',
phase: 'start',
point: { x: 30, y: 40 },
});
expect(actions[2]).toMatchObject({
hand: 'right',
phase: 'cancel',
point: { x: 48, y: 52 },
});
controller.dispose();
});
test('can be disabled or disposed without emitting debug actions', () => {
const target = createTarget();
const onAction = vi.fn();
const controller = createChildMotionDebugInputController({
target,
onAction,
});
controller.setEnabled(false);
expect(controller.isEnabled()).toBe(false);
dispatchKeyboard(target, { key: 'a', code: 'KeyA' });
dispatchPointer(target, 'pointerdown', {
button: 0,
clientX: 10,
clientY: 20,
});
expect(onAction).not.toHaveBeenCalled();
controller.setEnabled(true);
dispatchKeyboard(target, { key: 'd', code: 'KeyD' });
expect(onAction).toHaveBeenCalledTimes(1);
controller.dispose();
dispatchKeyboard(target, { key: ' ', code: 'Space' });
expect(onAction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,287 @@
export type ChildMotionDebugMoveDirection = 'left' | 'right';
export type ChildMotionDebugHand = 'left' | 'right';
export type ChildMotionDebugHandTracePhase = 'start' | 'move' | 'end' | 'cancel';
export type ChildMotionDebugPoint = {
x: number;
y: number;
};
export type ChildMotionDebugMoveAction = {
kind: 'move';
direction: ChildMotionDebugMoveDirection;
source: 'keyboard';
occurredAtMs: number;
};
export type ChildMotionDebugJumpAction = {
kind: 'jump';
source: 'keyboard';
occurredAtMs: number;
};
export type ChildMotionDebugHandTraceAction = {
kind: 'hand_trace';
hand: ChildMotionDebugHand;
phase: ChildMotionDebugHandTracePhase;
pointerId: number;
point: ChildMotionDebugPoint;
path: ChildMotionDebugPoint[];
source: 'pointer';
occurredAtMs: number;
};
export type ChildMotionDebugAction =
| ChildMotionDebugMoveAction
| ChildMotionDebugJumpAction
| ChildMotionDebugHandTraceAction;
type ChildMotionDebugActionPayload =
| Omit<ChildMotionDebugMoveAction, 'occurredAtMs'>
| Omit<ChildMotionDebugJumpAction, 'occurredAtMs'>
| Omit<ChildMotionDebugHandTraceAction, 'occurredAtMs'>;
export type ChildMotionDebugInputTarget = Pick<
EventTarget,
'addEventListener' | 'removeEventListener'
>;
export type ChildMotionDebugInputOptions = {
target: ChildMotionDebugInputTarget;
onAction: (action: ChildMotionDebugAction) => void;
enabled?: boolean;
now?: () => number;
preventContextMenu?: boolean;
};
export type ChildMotionDebugInputController = {
dispose: () => void;
isEnabled: () => boolean;
setEnabled: (enabled: boolean) => void;
};
type ActiveHandTrace = {
hand: ChildMotionDebugHand;
path: ChildMotionDebugPoint[];
};
const DEFAULT_POINTER_ID = 1;
export function createChildMotionDebugInputController(
options: ChildMotionDebugInputOptions,
): ChildMotionDebugInputController {
const { target, onAction, now = () => Date.now() } = options;
const preventContextMenu = options.preventContextMenu ?? true;
const activeHandTraces = new Map<number, ActiveHandTrace>();
let enabled = options.enabled ?? true;
const emit = (action: ChildMotionDebugActionPayload) => {
onAction({
...action,
occurredAtMs: now(),
});
};
const handleKeyDown = (event: Event) => {
if (!enabled) {
return;
}
const action = resolveKeyboardDebugAction(event);
if (!action) {
return;
}
event.preventDefault();
emit(action);
};
const handlePointerDown = (event: Event) => {
if (!enabled) {
return;
}
const hand = resolvePointerHand(event);
if (!hand) {
return;
}
event.preventDefault();
const pointerId = readPointerId(event);
const point = readPointerPoint(event);
const trace: ActiveHandTrace = {
hand,
path: [point],
};
activeHandTraces.set(pointerId, trace);
emit({
kind: 'hand_trace',
hand,
phase: 'start',
pointerId,
point,
path: trace.path,
source: 'pointer',
});
};
const handlePointerMove = (event: Event) => {
if (!enabled) {
return;
}
const pointerId = readPointerId(event);
const trace = activeHandTraces.get(pointerId);
if (!trace) {
return;
}
event.preventDefault();
const point = readPointerPoint(event);
trace.path = [...trace.path, point];
activeHandTraces.set(pointerId, trace);
emit({
kind: 'hand_trace',
hand: trace.hand,
phase: 'move',
pointerId,
point,
path: trace.path,
source: 'pointer',
});
};
const finishPointerTrace = (
event: Event,
phase: Extract<ChildMotionDebugHandTracePhase, 'end' | 'cancel'>,
) => {
if (!enabled) {
return;
}
const pointerId = readPointerId(event);
const trace = activeHandTraces.get(pointerId);
if (!trace) {
return;
}
event.preventDefault();
const point = readPointerPoint(event);
const path = [...trace.path, point];
activeHandTraces.delete(pointerId);
emit({
kind: 'hand_trace',
hand: trace.hand,
phase,
pointerId,
point,
path,
source: 'pointer',
});
};
const handlePointerUp = (event: Event) => finishPointerTrace(event, 'end');
const handlePointerCancel = (event: Event) =>
finishPointerTrace(event, 'cancel');
const handleContextMenu = (event: Event) => {
if (enabled && preventContextMenu) {
event.preventDefault();
}
};
target.addEventListener('keydown', handleKeyDown);
target.addEventListener('pointerdown', handlePointerDown);
target.addEventListener('pointermove', handlePointerMove);
target.addEventListener('pointerup', handlePointerUp);
target.addEventListener('pointercancel', handlePointerCancel);
target.addEventListener('contextmenu', handleContextMenu);
return {
dispose: () => {
activeHandTraces.clear();
target.removeEventListener('keydown', handleKeyDown);
target.removeEventListener('pointerdown', handlePointerDown);
target.removeEventListener('pointermove', handlePointerMove);
target.removeEventListener('pointerup', handlePointerUp);
target.removeEventListener('pointercancel', handlePointerCancel);
target.removeEventListener('contextmenu', handleContextMenu);
},
isEnabled: () => enabled,
setEnabled: (nextEnabled: boolean) => {
enabled = nextEnabled;
if (!enabled) {
activeHandTraces.clear();
}
},
};
}
export function resolveKeyboardDebugAction(
event: Event,
):
| Omit<ChildMotionDebugMoveAction, 'occurredAtMs'>
| Omit<ChildMotionDebugJumpAction, 'occurredAtMs'>
| null {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.repeat) {
return null;
}
const normalizedKey = keyboardEvent.key?.toLocaleLowerCase('en-US') ?? '';
const normalizedCode = keyboardEvent.code ?? '';
if (normalizedKey === 'a' || normalizedCode === 'KeyA') {
return {
kind: 'move',
direction: 'left',
source: 'keyboard',
};
}
if (normalizedKey === 'd' || normalizedCode === 'KeyD') {
return {
kind: 'move',
direction: 'right',
source: 'keyboard',
};
}
if (
keyboardEvent.key === ' ' ||
keyboardEvent.key === 'Spacebar' ||
normalizedCode === 'Space'
) {
return {
kind: 'jump',
source: 'keyboard',
};
}
return null;
}
function resolvePointerHand(event: Event): ChildMotionDebugHand | null {
const button = (event as MouseEvent).button;
if (button === 0) {
return 'left';
}
if (button === 2) {
return 'right';
}
return null;
}
function readPointerId(event: Event) {
const pointerId = (event as PointerEvent).pointerId;
return typeof pointerId === 'number' ? pointerId : DEFAULT_POINTER_ID;
}
function readPointerPoint(event: Event): ChildMotionDebugPoint {
const mouseEvent = event as MouseEvent;
return {
x: mouseEvent.clientX,
y: mouseEvent.clientY,
};
}

View File

@@ -0,0 +1 @@
export * from './childMotionDebugInput';

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

View File

@@ -0,0 +1,81 @@
import { describe, expect, test } from 'vitest';
import { parseMocapPacket, resolveMocapPalmCenter } from './useMocapInput';
describe('resolveMocapPalmCenter', () => {
test('优先用手腕和四个 MCP 点加权计算掌心派生点', () => {
const center = resolveMocapPalmCenter([
{ name: 'wrist', x: 0.1, y: 0.2 },
{ name: 'index_mcp', x: 0.3, y: 0.4 },
{ name: 'middle_mcp', x: 0.5, y: 0.6 },
{ name: 'ring_mcp', x: 0.7, y: 0.8 },
{ name: 'pinky_mcp', x: 0.9, y: 1 },
{ name: 'index_finger_tip', x: 1, y: 1 },
]);
expect(center?.x).toBeCloseTo(0.44);
expect(center?.y).toBeCloseTo(0.54);
});
test('可用掌心点少于三个时不返回掌心坐标', () => {
expect(
resolveMocapPalmCenter([
{ name: 'wrist', x: 0.1, y: 0.2 },
{ name: 'index_mcp', x: 0.3, y: 0.4 },
]),
).toBeNull();
});
});
describe('parseMocapPacket', () => {
test('解析手部数据时优先把 primaryHand 定位到掌心而不是腕部或指尖', () => {
const command = parseMocapPacket({
hands: [
{
state: 'open_palm',
x: 0.01,
y: 0.02,
landmarks: [
{ name: 'wrist', x: 0.1, y: 0.2 },
{ name: 'index_mcp', x: 0.3, y: 0.4 },
{ name: 'middle_mcp', x: 0.5, y: 0.6 },
{ name: 'ring_mcp', x: 0.7, y: 0.8 },
{ name: 'pinky_mcp', x: 0.9, y: 1 },
],
},
],
});
expect(command.primaryHand?.x).toBeCloseTo(0.44);
expect(command.primaryHand?.y).toBeCloseTo(0.54);
expect(command.primaryHand).toEqual(
expect.objectContaining({
state: 'open_palm',
source: 'palm_center',
}),
);
});
test('缺少足够掌心关键点时退回 wrist landmark再退回 hand 直出坐标', () => {
const landmarkFallback = parseMocapPacket({
hands: [
{
state: 'grab',
x: 0.9,
y: 0.8,
landmarks: [{ name: 'wrist', x: 0.25, y: 0.75 }],
},
],
});
expect(landmarkFallback.primaryHand).toEqual(
expect.objectContaining({x: 0.25, y: 0.75, source: 'landmark'}),
);
const directFallback = parseMocapPacket({
hands: [{ state: 'grab', x: 0.9, y: 0.8 }],
});
expect(directFallback.primaryHand).toEqual(
expect.objectContaining({x: 0.9, y: 0.8, source: 'direct'}),
);
});
});

View File

@@ -3,14 +3,29 @@ import {useEffect, useMemo, useRef, useState} from 'react';
export type MocapConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error';
export type MocapHandState = 'open_palm' | 'grab' | 'unknown';
export type MocapHandSide = 'left' | 'right' | 'unknown';
export type MocapHandSource = 'palm_center' | 'direct' | 'landmark';
export type MocapHandInput = {
x: number;
y: number;
state: MocapHandState;
side: MocapHandSide;
source?: MocapHandSource;
};
export type MocapBodyCenterInput = {
x: number;
y: number;
};
export type MocapInputCommand = {
actions: string[];
primaryHand?: {
x: number;
y: number;
state: MocapHandState;
} | null;
hands?: MocapHandInput[];
primaryHand?: MocapHandInput | null;
leftHand?: MocapHandInput | null;
rightHand?: MocapHandInput | null;
bodyCenter?: MocapBodyCenterInput | null;
parseWarnings?: string[];
};
@@ -32,9 +47,19 @@ export type UseMocapInputOptions = {
reconnectDelayMs?: number;
};
type MocapLandmarkRecord = Record<string, unknown>;
const DEFAULT_MOCAP_SERVICE_URL = 'http://127.0.0.1:8876';
const DEFAULT_RECONNECT_DELAY_MS = 1200;
const MAX_RAW_PACKET_PREVIEW_LENGTH = 360;
const PALM_CENTER_WEIGHTS = [
['wrist', 0.25],
['index_mcp', 0.2],
['middle_mcp', 0.25],
['ring_mcp', 0.2],
['pinky_mcp', 0.1],
] as const;
const MIN_PALM_CENTER_POINT_COUNT = 3;
function buildRawPacketPreview(rawData: unknown): string {
const rawText = typeof rawData === 'string' ? rawData : JSON.stringify(rawData);
@@ -57,89 +82,301 @@ function normalizeCoordinate(value: unknown) {
return Math.min(1, Math.max(0, numericValue));
}
function resolvePrimaryHand(hands: unknown) {
if (!Array.isArray(hands)) {
function resolveNormalizedPoint(value: unknown) {
if (Array.isArray(value)) {
const x = normalizeCoordinate(value[0]);
const y = normalizeCoordinate(value[1]);
if (x === null || y === null) {
return null;
}
return {x, y};
}
if (!value || typeof value !== 'object') {
return null;
}
for (const hand of hands) {
if (!hand || typeof hand !== 'object') {
continue;
}
const handRecord = hand as {state?: unknown; landmarks?: unknown; x?: unknown; y?: unknown};
const state = normaliseHandState(handRecord.state);
const directX = normalizeCoordinate(handRecord.x);
const directY = normalizeCoordinate(handRecord.y);
if (directX !== null && directY !== null) {
return {x: directX, y: directY, state};
}
if (!Array.isArray(handRecord.landmarks)) {
continue;
}
const landmarks = handRecord.landmarks as Array<Record<string, unknown>>;
const landmark =
landmarks.find((item) => item?.name === 'wrist') ?? landmarks[0];
const x = normalizeCoordinate(landmark?.x);
const y = normalizeCoordinate(landmark?.y);
if (x === null || y === null) {
continue;
}
return {x, y, state};
const pointRecord = value as {x?: unknown; y?: unknown};
const x = normalizeCoordinate(pointRecord.x);
const y = normalizeCoordinate(pointRecord.y);
if (x === null || y === null) {
return null;
}
return null;
return {x, y};
}
function resolveHandLike(record: unknown) {
if (!record || typeof record !== 'object') {
return null;
}
const handRecord = record as {state?: unknown; landmarks?: unknown; x?: unknown; y?: unknown};
const state = normaliseHandState(handRecord.state);
const directX = normalizeCoordinate(handRecord.x);
const directY = normalizeCoordinate(handRecord.y);
if (directX !== null && directY !== null) {
return {x: directX, y: directY, state};
}
if (!Array.isArray(handRecord.landmarks)) {
return null;
}
const landmarks = handRecord.landmarks as Array<Record<string, unknown>>;
const landmark =
landmarks.find((item) => item?.name === 'wrist') ?? landmarks[0];
function resolveLandmarkCoordinate(landmark: MocapLandmarkRecord | undefined) {
const x = normalizeCoordinate(landmark?.x);
const y = normalizeCoordinate(landmark?.y);
if (x === null || y === null) {
return null;
}
return {x, y, state};
return {x, y};
}
function normaliseHandState(state: unknown): MocapHandState {
if (state === 'grab' || state === 'open_palm') {
return state;
export function resolveMocapPalmCenter(
landmarks: Array<MocapLandmarkRecord>,
): {x: number; y: number} | null {
const landmarksByName = new Map(
landmarks
.filter((landmark) => typeof landmark?.name === 'string')
.map((landmark) => [String(landmark.name), landmark]),
);
const weightedPoints = PALM_CENTER_WEIGHTS.map(([name, weight]) => {
const point = resolveLandmarkCoordinate(landmarksByName.get(name));
return point ? {...point, weight: Number(weight)} : null;
}).filter((point): point is {x: number; y: number; weight: number} =>
Boolean(point),
);
if (weightedPoints.length < MIN_PALM_CENTER_POINT_COUNT) {
return null;
}
const weightTotal = weightedPoints.reduce((sum, point) => sum + point.weight, 0);
if (weightTotal <= 0) {
return null;
}
return {
x: weightedPoints.reduce((sum, point) => sum + point.x * point.weight, 0) / weightTotal,
y: weightedPoints.reduce((sum, point) => sum + point.y * point.weight, 0) / weightTotal,
};
}
function normaliseHandSide(side: unknown): MocapHandSide {
if (typeof side !== 'string') {
return 'unknown';
}
const normalized = side.trim().toLocaleLowerCase('en-US');
if (
normalized === 'left' ||
normalized === 'l' ||
normalized === 'left hand' ||
normalized === 'left_hand' ||
normalized === 'left-hand' ||
normalized === '左' ||
normalized === '左手'
) {
return 'left';
}
if (
normalized === 'right' ||
normalized === 'r' ||
normalized === 'right hand' ||
normalized === 'right_hand' ||
normalized === 'right-hand' ||
normalized === '右' ||
normalized === '右手'
) {
return 'right';
}
return 'unknown';
}
function parseMocapPacket(packet: unknown): MocapInputCommand {
function normalizeMocapAction(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const normalized = value
.trim()
.toLocaleLowerCase('en-US')
.replace(/\s+/gu, '_')
.replace(/-/gu, '_');
return normalized || null;
}
function addMocapActions(actions: Set<string>, value: unknown) {
if (Array.isArray(value)) {
value.forEach((item) => addMocapActions(actions, item));
return;
}
if (value && typeof value === 'object') {
const actionRecord = value as Record<string, unknown>;
addMocapActions(actions, actionRecord.action);
addMocapActions(actions, actionRecord.actions);
addMocapActions(actions, actionRecord.gesture);
addMocapActions(actions, actionRecord.gestures);
addMocapActions(actions, actionRecord.event);
addMocapActions(actions, actionRecord.name);
addMocapActions(actions, actionRecord.type);
return;
}
const normalized = normalizeMocapAction(value);
if (normalized) {
actions.add(normalized);
}
}
function normaliseHandState(state: unknown): MocapHandState {
if (typeof state !== 'string') {
return 'unknown';
}
const normalized = state
.trim()
.toLocaleLowerCase('en-US')
.replace(/\s+/gu, '_')
.replace(/-/gu, '_');
if (
normalized === 'grab' ||
normalized === 'grabbing' ||
normalized === 'close' ||
normalized === 'fist' ||
normalized === 'closed_fist' ||
normalized === 'closed'
) {
return 'grab';
}
if (
normalized === 'open_palm' ||
normalized === 'open_palm_up' ||
normalized === 'open' ||
normalized === 'palm' ||
normalized === 'hand_open'
) {
return 'open_palm';
}
return 'unknown';
}
function resolveMocapHand(record: unknown, fallbackSide: MocapHandSide) {
if (!record || typeof record !== 'object') {
return null;
}
const handRecord = record as {
state?: unknown;
landmarks?: unknown;
x?: unknown;
y?: unknown;
side?: unknown;
handedness?: unknown;
label?: unknown;
hand?: unknown;
};
const state = normaliseHandState(handRecord.state);
const detectedSide = normaliseHandSide(
handRecord.side ??
handRecord.handedness ??
handRecord.label ??
handRecord.hand,
);
const side = detectedSide === 'unknown' ? fallbackSide : detectedSide;
if (Array.isArray(handRecord.landmarks)) {
const landmarks = handRecord.landmarks as Array<MocapLandmarkRecord>;
const palmCenter = resolveMocapPalmCenter(landmarks);
if (palmCenter) {
return {...palmCenter, state, side, source: 'palm_center' as const};
}
const landmark =
landmarks.find((item) => item?.name === 'wrist') ?? landmarks[0];
const fallbackPoint = resolveLandmarkCoordinate(landmark);
if (fallbackPoint) {
return {...fallbackPoint, state, side, source: 'landmark' as const};
}
}
const directX = normalizeCoordinate(handRecord.x);
const directY = normalizeCoordinate(handRecord.y);
if (directX !== null && directY !== null) {
return {x: directX, y: directY, state, side, source: 'direct' as const};
}
return null;
}
function resolveHands(packetRecord: Record<string, unknown>) {
const resolvedHands: MocapHandInput[] = [];
const packetHands = packetRecord.hands;
if (!Array.isArray(packetHands)) {
const leftHand = resolveMocapHand(
packetRecord.leftHand ?? packetRecord.left_hand,
'left',
);
const rightHand = resolveMocapHand(
packetRecord.rightHand ?? packetRecord.right_hand,
'right',
);
if (leftHand) {
resolvedHands.push(leftHand);
}
if (rightHand) {
resolvedHands.push(rightHand);
}
return resolvedHands;
}
for (const hand of packetHands) {
const resolvedHand = resolveMocapHand(hand, 'unknown');
if (resolvedHand) {
resolvedHands.push(resolvedHand);
}
}
return resolvedHands;
}
function resolveBodyCenter(packetRecord: Record<string, unknown>) {
const generalRecord =
packetRecord.general && typeof packetRecord.general === 'object'
? (packetRecord.general as Record<string, unknown>)
: null;
const bodyCandidates = [generalRecord?.body, packetRecord.body];
for (const bodyCandidate of bodyCandidates) {
if (!bodyCandidate || typeof bodyCandidate !== 'object') {
continue;
}
const bodyRecord = bodyCandidate as Record<string, unknown>;
const center = resolveNormalizedPoint(
bodyRecord.center_norm ?? bodyRecord.centerNorm ?? bodyRecord.center,
);
if (center) {
return center;
}
}
return null;
}
export function parseMocapPacket(packet: unknown): MocapInputCommand {
if (!packet || typeof packet !== 'object') {
return {actions: [], parseWarnings: ['packet 不是对象']};
}
const packetRecord = packet as {hands?: unknown};
const primaryHand = resolvePrimaryHand(packetRecord.hands);
const packetRecord = packet as Record<string, unknown>;
const hands = resolveHands(packetRecord);
const primaryHand = hands[0] ?? null;
const leftHand = hands.find((hand) => hand.side === 'left') ?? null;
const rightHand = hands.find((hand) => hand.side === 'right') ?? null;
const bodyCenter = resolveBodyCenter(packetRecord);
const actions = new Set<string>();
const parseWarnings: string[] = [];
if (!Array.isArray(packetRecord.hands)) {
addMocapActions(actions, packetRecord.actions);
addMocapActions(actions, packetRecord.action);
addMocapActions(actions, packetRecord.gesture);
addMocapActions(actions, packetRecord.gestures);
addMocapActions(actions, packetRecord.event);
addMocapActions(actions, packetRecord.name);
addMocapActions(actions, packetRecord.type);
if (!Array.isArray(packetRecord.hands) && hands.length === 0 && !bodyCenter) {
parseWarnings.push('缺少 hands 数组');
} else if (!primaryHand) {
} else if (!primaryHand && !bodyCenter) {
parseWarnings.push('hands 中没有可用坐标');
}
if (primaryHand?.state === 'grab') {
@@ -148,13 +385,22 @@ function parseMocapPacket(packet: unknown): MocapInputCommand {
if (primaryHand?.state === 'open_palm') {
actions.add('open_palm');
}
for (const hand of hands) {
if (hand.state !== 'unknown') {
actions.add(hand.state);
}
}
if (primaryHand && primaryHand.state === 'unknown') {
parseWarnings.push('手势 state 未识别');
}
return {
actions: Array.from(actions),
hands,
primaryHand,
leftHand,
rightHand,
bodyCenter,
parseWarnings,
};
}