This commit is contained in:
2026-05-16 22:59:18 +08:00
69 changed files with 11457 additions and 2899 deletions

View File

@@ -48,6 +48,8 @@ const mocapMock = vi.hoisted(() => ({
rightShoulder?: { x: number; y: number } | null;
leftElbow?: { x: number; y: number } | null;
rightElbow?: { x: number; y: number } | null;
leftWrist?: { x: number; y: number } | null;
rightWrist?: { x: number; y: number } | null;
};
},
receivedAtMs: 1,
@@ -242,16 +244,18 @@ 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.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull();
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByText('请横屏体验')).toBeTruthy();
});
test('shows narration first before revealing the step cue', async () => {
test('shows the first subtitle before revealing the step cue', async () => {
vi.useFakeTimers();
render(<ChildMotionWarmupDemo />);
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro');
@@ -261,6 +265,25 @@ test('shows narration first before revealing the step cue', async () => {
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active');
});
test('switches the center step subtitle to the second line after a two second pause', async () => {
vi.useFakeTimers();
render(<ChildMotionWarmupDemo />);
expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull();
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
await advanceWarmupTime(1999);
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
await advanceWarmupTime(1);
expect(screen.queryByText('欢迎你,小朋友,见到你真开心')).toBeNull();
expect(screen.getByText('来圆圈这里和我打个招呼吧')).toBeTruthy();
});
test('re-entering within the same runtime session opens the start button', () => {
markChildMotionWarmupCompletedInRuntime();
@@ -299,6 +322,50 @@ test('developer keyboard input moves the avatar and triggers jump state', () =>
expect(avatar.className).toContain('child-motion-avatar--jumping');
});
test('developer pointer input renders baby object hand indicators in warmup', async () => {
vi.useFakeTimers();
render(<ChildMotionWarmupDemo />);
const stage = screen.getByTestId('child-motion-stage');
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
width: 1000,
height: 500,
top: 0,
right: 1000,
bottom: 500,
left: 0,
toJSON: () => ({}),
});
await revealCurrentStepCue();
const pointerDownEvent = new Event('pointerdown', {
bubbles: true,
cancelable: true,
});
Object.defineProperties(pointerDownEvent, {
button: { value: 0 },
buttons: { value: 1 },
clientX: { value: 250 },
clientY: { value: 150 },
pointerId: { value: 1 },
});
await act(async () => {
stage.dispatchEvent(pointerDownEvent);
});
const leftHand = screen.getByTestId('child-motion-left-hand-indicator');
expect(leftHand.className).toContain('baby-object-runtime__hand--left');
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-x: 25%',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-y: 30%',
);
vi.useRealTimers();
});
test('mocap body center dampens small jitter before moving the avatar', async () => {
setMocapBodyCenter(0.5);
const { rerender } = render(<ChildMotionWarmupDemo />);
@@ -325,6 +392,68 @@ test('mocap body center dampens small jitter before moving the avatar', async ()
expect(style).not.toContain('left: 34%');
});
test('mocap hand positions render with baby object hand indicators in body-side mapping', async () => {
setMocapCameraHandTrackPoint({ cameraSide: 'right', x: 0.24, y: 0.36 });
const { rerender } = render(<ChildMotionWarmupDemo />);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
const leftHand = await screen.findByTestId(
'child-motion-left-hand-indicator',
);
expect(leftHand.className).toContain('baby-object-runtime__hand--left');
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-x: 24%',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-y: 36%',
);
expect(screen.queryByTestId('child-motion-right-hand-indicator')).toBeNull();
});
test('mocap hand indicators prefer skeleton wrist nodes in warmup', async () => {
const cameraRightHand = {
x: 0.24,
y: 0.36,
state: 'unknown',
side: 'right',
wrist: { x: 0.27, y: 0.39 },
};
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
bodyJoints: {
leftShoulder: { x: 0.62, y: 0.48 },
leftElbow: { x: 0.7, y: 0.5 },
rightShoulder: { x: 0.38, y: 0.48 },
rightElbow: { x: 0.3, y: 0.5 },
rightWrist: { x: 0.64, y: 0.25 },
},
hands: [cameraRightHand],
primaryHand: cameraRightHand,
leftHand: null,
rightHand: cameraRightHand,
};
mocapMock.receivedAtMs += 1;
const { rerender } = render(<ChildMotionWarmupDemo />);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
const leftHand = await screen.findByTestId(
'child-motion-left-hand-indicator',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-x: 64%',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-y: 25%',
);
});
test('mocap body center keeps the warmup flow on the motion data source', async () => {
vi.useFakeTimers();
setMocapBodyCenter(0.5);
@@ -440,6 +569,33 @@ test('mocap greeting requires a real horizontal wave track', async () => {
vi.useRealTimers();
});
test('greeting completion goes to warmup intro without praise float text', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
await revealCurrentStepCue();
await completeGreetingByWaveTrack(rerender);
expect(screen.queryByText('真棒')).toBeNull();
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
expect(screen.queryByText('真棒')).toBeNull();
await act(async () => {
unmount();
});
vi.useRealTimers();
});
test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
@@ -477,6 +633,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
});
await revealCurrentStepCue();
expect(screen.getByTestId('child-motion-arm-swing-guide-left')).toBeTruthy();
expect(screen.queryByTestId('child-motion-arm-swing-guide-right')).toBeNull();
expect(
screen
.getByTestId('child-motion-arm-swing-guide-left')
.querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'),
).toBeTruthy();
await sendMocapCameraHandTrack(rerender, 'left', [
{ x: 0.78, y: 0.5 },
{ x: 0.86, y: 0.5 },
@@ -505,6 +669,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
});
await revealCurrentStepCue();
expect(screen.getByTestId('child-motion-arm-swing-guide-right')).toBeTruthy();
expect(screen.queryByTestId('child-motion-arm-swing-guide-left')).toBeNull();
expect(
screen
.getByTestId('child-motion-arm-swing-guide-right')
.querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'),
).toBeTruthy();
await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.2, y: 0.5 },
{ x: 0.16, y: 0.42 },
@@ -519,8 +691,9 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '热身完成' })).toBeTruthy();
});
expect(screen.queryByRole('heading', { name: '原地跳一下' })).toBeNull();
await advanceWarmupTime(720);
await act(async () => {
unmount();

View File

@@ -1,5 +1,5 @@
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
@@ -40,9 +40,9 @@ type WarmupStepPhase = 'intro' | 'active' | 'complete';
type WarmupMocapGestureIntent =
| 'greeting'
| 'left-hand'
| 'right-hand'
| 'jump';
| 'right-hand';
type WarmupBodyHandSide = 'left' | 'right';
type WarmupHandIndicators = Record<DragHand, ChildMotionPoint | null>;
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
draftId: 'child-motion-demo-baby-object-draft',
@@ -94,7 +94,9 @@ const WARMUP_GREETING_WAVE_DIRECTION_EPSILON = 0.008;
const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04;
const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08;
const WARMUP_STEP_INTRO_DELAY_MS = 1000;
const WARMUP_SUBTITLE_LINE_DELAY_MS = 2000;
const WARMUP_STEP_COMPLETE_PAUSE_MS = 820;
const WARMUP_TOTAL_STEPS = 11;
const AVATAR_MOCAP_DEAD_ZONE = 0.012;
const AVATAR_MOCAP_SMOOTHING = 0.28;
const AVATAR_MOCAP_MAX_STEP = 0.035;
@@ -128,6 +130,13 @@ function formatAvatarLeftPercent(value: number) {
return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`;
}
function createEmptyWarmupHandIndicators(): WarmupHandIndicators {
return {
left: null,
right: null,
};
}
function resolveMocapHandWithBodySide(
command: MocapInputCommand,
side: WarmupBodyHandSide,
@@ -136,6 +145,29 @@ function resolveMocapHandWithBodySide(
return side === 'left' ? command.rightHand : command.leftHand;
}
function resolveMocapSkeletonWristWithBodySide(
command: MocapInputCommand,
side: WarmupBodyHandSide,
) {
const joints = command.bodyJoints;
return side === 'left' ? joints?.rightWrist : joints?.leftWrist;
}
function resolveWarmupHandIndicatorsFromMocap(
command: MocapInputCommand,
): WarmupHandIndicators {
return {
left: mocapHandToWarmupIndicatorPoint(
resolveMocapHandWithBodySide(command, 'left'),
resolveMocapSkeletonWristWithBodySide(command, 'left'),
),
right: mocapHandToWarmupIndicatorPoint(
resolveMocapHandWithBodySide(command, 'right'),
resolveMocapSkeletonWristWithBodySide(command, 'right'),
),
};
}
function resolveMocapJointWithBodySide(
command: MocapInputCommand,
side: WarmupBodyHandSide,
@@ -175,6 +207,22 @@ function mocapHandToChildMotionPoint(
};
}
function mocapHandToWarmupIndicatorPoint(
hand: MocapHandInput | null | undefined,
skeletonWrist: MocapPointInput | null | undefined,
): ChildMotionPoint | null {
// 骨架手腕节点比手掌识别结果更稳定;热身指示器优先跟随骨架手腕。
const point = skeletonWrist ?? hand?.wrist ?? hand;
if (!point) {
return null;
}
return {
x: clampMotionUnit(point.x),
y: clampMotionUnit(point.y),
};
}
function appendWarmupMocapPoint(
points: ChildMotionPoint[],
point: ChildMotionPoint,
@@ -218,13 +266,6 @@ function getMotionSourceText(state: MotionSourceState) {
return '正在连接动作数据';
}
function hasWarmupMocapAction(
command: MocapInputCommand,
expectedActions: string[],
) {
return command.actions.some((action) => expectedActions.includes(action));
}
function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) {
let previousDirection = 0;
let directionChanges = 0;
@@ -403,7 +444,6 @@ function resolveDampedAvatarX(current: number, target: number) {
function resolveWarmupMocapGestureIntent(
stepId: ChildMotionWarmupStepId,
command: MocapInputCommand,
paths: {
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
@@ -434,19 +474,6 @@ function resolveWarmupMocapGestureIntent(
return 'right-hand';
}
if (
stepId === 'jump_once' &&
hasWarmupMocapAction(command, [
'jump',
'jump_once',
'hop',
'跳跃',
'原地跳',
])
) {
return 'jump';
}
return null;
}
@@ -475,7 +502,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
'return_center_2',
'wave_left_hand',
'wave_right_hand',
'jump_once',
'warmup_finish',
'level_select',
];
@@ -546,11 +572,15 @@ function ChildMotionGestureGuide({
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">
<div
className={`child-motion-gesture-guide ${
isGreeting ? 'child-motion-gesture-guide--greeting' : ''
}`}
aria-hidden="true"
>
{isGreeting ? (
<span className="child-motion-gesture-guide__wave-cat">
<span className="child-motion-gesture-guide__wave-cat-body" />
@@ -561,8 +591,14 @@ function ChildMotionGestureGuide({
{isLeft || isRight ? (
<>
<span
className={`child-motion-gesture-guide__arm child-motion-gesture-guide__arm--${isLeft ? 'left' : 'right'}`}
/>
className={`child-motion-gesture-guide__arm-swing child-motion-gesture-guide__arm-swing--${isLeft ? 'left' : 'right'}`}
data-testid={`child-motion-arm-swing-guide-${isLeft ? 'left' : 'right'}`}
>
<span className="child-motion-gesture-guide__arm-swing-track" />
<span className="child-motion-gesture-guide__arm-swing-paw">
<span className="child-motion-gesture-guide__arm-swing-paw-asset" />
</span>
</span>
{activePath.map((point, index) => (
<span
key={`${isLeft ? 'left' : 'right'}-${index}`}
@@ -576,9 +612,40 @@ function ChildMotionGestureGuide({
))}
</>
) : null}
{isJump ? (
<span className="child-motion-gesture-guide__jump"></span>
) : null}
</div>
);
}
function ChildMotionHandIndicators({
hands,
}: {
hands: WarmupHandIndicators;
}) {
return (
<div
className="baby-object-runtime__hands child-motion-hand-indicators"
aria-hidden="true"
>
{(['left', 'right'] as const).map((hand) => {
const point = hands[hand];
if (!point) {
return null;
}
return (
<div
key={hand}
className={`baby-object-runtime__hand baby-object-runtime__hand--${hand} child-motion-hand-indicator`}
data-testid={`child-motion-${hand}-hand-indicator`}
style={
{
'--baby-object-hand-x': `${point.x * 100}%`,
'--baby-object-hand-y': `${point.y * 100}%`,
} as CSSProperties
}
/>
);
})}
</div>
);
}
@@ -606,10 +673,6 @@ function ChildMotionCalibrationPanel({
<span></span>
<strong>{calibration.rightHandPath.length}</strong>
</div>
<div>
<span></span>
<strong>{formatPercent(calibration.jumpSpace)}</strong>
</div>
</div>
);
}
@@ -630,11 +693,15 @@ export function ChildMotionWarmupDemo() {
const [nowMs, setNowMs] = useState(() => Date.now());
const [leftHandPath, setLeftHandPath] = useState<ChildMotionPoint[]>([]);
const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]);
const [handIndicators, setHandIndicators] = useState(
createEmptyWarmupHandIndicators,
);
const [activeHand, setActiveHand] = useState<DragHand | null>(null);
const [isJumping, setIsJumping] = useState(false);
const [justCompletedText, setJustCompletedText] = useState<string | null>(
null,
);
const [subtitleLineIndex, setSubtitleLineIndex] = useState(0);
const [cameraAccessState, setCameraAccessState] = useState<CameraAccessState>(
() =>
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
@@ -657,7 +724,9 @@ export function ChildMotionWarmupDemo() {
step.kind === 'finish',
});
const stepIndex = getStepIndex(stepId);
const progressPercent = Math.round((stepIndex / 12) * 100);
const progressPercent = Math.round(
(stepIndex / (WARMUP_TOTAL_STEPS - 1)) * 100,
);
const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs);
const isStepActive = stepPhase === 'active';
const shouldShowStepCues = stepPhase !== 'intro';
@@ -681,12 +750,14 @@ export function ChildMotionWarmupDemo() {
);
const nextStep = resolveNextChildMotionWarmupStep(stepId);
if (stepId === 'jump_once') {
if (stepId === 'warmup_finish') {
markChildMotionWarmupCompletedInRuntime();
}
const completionText =
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒';
stepId === 'wave_greeting' || stepId === 'warmup_finish'
? null
: '真棒';
setJustCompletedText(completionText);
setStepPhase('complete');
setHoldStartedAt(null);
@@ -803,6 +874,7 @@ export function ChildMotionWarmupDemo() {
setHoldStartedAt(null);
setLeftHandPath([]);
setRightHandPath([]);
setSubtitleLineIndex(0);
handledMocapPacketKeyRef.current = null;
if (step.kind === 'levelSelect') {
@@ -819,6 +891,20 @@ export function ChildMotionWarmupDemo() {
return () => window.clearTimeout(timeout);
}, [step.kind, stepId]);
useEffect(() => {
if (step.spokenLines.length <= 1) {
return;
}
setSubtitleLineIndex(0);
const timeout = window.setTimeout(() => {
setSubtitleLineIndex((current) =>
Math.min(current + 1, step.spokenLines.length - 1),
);
}, WARMUP_SUBTITLE_LINE_DELAY_MS);
return () => window.clearTimeout(timeout);
}, [step.spokenLines, stepId]);
useEffect(() => {
if (step.kind !== 'position' || !isStepActive) {
return;
@@ -925,7 +1011,7 @@ export function ChildMotionWarmupDemo() {
setRightHandPath(nextRightHandPath);
}
const intent = resolveWarmupMocapGestureIntent(stepId, command, {
const intent = resolveWarmupMocapGestureIntent(stepId, {
leftHandPath: nextLeftHandPath,
rightHandPath: nextRightHandPath,
primaryHandPath: nextPrimaryHandPath,
@@ -934,13 +1020,6 @@ export function ChildMotionWarmupDemo() {
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].filter(
(point): point is ChildMotionPoint => Boolean(point),
@@ -965,6 +1044,21 @@ export function ChildMotionWarmupDemo() {
stepId,
]);
useEffect(() => {
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
return;
}
setHandIndicators(
resolveWarmupHandIndicatorsFromMocap(mocapInput.latestCommand),
);
}, [
mocapInput.latestCommand,
mocapInput.rawPacketPreview?.receivedAtMs,
mocapInput.rawPacketPreview?.text,
stepPhase,
]);
useEffect(() => {
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
return;
@@ -1008,15 +1102,12 @@ export function ChildMotionWarmupDemo() {
event.preventDefault();
setIsJumping(true);
window.setTimeout(() => setIsJumping(false), 360);
if (stepId === 'jump_once' && isStepActive) {
completeStep({ type: 'jump', jumpSpace: 0.14 });
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [completeStep, isStepActive, stepId, stepPhase]);
}, [stepPhase]);
useEffect(() => {
const handleKeyUp = (event: KeyboardEvent) => {
@@ -1040,20 +1131,29 @@ export function ChildMotionWarmupDemo() {
return;
}
if (event.button !== 0 && event.button !== 2) {
if (
event.button !== 0 &&
event.button !== 2 &&
event.buttons !== 1 &&
event.buttons !== 2
) {
return;
}
event.preventDefault();
const nextHand: DragHand = event.button === 2 ? 'right' : 'left';
const nextHand: DragHand =
event.button === 2 || event.buttons === 2 ? 'right' : 'left';
setActiveHand(nextHand);
const point = normalizePointerPoint(event, event.currentTarget);
setHandIndicators((current) => ({ ...current, [nextHand]: point }));
if (nextHand === 'left') {
setLeftHandPath([point]);
} else {
setRightHandPath([point]);
}
event.currentTarget.setPointerCapture(event.pointerId);
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId);
}
};
const handleStagePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
@@ -1062,6 +1162,7 @@ export function ChildMotionWarmupDemo() {
}
const point = normalizePointerPoint(event, event.currentTarget);
setHandIndicators((current) => ({ ...current, [activeHand]: point }));
const appendPoint = (points: ChildMotionPoint[]) =>
[...points, point].slice(-16);
if (activeHand === 'left') {
@@ -1076,7 +1177,10 @@ export function ChildMotionWarmupDemo() {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
if (
typeof event.currentTarget.hasPointerCapture === 'function' &&
event.currentTarget.hasPointerCapture(event.pointerId)
) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const hand = activeHand;
@@ -1110,10 +1214,8 @@ export function ChildMotionWarmupDemo() {
setIsBabyObjectRuntimeOpen(true);
};
const lineText = useMemo(
() => step.spokenLines.join(''),
[step.spokenLines],
);
const shouldHideStepTitle = stepId === 'center_arrive';
const subtitleText = step.spokenLines[subtitleLineIndex] ?? step.spokenLines[0];
if (isBabyObjectRuntimeOpen) {
return (
@@ -1170,6 +1272,7 @@ export function ChildMotionWarmupDemo() {
rightHandPath={rightHandPath}
/>
) : null}
<ChildMotionHandIndicators hands={handIndicators} />
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
{justCompletedText ? (
<div className="child-motion-floating-reward">
@@ -1178,10 +1281,16 @@ export function ChildMotionWarmupDemo() {
) : 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>
<span className="child-motion-step-count">{`${Math.min(stepIndex + 1, WARMUP_TOTAL_STEPS)}/${WARMUP_TOTAL_STEPS}`}</span>
<div
className={
shouldHideStepTitle
? 'child-motion-hud__copy child-motion-hud__copy--subtitle-only'
: 'child-motion-hud__copy'
}
>
{shouldHideStepTitle ? null : <h1>{step.title}</h1>}
<p>{subtitleText}</p>
</div>
<span className="child-motion-progress">{progressPercent}%</span>
</div>

View File

@@ -22,7 +22,6 @@ describe('childMotionWarmupModel', () => {
'return_center_2',
'wave_left_hand',
'wave_right_hand',
'jump_once',
'warmup_finish',
'level_select',
]);
@@ -76,19 +75,11 @@ describe('childMotionWarmupModel', () => {
],
},
);
const completed = applyChildMotionWarmupCompletion(
'jump_once',
withRightHand,
{
type: 'jump',
jumpSpace: 0.14,
},
);
expect(completed.leftBoundary).toBeCloseTo(0.16);
expect(completed.rightBoundary).toBeCloseTo(0.16);
expect(completed.leftHandPath).toHaveLength(2);
expect(completed.leftHandSpace).toEqual({
expect(withRightHand.leftBoundary).toBeCloseTo(0.16);
expect(withRightHand.rightBoundary).toBeCloseTo(0.16);
expect(withRightHand.leftHandPath).toHaveLength(2);
expect(withRightHand.leftHandSpace).toEqual({
minX: 0.3,
maxX: 0.34,
minY: 0.32,
@@ -97,7 +88,6 @@ describe('childMotionWarmupModel', () => {
maxAngleDeg: 44,
maxReach: 0.28,
});
expect(completed.rightHandSpace?.maxReach).toBe(0.31);
expect(completed.jumpSpace).toBe(0.14);
expect(withRightHand.rightHandSpace?.maxReach).toBe(0.31);
});
});

View File

@@ -8,7 +8,6 @@ export type ChildMotionWarmupStepId =
| 'return_center_2'
| 'wave_left_hand'
| 'wave_right_hand'
| 'jump_once'
| 'warmup_finish'
| 'level_select';
@@ -55,7 +54,6 @@ export type ChildMotionWarmupCalibration = {
rightHandPath: ChildMotionPoint[];
leftHandSpace: ChildMotionHandSpace | null;
rightHandSpace: ChildMotionHandSpace | null;
jumpSpace: number | null;
};
export type ChildMotionWarmupCompletion =
@@ -71,10 +69,6 @@ export type ChildMotionWarmupCompletion =
type: 'right-hand';
path: ChildMotionPoint[];
}
| {
type: 'jump';
jumpSpace: number;
}
| {
type: 'narration';
};
@@ -92,14 +86,14 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
id: 'center_arrive',
kind: 'position',
title: '来到圆圈这里',
spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'],
spokenLines: ['欢迎你,小朋友,见到你真开心', '圆圈这里和我打个招呼吧'],
target: 'center',
},
{
id: 'wave_greeting',
kind: 'gesture',
title: '打个招呼',
spokenLines: ['请你来到圆圈这里和我打个招呼吧'],
spokenLines: ['圆圈这里和我打个招呼吧'],
},
{
id: 'warmup_intro',
@@ -147,12 +141,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
title: '挥动右手',
spokenLines: ['挥动右手'],
},
{
id: 'jump_once',
kind: 'gesture',
title: '原地跳一下',
spokenLines: ['原地跳一下'],
},
{
id: 'warmup_finish',
kind: 'finish',
@@ -224,7 +212,6 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
rightHandPath: [],
leftHandSpace: null,
rightHandSpace: null,
jumpSpace: null,
};
}
@@ -290,13 +277,6 @@ export function applyChildMotionWarmupCompletion(
};
}
if (stepId === 'jump_once' && completion.type === 'jump') {
return {
...calibration,
jumpSpace: completion.jumpSpace,
};
}
return calibration;
}

View File

@@ -94,14 +94,6 @@ function createGeneratedDraft() {
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background',
},
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui',
},
{
assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box',
@@ -118,14 +110,6 @@ function createGeneratedDraft() {
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket',
},
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke',
},
],
},
});

View File

@@ -38,10 +38,8 @@ function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
const REQUIRED_VISUAL_ASSET_KINDS = [
'background',
'ui-frame',
'gift-box',
'basket',
'smoke-puff',
] as const;
export function BabyObjectMatchResultView({

View File

@@ -157,6 +157,7 @@ function dispatchPointerEvent(
options: {
pointerId: number;
button?: number;
buttons?: number;
clientX: number;
clientY: number;
},
@@ -164,9 +165,10 @@ function dispatchPointerEvent(
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
return event;
}
function dragHand(stage: HTMLElement, button: 0 | 2) {
function setStageRect(stage: HTMLElement) {
Object.defineProperty(stage, 'getBoundingClientRect', {
configurable: true,
value: () => ({
@@ -181,34 +183,67 @@ function dragHand(stage: HTMLElement, button: 0 | 2) {
toJSON: () => ({}),
}),
});
}
function dragItemWithHand(stage: HTMLElement, button: 0 | 2, targetX: number) {
setStageRect(stage);
act(() => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: button + 1,
button,
clientX: 20,
clientY: 140,
buttons: button === 2 ? 2 : 1,
clientX: 160,
clientY: 89,
});
});
act(() => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: button + 1,
button,
clientX: 120,
clientY: 140,
buttons: button === 2 ? 2 : 1,
clientX: targetX,
clientY: 190,
});
});
act(() => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: button + 1,
button,
clientX: 120,
clientY: 140,
buttons: 0,
clientX: targetX,
clientY: 190,
});
});
}
async function advanceRoundIntro() {
await advanceInitialTargetPreview();
await advanceGiftIntro();
}
async function advanceInitialTargetPreview() {
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
async function advanceGiftIntro() {
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
@@ -236,6 +271,7 @@ test('shows the first gift item after gift and item animations', async () => {
);
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
@@ -246,6 +282,56 @@ test('shows the first gift item after gift and item animations', async () => {
vi.useRealTimers();
});
test('previews both target items before the first gift box round', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0])}
/>,
);
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
expect(
within(screen.getByTestId('baby-object-intro-item')).getByAltText('苹果'),
).toBeTruthy();
expect(screen.queryByLabelText('礼物盒')).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(screen.getByTestId('baby-object-intro-item').className).toContain(
'baby-object-runtime__intro-item--flying',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
expect(
within(screen.getByTestId('baby-object-intro-item')).getByAltText('香蕉'),
).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.queryByTestId('baby-object-intro-item')).toBeNull();
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
vi.useRealTimers();
});
test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => {
vi.useFakeTimers();
const { container } = render(
@@ -270,6 +356,8 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu
expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain(
'smoke',
);
await advanceInitialTargetPreview();
expect(screen.getByAltText('礼物盒')).toBeTruthy();
expect(
container.querySelector('.baby-object-runtime__basket-shell-image'),
@@ -283,6 +371,80 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu
vi.useRealTimers();
});
test('uses default runtime hand indicators instead of per-draft generated hand assets', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const draftWithLegacyHandAssets: BabyObjectMatchDraft = {
...createVisualPackageDraft(),
visualPackage: {
...createVisualPackageDraft().visualPackage!,
assets: [
...createVisualPackageDraft().visualPackage!.assets,
{
assetId: 'legacy-left-hand',
assetKind: 'left-hand',
imageSrc: 'data:image/png;base64,legacy-left-hand',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '旧左手',
},
{
assetId: 'legacy-right-hand',
assetKind: 'right-hand',
imageSrc: 'data:image/png;base64,legacy-right-hand',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '旧右手',
},
],
},
};
const { container, rerender } = render(
<BabyObjectMatchRuntimeShell
draft={draftWithLegacyHandAssets}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={draftWithLegacyHandAssets}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'show-default-hand',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
const stage = container.querySelector('.baby-object-runtime__stage');
expect(stage).toBeInstanceOf(HTMLElement);
expect(
(stage as HTMLElement).style.getPropertyValue(
'--baby-object-left-hand-image',
),
).toBe('');
expect(
(stage as HTMLElement).style.getPropertyValue(
'--baby-object-right-hand-image',
),
).toBe('');
vi.useRealTimers();
});
test('removes the gift box after smoke releases the current item', async () => {
vi.useFakeTimers();
render(
@@ -292,6 +454,8 @@ test('removes the gift box after smoke releases the current item', async () => {
/>,
);
await advanceInitialTargetPreview();
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
await act(async () => {
@@ -335,10 +499,16 @@ test('keeps left and right baskets fixed while only the gift item is random', as
).toBeTruthy();
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
expect(
within(screen.getByLabelText('左侧篮子 苹果')).getByText('苹果'),
).toBeTruthy();
expect(
within(screen.getByLabelText('右侧篮子 香蕉')).getByText('香蕉'),
).toBeTruthy();
vi.useRealTimers();
});
test('mocap camera-right hand movement sends the player left hand item into the left basket', async () => {
test('mocap hand must touch the current item before dropping it into a basket', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
@@ -358,13 +528,211 @@ test('mocap camera-right hand movement sends the player left hand item into the
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
hands: [{ x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
rightHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'camera-right-horizontal-1',
text: 'drop-without-grab',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'touch-current-item',
receivedAtMs: 2,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
expect(screen.queryByText('真棒')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.78, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'drop-left-basket',
receivedAtMs: 3,
},
})}
/>,
);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers();
});
test('mocap hand uses skeleton wrist before hand landmark points in baby object runtime', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [
{
x: 0.5,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
],
primaryHand: {
x: 0.5,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
leftHand: null,
rightHand: {
x: 0.5,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
bodyJoints: {
rightWrist: { x: 0.64, y: 0.37 },
},
},
rawPacketPreview: {
text: 'hand-points-over-item-skeleton-wrist-away',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.queryByTestId('baby-object-left-hand')).toBeTruthy();
expect(screen.queryByText('真棒')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [
{
x: 0.62,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.5, y: 0.37 },
},
],
primaryHand: {
x: 0.62,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.5, y: 0.37 },
},
leftHand: null,
rightHand: {
x: 0.62,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
bodyJoints: {
rightWrist: { x: 0.5, y: 0.37 },
},
},
rawPacketPreview: {
text: 'skeleton-wrist-over-item-hand-points-away',
receivedAtMs: 2,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand').className).toContain(
'baby-object-runtime__hand--holding-left-corner',
);
vi.useRealTimers();
});
test('basket judgement accepts the enlarged basket edge while keeping center gap safe', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'touch-current-item-before-narrow-zone',
receivedAtMs: 1,
},
})}
@@ -378,40 +746,22 @@ test('mocap camera-right hand movement sends the player left hand item into the
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.24, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
hands: [{ x: 0.37, y: 0.82, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
rightHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'camera-right-horizontal-2',
text: 'outside-enlarged-left-hitbox-center-gap',
receivedAtMs: 2,
},
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'camera-right-horizontal-3',
receivedAtMs: 3,
},
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
rerender(
<BabyObjectMatchRuntimeShell
@@ -420,14 +770,14 @@ test('mocap camera-right hand movement sends the player left hand item into the
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.31, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
hands: [{ x: 0.36, y: 0.62, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
rightHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'camera-right-horizontal-4',
receivedAtMs: 4,
text: 'enlarged-left-hitbox-edge',
receivedAtMs: 3,
},
})}
/>,
@@ -438,7 +788,7 @@ test('mocap camera-right hand movement sends the player left hand item into the
vi.useRealTimers();
});
test('mocap camera-left hand movement sends the player right hand item into the right basket', async () => {
test('either mocap hand can drag the current item into either basket', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
@@ -458,12 +808,12 @@ test('mocap camera-left hand movement sends the player right hand item into the
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 1 },
rawPacketPreview: { text: 'right-hand-touch-item', receivedAtMs: 1 },
})}
/>,
);
@@ -475,48 +825,12 @@ test('mocap camera-left hand movement sends the player right hand item into the
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
hands: [{ x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
leftHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 2 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 3 },
})}
/>,
);
expect(screen.queryByText('再想一想吧')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.73, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 4 },
rawPacketPreview: { text: 'right-hand-drop-right', receivedAtMs: 2 },
})}
/>,
);
@@ -526,7 +840,84 @@ test('mocap camera-left hand movement sends the player right hand item into the
vi.useRealTimers();
});
test('mocap action names do not select a basket without horizontal hand movement', async () => {
test('holding hand indicator anchors to the lower item corner by hand side', async () => {
vi.useFakeTimers();
const leftHandRandom = createRandomSequence([0, 0]);
const leftHandRuntime = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={leftHandRandom}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
leftHandRuntime.rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={leftHandRandom}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'left-hand-holding-corner',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand').className).toContain(
'baby-object-runtime__hand--holding-left-corner',
);
leftHandRuntime.unmount();
const rightHandRandom = createRandomSequence([0, 0]);
const rightHandRuntime = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={rightHandRandom}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rightHandRuntime.rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={rightHandRandom}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: {
text: 'right-hand-holding-corner',
receivedAtMs: 2,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-right-hand').className).toContain(
'baby-object-runtime__hand--holding-right-corner',
);
rightHandRuntime.unmount();
vi.useRealTimers();
});
test('mocap action names do not select a basket without touching and dragging item', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
@@ -564,7 +955,7 @@ test('mocap action names do not select a basket without horizontal hand movement
vi.useRealTimers();
});
test('mocap unknown hand horizontal movement does not select a basket', async () => {
test('mocap unknown hand movement does not grab or select a basket', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
@@ -578,7 +969,8 @@ test('mocap unknown hand horizontal movement does not select a basket', async ()
await advanceRoundIntro();
for (let index = 0; index < 4; index += 1) {
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22;
const x = [0.5, 0.5, 0.22, 0.22][index] ?? 0.5;
const y = [0.37, 0.78, 0.78, 0.37][index] ?? 0.37;
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
@@ -586,13 +978,13 @@ test('mocap unknown hand horizontal movement does not select a basket', async ()
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x, y: 0.45, state: 'open_palm', side: 'unknown' }],
primaryHand: { x, y: 0.45, state: 'open_palm', side: 'unknown' },
hands: [{ x, y, state: 'open_palm', side: 'unknown' }],
primaryHand: { x, y, state: 'open_palm', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: {
text: `unknown-horizontal-${index + 1}`,
text: `unknown-drag-${index + 1}`,
receivedAtMs: index + 1,
},
})}
@@ -608,7 +1000,7 @@ test('mocap unknown hand horizontal movement does not select a basket', async ()
vi.useRealTimers();
});
test('left hand horizontal drag sends a correct item into the left basket', async () => {
test('left mouse hand drags a correct item into the left basket', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
@@ -622,7 +1014,7 @@ test('left hand horizontal drag sends a correct item into the left basket', asyn
}
await advanceRoundIntro();
dragHand(stage, 0);
dragItemWithHand(stage, 0, 70);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
@@ -657,19 +1049,55 @@ test('ignores drag input until the item animation finishes', async () => {
throw new Error('Missing baby object runtime stage');
}
dragHand(stage, 0);
dragItemWithHand(stage, 0, 70);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
await advanceRoundIntro();
dragHand(stage, 0);
dragItemWithHand(stage, 0, 70);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers();
});
test('keeps the back button outside active gameplay pointer input', async () => {
vi.useFakeTimers();
const onBack = vi.fn();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
onBack={onBack}
/>,
);
await advanceRoundIntro();
const backButton = screen.getByRole('button', { name: '返回' });
let pointerDownEvent!: Event;
act(() => {
pointerDownEvent = dispatchPointerEvent(backButton, 'pointerdown', {
pointerId: 9,
button: 0,
buttons: 1,
clientX: 16,
clientY: 16,
});
});
expect(pointerDownEvent.defaultPrevented).toBe(false);
expect(screen.queryByTestId('baby-object-left-hand')).toBeNull();
act(() => {
backButton.click();
});
expect(onBack).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
test('correct placement automatically shows the next gift item', async () => {
vi.useFakeTimers();
const { container } = render(
@@ -691,7 +1119,7 @@ test('correct placement automatically shows the next gift item', async () => {
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
dragHand(stage, 0);
dragItemWithHand(stage, 0, 70);
expect(screen.getByText('真棒')).toBeTruthy();
@@ -722,7 +1150,7 @@ test('wrong basket keeps the item active after feedback', async () => {
}
await advanceRoundIntro();
dragHand(stage, 2);
dragItemWithHand(stage, 2, 250);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
@@ -752,7 +1180,7 @@ test('twenty correct placements completes the level', async () => {
for (let index = 0; index < 20; index += 1) {
await advanceRoundIntro();
dragHand(stage, 0);
dragItemWithHand(stage, 0, 70);
await advanceFeedback();
}

View File

@@ -33,8 +33,15 @@ const BABY_OBJECT_MATCH_GIFT_APPEAR_DURATION_MS = 620;
const BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS = 640;
const BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS = 620;
const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 1180;
const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05;
const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16;
const BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS = 2000;
const BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS = 720;
const BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS = 1000;
const BABY_OBJECT_MATCH_ITEM_CENTER: RuntimeHandPoint = { x: 0.5, y: 0.37 };
const BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS = 0.14;
// 篮子仍只认主体附近,但在上一版核心区基础上扩大约 50%,避免贴近篮子后仍难以命中。
const BABY_OBJECT_MATCH_BASKET_DROP_Y = 0.62;
const BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X = 0.36;
const BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X = 0.64;
type BabyObjectMatchRuntimeShellProps = {
draft: BabyObjectMatchDraft;
@@ -48,6 +55,12 @@ type BabyObjectMatchRuntimeShellProps = {
type BasketSide = 'left' | 'right';
type RuntimePhase =
| 'intro-left-showing'
| 'intro-left-flying'
| 'intro-left-ready'
| 'intro-right-showing'
| 'intro-right-flying'
| 'intro-right-ready'
| 'gift-entering'
| 'gift-opening'
| 'item-appearing'
@@ -61,10 +74,10 @@ type RuntimeRound = {
baskets: Record<BasketSide, BabyObjectMatchItemAsset>;
};
type DragState = {
type RuntimeIntroShowcase = {
side: BasketSide;
startX: number;
lastX: number;
item: BabyObjectMatchItemAsset;
isFlying: boolean;
};
type RuntimeHandPoint = {
@@ -72,9 +85,12 @@ type RuntimeHandPoint = {
y: number;
};
type RuntimeMocapHandPaths = {
left: RuntimeHandPoint[];
right: RuntimeHandPoint[];
type RuntimeHandRole = 'left' | 'right';
type RuntimeHands = Record<RuntimeHandRole, RuntimeHandPoint | null>;
type HeldItemState = {
hand: RuntimeHandRole;
};
type BabyObjectMatchRandom = () => number;
@@ -113,74 +129,72 @@ function buildRuntimeRound(
};
}
function isHorizontalDrag(dragState: DragState) {
return (
Math.abs(dragState.lastX - dragState.startX) >=
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
);
}
function mocapHandToRuntimePoint(
hand: MocapHandInput | null | undefined,
skeletonWrist: RuntimeHandPoint | null | undefined,
): RuntimeHandPoint | null {
if (skeletonWrist) {
return clampRuntimePoint(skeletonWrist);
}
if (!hand) {
return null;
}
return { x: hand.x, y: hand.y };
// 骨架 wrist 缺失时再回退到手部 landmarks 的 wrist最后才使用手部派生点。
const point = hand.wrist ?? hand;
return clampRuntimePoint({ x: point.x, y: point.y });
}
function appendRuntimeHandPoint(
points: RuntimeHandPoint[],
point: RuntimeHandPoint,
) {
return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT);
function clampRuntimePoint(point: RuntimeHandPoint): RuntimeHandPoint {
return {
x: Math.max(0, Math.min(1, point.x)),
y: Math.max(0, Math.min(1, point.y)),
};
}
function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) {
if (points.length < 3) {
return false;
}
function isRuntimePointTouchingItem(point: RuntimeHandPoint) {
const dx = point.x - BABY_OBJECT_MATCH_ITEM_CENTER.x;
const dy = point.y - BABY_OBJECT_MATCH_ITEM_CENTER.y;
return Math.sqrt(dx * dx + dy * dy) <= BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS;
}
const xValues = points.map((point) => point.x);
function isRuntimeControlPointerTarget(target: EventTarget | null) {
return (
Math.max(...xValues) - Math.min(...xValues) >=
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
target instanceof Element &&
target.closest(
'button, a, input, select, textarea, [role="button"], [data-baby-object-runtime-control="true"]',
) !== null
);
}
function resolveMocapHandPaths(
command: MocapInputCommand,
currentPaths: RuntimeMocapHandPaths,
) {
// 本地 mocap 当前按摄像头视角输出 handedness这里换回用户身体视角再选篮。
const leftPoint = mocapHandToRuntimePoint(command.rightHand);
const rightPoint = mocapHandToRuntimePoint(command.leftHand);
return {
left: leftPoint
? appendRuntimeHandPoint(currentPaths.left, leftPoint)
: currentPaths.left,
right: rightPoint
? appendRuntimeHandPoint(currentPaths.right, rightPoint)
: currentPaths.right,
} satisfies RuntimeMocapHandPaths;
}
function resolveMocapHorizontalMoveSide(
paths: RuntimeMocapHandPaths,
): BasketSide | null {
if (hasRuntimeHorizontalMovePath(paths.left)) {
function resolveBasketSideForPoint(point: RuntimeHandPoint): BasketSide | null {
if (point.y < BABY_OBJECT_MATCH_BASKET_DROP_Y) {
return null;
}
if (point.x <= BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X) {
return 'left';
}
if (hasRuntimeHorizontalMovePath(paths.right)) {
if (point.x >= BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X) {
return 'right';
}
return null;
}
function resolveMocapRuntimeHands(command: MocapInputCommand): RuntimeHands {
// 本地 mocap 当前按摄像头视角输出 handedness这里换回用户身体视角用于显示双手。
return {
left: mocapHandToRuntimePoint(
command.rightHand,
command.bodyJoints?.rightWrist,
),
right: mocapHandToRuntimePoint(
command.leftHand,
command.bodyJoints?.leftWrist,
),
};
}
function buildMocapPacketKey(
command: MocapInputCommand,
rawPacketPreview: UseMocapInputResult['rawPacketPreview'],
@@ -204,6 +218,44 @@ function buildCssImageValue(src: string) {
return `url("${src.replace(/"/gu, '\\"')}")`;
}
function resolveIntroShowcase(
phase: RuntimePhase,
draft: BabyObjectMatchDraft,
): RuntimeIntroShowcase | null {
if (phase === 'intro-left-showing' || phase === 'intro-left-flying') {
const item = draft.itemAssets[0];
return item
? { side: 'left', item, isFlying: phase === 'intro-left-flying' }
: null;
}
if (phase === 'intro-right-showing' || phase === 'intro-right-flying') {
const item = draft.itemAssets[1];
return item
? { side: 'right', item, isFlying: phase === 'intro-right-flying' }
: null;
}
return null;
}
function isBasketOptionReadyInIntro(side: BasketSide, phase: RuntimePhase) {
if (!phase.startsWith('intro-')) {
return true;
}
if (side === 'left') {
return (
phase === 'intro-left-ready' ||
phase === 'intro-right-showing' ||
phase === 'intro-right-flying' ||
phase === 'intro-right-ready'
);
}
return phase === 'intro-right-ready';
}
export function BabyObjectMatchRuntimeShell({
draft,
embedded = false,
@@ -218,20 +270,20 @@ export function BabyObjectMatchRuntimeShell({
);
const introTimerRef = useRef<number | null>(null);
const feedbackTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const handledMocapPacketKeyRef = useRef<string | null>(null);
const latestMocapPacketKeyRef = useRef<string | null>(null);
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
left: [],
right: [],
});
const [phase, setPhase] = useState<RuntimePhase>('gift-entering');
const [phase, setPhase] = useState<RuntimePhase>('intro-left-showing');
const [successCount, setSuccessCount] = useState(0);
const [round, setRound] = useState<RuntimeRound | null>(() =>
buildRuntimeRound(draft, randomRef.current),
);
const [feedbackText, setFeedbackText] = useState<string | null>(null);
const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null);
const [runtimeHands, setRuntimeHands] = useState<RuntimeHands>({
left: null,
right: null,
});
const [heldItem, setHeldItem] = useState<HeldItemState | null>(null);
const liveMocapInput = useMocapInput({
enabled: enableMocapInput && !mocapInput,
});
@@ -276,6 +328,8 @@ export function BabyObjectMatchRuntimeShell({
const isComplete = phase === 'complete';
const currentItem = round?.item ?? null;
const isJudgementOpen = phase === 'active';
const introShowcase = resolveIntroShowcase(phase, draft);
const heldPoint = heldItem ? runtimeHands[heldItem.hand] : null;
const shouldShowCurrentItem =
currentItem &&
(phase === 'item-appearing' ||
@@ -314,14 +368,61 @@ export function BabyObjectMatchRuntimeShell({
}, []);
const resetInputPaths = useCallback(() => {
dragStateRef.current = null;
handledMocapPacketKeyRef.current = null;
mocapHandPathsRef.current = { left: [], right: [] };
setHeldItem(null);
}, []);
useEffect(() => {
clearIntroTimer();
if (phase === 'intro-left-showing') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-left-flying');
}, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-left-flying') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-left-ready');
}, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-left-ready') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-right-showing');
}, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS);
return clearIntroTimer;
}
if (phase === 'intro-right-showing') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-right-flying');
}, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-right-flying') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-right-ready');
}, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-right-ready') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('gift-entering');
}, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS);
return clearIntroTimer;
}
if (phase === 'gift-entering') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
@@ -359,7 +460,7 @@ export function BabyObjectMatchRuntimeShell({
setRound(buildRuntimeRound(draft, randomRef.current));
setFeedbackText(null);
setLastTargetSide(null);
setPhase('gift-entering');
setPhase('intro-left-showing');
}, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]);
const finishFeedback = useCallback(
@@ -440,20 +541,38 @@ export function BabyObjectMatchRuntimeShell({
}
handledMocapPacketKeyRef.current = packetKey;
const nextHands = resolveMocapRuntimeHands(command);
setRuntimeHands(nextHands);
if (!isJudgementOpen) {
resetInputPaths();
return;
}
const nextPaths = resolveMocapHandPaths(command, mocapHandPathsRef.current);
mocapHandPathsRef.current = nextPaths;
const targetSide = resolveMocapHorizontalMoveSide(nextPaths);
if (targetSide) {
const currentHeldItem = heldItem;
if (currentHeldItem) {
const heldHandPoint = nextHands[currentHeldItem.hand];
const targetSide = heldHandPoint
? resolveBasketSideForPoint(heldHandPoint)
: null;
if (!targetSide) {
return;
}
sendItemToBasket(targetSide);
resetInputPaths();
return;
}
for (const hand of ['left', 'right'] as const) {
const point = nextHands[hand];
if (!point || !isRuntimePointTouchingItem(point)) {
continue;
}
setHeldItem({ hand });
return;
}
}, [
heldItem,
isComplete,
isJudgementOpen,
resetInputPaths,
@@ -462,16 +581,24 @@ export function BabyObjectMatchRuntimeShell({
sendItemToBasket,
]);
const getPointerUnitX = (
const getPointerUnitPoint = (
event: ReactPointerEvent<HTMLElement>,
element: HTMLElement,
) => {
): RuntimeHandPoint => {
const rect = element.getBoundingClientRect();
const width = rect.width || 1;
return Math.max(0, Math.min(1, (event.clientX - rect.left) / width));
const height = rect.height || 1;
return clampRuntimePoint({
x: (event.clientX - rect.left) / width,
y: (event.clientY - rect.top) / height,
});
};
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (isRuntimeControlPointerTarget(event.target)) {
return;
}
if (!isJudgementOpen) {
return;
}
@@ -480,13 +607,12 @@ export function BabyObjectMatchRuntimeShell({
return;
}
const side: BasketSide = event.button === 2 ? 'right' : 'left';
const pointerX = getPointerUnitX(event, event.currentTarget);
dragStateRef.current = {
side,
startX: pointerX,
lastX: pointerX,
};
const hand: RuntimeHandRole = event.button === 2 ? 'right' : 'left';
const point = getPointerUnitPoint(event, event.currentTarget);
setRuntimeHands((current) => ({ ...current, [hand]: point }));
if (isRuntimePointTouchingItem(point)) {
setHeldItem({ hand });
}
event.preventDefault();
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId);
@@ -494,36 +620,44 @@ export function BabyObjectMatchRuntimeShell({
};
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (isRuntimeControlPointerTarget(event.target)) {
return;
}
if (!isJudgementOpen) {
dragStateRef.current = null;
return;
}
if (!dragStateRef.current) {
if (event.buttons !== 1 && event.buttons !== 2) {
return;
}
dragStateRef.current = {
...dragStateRef.current,
lastX: getPointerUnitX(event, event.currentTarget),
};
const hand: RuntimeHandRole = event.buttons === 2 ? 'right' : 'left';
const point = getPointerUnitPoint(event, event.currentTarget);
setRuntimeHands((current) => ({ ...current, [hand]: point }));
if (!heldItem && isRuntimePointTouchingItem(point)) {
setHeldItem({ hand });
return;
}
if (!heldItem || heldItem.hand !== hand) {
return;
}
const targetSide = resolveBasketSideForPoint(point);
if (targetSide) {
sendItemToBasket(targetSide);
resetInputPaths();
}
};
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current;
dragStateRef.current = null;
if (
typeof event.currentTarget.hasPointerCapture === 'function' &&
event.currentTarget.hasPointerCapture(event.pointerId)
) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!dragState || !isHorizontalDrag(dragState)) {
return;
}
sendItemToBasket(dragState.side);
};
return (
@@ -556,6 +690,7 @@ export function BabyObjectMatchRuntimeShell({
<button
type="button"
className="baby-object-runtime__back"
data-baby-object-runtime-control="true"
onClick={onBack}
aria-label="返回"
title="返回"
@@ -613,10 +748,31 @@ export function BabyObjectMatchRuntimeShell({
/>
) : null}
{introShowcase ? (
<div
className={`baby-object-runtime__intro-item baby-object-runtime__intro-item--${introShowcase.side}${
introShowcase.isFlying
? ' baby-object-runtime__intro-item--flying'
: ''
}`}
data-testid="baby-object-intro-item"
aria-live="polite"
>
<ResolvedAssetImage
src={introShowcase.item.imageSrc}
alt={introShowcase.item.itemName}
className="baby-object-runtime__intro-item-image"
/>
<span className="baby-object-runtime__intro-item-name">
{introShowcase.item.itemName}
</span>
</div>
) : null}
<div
className={`baby-object-runtime__item${
shouldShowCurrentItem ? ' baby-object-runtime__item--visible' : ''
}${
}${heldPoint ? ' baby-object-runtime__item--held' : ''}${
phase === 'item-appearing'
? ' baby-object-runtime__item--appearing'
: ''
@@ -629,6 +785,14 @@ export function BabyObjectMatchRuntimeShell({
}`}
data-testid="baby-object-current-item"
aria-live="polite"
style={
heldPoint
? ({
'--baby-object-held-x': `${heldPoint.x * 100}%`,
'--baby-object-held-y': `${heldPoint.y * 100}%`,
} as CSSProperties)
: undefined
}
>
{shouldShowCurrentItem ? (
<>
@@ -644,6 +808,34 @@ export function BabyObjectMatchRuntimeShell({
) : null}
</div>
<div className="baby-object-runtime__hands" aria-hidden="true">
{(['left', 'right'] as const).map((hand) => {
const point = runtimeHands[hand];
const isHoldingHand = heldItem?.hand === hand;
if (!point) {
return null;
}
return (
<div
key={hand}
className={`baby-object-runtime__hand baby-object-runtime__hand--${hand}${
isHoldingHand
? ` baby-object-runtime__hand--holding baby-object-runtime__hand--holding-${hand}-corner`
: ''
}`}
data-testid={`baby-object-${hand}-hand`}
style={
{
'--baby-object-hand-x': `${point.x * 100}%`,
'--baby-object-hand-y': `${point.y * 100}%`,
} as CSSProperties
}
/>
);
})}
</div>
{feedbackText ? (
<div
className={`baby-object-runtime__feedback baby-object-runtime__feedback--${phase}`}
@@ -673,6 +865,7 @@ export function BabyObjectMatchRuntimeShell({
{(['left', 'right'] as const).map((side) => {
const basketItem =
round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1];
const isOptionReady = isBasketOptionReadyInIntro(side, phase);
return (
<div
@@ -684,12 +877,23 @@ export function BabyObjectMatchRuntimeShell({
}`}
aria-label={`${side === 'left' ? '左侧' : '右侧'} ${basketItem.itemName}`}
>
<div className="baby-object-runtime__basket-icon">
<ResolvedAssetImage
src={basketItem.imageSrc}
alt={basketItem.itemName}
className="baby-object-runtime__basket-image"
/>
<div
className={`baby-object-runtime__basket-option${
isOptionReady
? ' baby-object-runtime__basket-option--ready'
: ''
}`}
>
<div className="baby-object-runtime__basket-icon">
<ResolvedAssetImage
src={basketItem.imageSrc}
alt={basketItem.itemName}
className="baby-object-runtime__basket-image"
/>
</div>
<span className="baby-object-runtime__basket-name">
{basketItem.itemName}
</span>
</div>
<div
className={`baby-object-runtime__basket-body${

View File

@@ -37,6 +37,8 @@ import {
} from './rpgEntryWorldPresentation';
const {
mockQrCodeToDataUrl,
mockRedirectToPaymentUrl,
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
@@ -48,6 +50,8 @@ const {
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => {
const qrCodeToDataUrl = vi.fn(async () => 'data:image/png;base64,QR');
const redirectToPaymentUrl = vi.fn();
const buildReferralCenter = (
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
): ProfileReferralInviteCenterResponse => ({
@@ -119,6 +123,8 @@ const {
});
return {
mockQrCodeToDataUrl: qrCodeToDataUrl,
mockRedirectToPaymentUrl: redirectToPaymentUrl,
mockBuildReferralCenter: buildReferralCenter,
mockBuildTaskCenter: buildTaskCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
@@ -343,6 +349,16 @@ vi.mock('../../services/authService', () => ({
updateAuthProfile: mockUpdateAuthProfile,
}));
vi.mock('qrcode', () => ({
default: {
toDataURL: mockQrCodeToDataUrl,
},
}));
vi.mock('../../services/payment/paymentRedirect', () => ({
redirectToPaymentUrl: mockRedirectToPaymentUrl,
}));
mockUpdateAuthProfile.mockResolvedValue({
id: 'user-1',
publicUserCode: '100001',
@@ -385,6 +401,8 @@ vi.mock('../ResolvedAssetImage', () => ({
}));
const originalMatchMedia = window.matchMedia;
const originalUserAgent = navigator.userAgent;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
@@ -584,12 +602,56 @@ function buildBabyObjectMatchEntry(
}
function mockDesktopLayout() {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
});
Object.defineProperty(navigator, 'maxTouchPoints', {
configurable: true,
value: 0,
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => {
const normalizedQuery = query.replace(/\s/g, '');
return {
matches:
normalizedQuery.includes('min-width:1024px') ||
normalizedQuery.includes('min-width:1024'),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
function mockWechatDesktopLayout() {
mockDesktopLayout();
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 MicroMessenger/8.0',
});
}
function mockWechatMobileLayout() {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit MicroMessenger/8.0 Mobile',
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(min-width: 1024px)',
media: '(max-width: 767px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
@@ -676,7 +738,10 @@ function renderProfileView(
}
async function openRechargeModal(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /\s*\//u }));
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
}
function renderLoggedOutHomeView(
@@ -981,11 +1046,21 @@ afterEach(() => {
wechatBound: false,
createdAt: new Date().toISOString(),
});
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
mockRedirectToPaymentUrl.mockReset();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value: originalUserAgent,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
configurable: true,
value: originalMaxTouchPoints,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
@@ -1017,12 +1092,49 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy();
});
test('profile recharge modal buys points through mock channel outside mini program', async () => {
test('profile recharge modal shows native qr code on desktop web by default', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
mockWechatDesktopLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_native',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test',
},
});
renderProfileView(onRechargeSuccess);
await openRechargeModal(user);
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
@@ -1031,16 +1143,84 @@ test('profile recharge modal buys points through mock channel outside mini progr
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'mock',
'wechat_native',
);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('微信扫码支付')).toBeTruthy();
await waitFor(() => {
expect(screen.getByAltText('微信 Native 支付二维码')).toBeTruthy();
});
expect(mockQrCodeToDataUrl).toHaveBeenCalledWith(
'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-test',
expect.objectContaining({ width: 180 }),
);
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
});
test('profile recharge modal jumps to h5 payment on mobile web by default', async () => {
const user = userEvent.setup();
mockWechatMobileLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-h5-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_h5',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatH5Payment: {
h5Url:
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5',
},
});
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_h5',
);
});
expect(mockRedirectToPaymentUrl).toHaveBeenCalledWith(
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx-h5',
);
expect(
await screen.findByRole('dialog', { name: '正在打开微信支付' }),
).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '支付成功' })).toBeNull();
});
test('profile recharge modal trusts per-product first bonus display after points recharge', async () => {
const user = userEvent.setup();
mockWechatDesktopLayout();
mockGetRpgProfileRechargeCenter.mockResolvedValueOnce({
walletBalance: 60,
membership: {
@@ -1158,6 +1338,11 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(mockCreateRpgProfileRechargeOrder).not.toHaveBeenCalledWith(
'points_60',
'mock',
);
expect(mockRedirectToPaymentUrl).not.toHaveBeenCalled();
expect(screen.getByText('已到账,账户状态已刷新。')).toBeTruthy();
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-wechat-1',
@@ -1476,6 +1661,110 @@ test('profile recharge modal releases submitting state after cancelled wechat pa
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
});
test('profile native qr confirmation refreshes only after server reports paid', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
mockWechatDesktopLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_native',
paidAt: null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-paid',
},
});
mockConfirmWechatRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid' as const,
paymentChannel: 'wechat_native',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-native-1',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
});
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await user.click(await screen.findByRole('button', { name: '我已支付' }));
await waitFor(() => {
expect(mockConfirmWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-native-paid',
);
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('non-wechat profile shows reward code instead of recharge entry', async () => {
const user = userEvent.setup();
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(shortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
await user.click(within(shortcutRegion).getByRole('button', { name: //u }));
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
@@ -1731,7 +2020,10 @@ test('opens reward code modal from profile action on mobile', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: //u }));
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
const modal = await screen.findByPlaceholderText('输入兑换码');
expect(modal).toBeTruthy();

View File

@@ -42,6 +42,7 @@ import {
useRef,
useState,
} from 'react';
import QRCode from 'qrcode';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
@@ -57,6 +58,7 @@ import type {
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
WechatNativePayment,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
@@ -73,6 +75,14 @@ import {
updateAuthProfile,
} from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
import {
resolveProfileRechargePaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from '../../services/payment/paymentPlatform';
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
import {
claimRpgProfileTaskReward,
confirmWechatRpgProfileRechargeOrder,
@@ -217,9 +227,9 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type RechargeTab = 'points' | 'membership';
@@ -235,6 +245,10 @@ type RechargePaymentResult = {
title: string;
message: string;
};
type NativeWechatPaymentState = WechatNativePayment & {
orderId: string;
isConfirming: boolean;
};
type DiscoverChannel =
| 'recommend'
| 'today'
@@ -2527,18 +2541,6 @@ function formatRechargePrice(priceCents: number) {
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function isWechatMiniProgramWebView() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program'
);
}
function clearWechatPayResultHash() {
if (typeof window === 'undefined') {
return;
@@ -2685,6 +2687,36 @@ async function confirmWechatRechargeOrderUntilSettled(
return latestResponse;
}
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
function RechargeProductCard({
product,
submittingProductId,
@@ -2737,22 +2769,29 @@ function ProfileRechargeModal({
isLoading,
error,
submittingProductId,
nativePayment,
activeTab,
onTabChange,
onClose,
onRetry,
onBuy,
onConfirmNativePayment,
}: {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
onConfirmNativePayment: () => void;
}) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const products =
activeTab === 'points'
? (center?.pointProducts ?? [])
@@ -2841,6 +2880,33 @@ function ProfileRechargeModal({
</div>
)}
{nativePayment ? (
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-4 text-center">
<div className="text-sm font-black"></div>
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">
</span>
)}
</div>
<button
type="button"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
className="platform-primary-button mt-4 rounded-2xl px-4 py-2 text-xs font-black disabled:cursor-wait disabled:opacity-60"
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</button>
</div>
) : null}
</div>
</div>
</div>
@@ -3594,6 +3660,7 @@ export function RpgEntryHomeView({
hasUnreadDraftUpdate = false,
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const showRechargeEntry = shouldShowRechargeEntry();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
@@ -3611,6 +3678,8 @@ export function RpgEntryHomeView({
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null);
const [nativeWechatPayment, setNativeWechatPayment] =
useState<NativeWechatPaymentState | null>(null);
const [activeRechargeTab, setActiveRechargeTab] =
useState<RechargeTab>('points');
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
@@ -4149,6 +4218,7 @@ export function RpgEntryHomeView({
loadRechargeCenter();
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
setNativeWechatPayment(null);
}, [loadRechargeCenter]);
const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash();
@@ -4232,16 +4302,24 @@ export function RpgEntryHomeView({
setIsRechargeOpen(true);
loadRechargeCenter();
};
const openRechargeOrRewardCodeModal = () => {
if (showRechargeEntry) {
openRechargeModal();
return;
}
openRewardCodeModal();
};
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) {
return;
}
const paymentChannel = isWechatMiniProgramWebView()
? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
: 'mock';
const paymentChannel = resolveProfileRechargePaymentChannel();
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
setRechargePaymentResult(null);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
@@ -4252,24 +4330,105 @@ export function RpgEntryHomeView({
);
setRechargeCenter(response.center);
return;
} else {
}
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {
const h5Url = response.wechatH5Payment?.h5Url?.trim();
if (!h5Url) {
throw new Error('微信 H5 支付链接生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
setRechargePaymentResult({
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
kind: 'pending',
title: '正在打开微信支付',
message: '完成支付后返回页面确认到账状态。',
});
pendingWechatRechargeOrderIdRef.current = null;
setSubmittingRechargeProductId(null);
redirectToPaymentUrl(h5Url);
return;
}
void onRechargeSuccess?.();
if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) {
const codeUrl = response.wechatNativePayment?.codeUrl?.trim();
if (!codeUrl) {
throw new Error('微信 Native 支付二维码生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
setNativeWechatPayment({
orderId: response.order.orderId,
codeUrl,
isConfirming: false,
});
setSubmittingRechargeProductId(null);
return;
}
throw new Error('充值支付渠道无效');
})
.catch((error: unknown) => {
pendingWechatRechargeOrderIdRef.current = null;
setNativeWechatPayment(null);
setRechargeError(error instanceof Error ? error.message : '充值失败');
setSubmittingRechargeProductId(null);
});
};
const confirmNativeWechatPayment = useCallback(() => {
if (!nativeWechatPayment || nativeWechatPayment.isConfirming) {
return;
}
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: true }
: current,
);
setRechargePaymentResult({
kind: 'pending',
title: '正在确认支付',
message: '正在查询微信支付到账状态。',
});
void confirmWechatRechargeOrderUntilSettled(nativeWechatPayment.orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '等待微信确认',
message: '暂时没能确认到账状态,请稍后再试。',
},
);
if (isPaid) {
setNativeWechatPayment(null);
pendingWechatRechargeOrderIdRef.current = null;
void onRechargeSuccess?.();
} else {
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: false }
: current,
);
}
})
.catch(() => {
setRechargePaymentResult({
kind: 'pending',
title: '等待微信确认',
message: '暂时没能确认到账状态,请稍后再试。',
});
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: false }
: current,
);
})
.finally(() => setSubmittingRechargeProductId(null));
}, [nativeWechatPayment, onRechargeSuccess]);
useEffect(() => {
const handleResume = () => {
handleWechatPayResult();
@@ -5569,13 +5728,21 @@ export function RpgEntryHomeView({
<button
type="button"
onClick={openRechargeModal}
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
>
<Coins className="h-4 w-4" />
{showRechargeEntry ? (
<Coins className="h-4 w-4" />
) : (
<Ticket className="h-4 w-4" />
)}
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80">/</div>
<div className="text-xs font-bold">
{showRechargeEntry ? '充值' : '兑换码'}
</div>
<div className="text-[10px] opacity-80">
{showRechargeEntry ? '泥点/会员' : '福利奖励'}
</div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -5659,11 +5826,19 @@ export function RpgEntryHomeView({
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"
icon={Ticket}
onClick={openRewardCodeModal}
label={showRechargeEntry ? '充值' : '兑换码'}
subLabel={showRechargeEntry ? '泥点/会员' : '福利奖励'}
icon={showRechargeEntry ? Coins : Ticket}
onClick={openRechargeOrRewardCodeModal}
/>
{showRechargeEntry ? (
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"
icon={Ticket}
onClick={openRewardCodeModal}
/>
) : null}
<ProfileShortcutButton
label="邀请好友"
subLabel={
@@ -6149,11 +6324,13 @@ export function RpgEntryHomeView({
isLoading={isLoadingRechargeCenter}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (