Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
364
src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx
Normal file
364
src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx
Normal 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();
|
||||
});
|
||||
870
src/components/child-motion-demo/ChildMotionWarmupDemo.tsx
Normal file
870
src/components/child-motion-demo/ChildMotionWarmupDemo.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
274
src/components/child-motion-demo/childMotionWarmupModel.ts
Normal file
274
src/components/child-motion-demo/childMotionWarmupModel.ts
Normal 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;
|
||||
}
|
||||
@@ -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('未找到视觉小说作品。');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user