Merge branch 'master' of https://git.genarrative.world/GenarrativeAI/Genarrative
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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${
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
479
src/index.css
479
src/index.css
@@ -2901,6 +2901,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
--baby-object-gift-box-image: linear-gradient(180deg, transparent, transparent);
|
||||
--baby-object-basket-image: linear-gradient(180deg, transparent, transparent);
|
||||
--baby-object-smoke-image: radial-gradient(circle, rgba(255, 255, 255, 0.9), transparent 68%);
|
||||
--baby-object-left-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png');
|
||||
--baby-object-right-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png');
|
||||
--baby-object-sky: #cfefff;
|
||||
--baby-object-ground: #7bc36f;
|
||||
--baby-object-ground-deep: #3f8b48;
|
||||
@@ -2974,7 +2976,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.baby-object-runtime__back,
|
||||
.baby-object-runtime__counter {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
z-index: 11;
|
||||
top: max(0.75rem, env(safe-area-inset-top));
|
||||
display: inline-flex;
|
||||
min-height: 2.4rem;
|
||||
@@ -2988,6 +2990,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: var(--baby-object-text);
|
||||
box-shadow: 0 14px 34px rgba(60, 112, 74, 0.16);
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.baby-object-runtime__back {
|
||||
@@ -3189,14 +3192,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.baby-object-runtime__item {
|
||||
--baby-object-item-size: clamp(6.2rem, 15vw, 9.5rem);
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
left: 50%;
|
||||
top: 37%;
|
||||
display: grid;
|
||||
width: clamp(6.2rem, 15vw, 9.5rem);
|
||||
width: var(--baby-object-item-size);
|
||||
height: var(--baby-object-item-size);
|
||||
min-width: var(--baby-object-item-size);
|
||||
min-height: var(--baby-object-item-size);
|
||||
max-width: var(--baby-object-item-size);
|
||||
max-height: var(--baby-object-item-size);
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
box-sizing: border-box;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 0.2rem solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 50%;
|
||||
@@ -3205,18 +3215,133 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
0 18px 42px rgba(61, 106, 72, 0.17),
|
||||
inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32);
|
||||
opacity: 0;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
transition: transform 260ms ease;
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.baby-object-runtime__item--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.baby-object-runtime__intro-item {
|
||||
--baby-object-intro-target-x: -210%;
|
||||
--baby-object-intro-target-y: 126%;
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
left: 50%;
|
||||
top: 37%;
|
||||
display: grid;
|
||||
width: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
|
||||
height: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
|
||||
min-width: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
|
||||
min-height: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
|
||||
max-width: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
|
||||
max-height: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
box-sizing: border-box;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
border: 0.2rem solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 253, 244, 0.78);
|
||||
box-shadow:
|
||||
0 18px 42px rgba(61, 106, 72, 0.17),
|
||||
inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32);
|
||||
pointer-events: none;
|
||||
animation: baby-object-intro-pop 0.46s ease-out both;
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.baby-object-runtime__intro-item--right {
|
||||
--baby-object-intro-target-x: 110%;
|
||||
--baby-object-intro-target-y: 126%;
|
||||
}
|
||||
|
||||
.baby-object-runtime__intro-item--flying {
|
||||
animation: baby-object-intro-fly 0.72s cubic-bezier(0.22, 0.82, 0.24, 1)
|
||||
forwards;
|
||||
}
|
||||
|
||||
.baby-object-runtime__intro-item-image {
|
||||
display: block;
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 76%;
|
||||
max-height: 76%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.baby-object-runtime__intro-item-name {
|
||||
position: absolute;
|
||||
bottom: -1.45rem;
|
||||
max-width: 12.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 253, 244, 0.9);
|
||||
padding: 0.32rem 0.95rem;
|
||||
color: var(--baby-object-text);
|
||||
font-size: clamp(1.56rem, 3vw, 2rem);
|
||||
font-weight: 950;
|
||||
line-height: 1.05;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgba(60, 112, 74, 0.12);
|
||||
}
|
||||
|
||||
.baby-object-runtime__intro-item--flying .baby-object-runtime__intro-item-name {
|
||||
bottom: -0.9rem;
|
||||
max-width: 8rem;
|
||||
padding: 0.22rem 0.7rem;
|
||||
font-size: clamp(0.78rem, 1.5vw, 1rem);
|
||||
transition:
|
||||
bottom 0.72s ease,
|
||||
max-width 0.72s ease,
|
||||
padding 0.72s ease,
|
||||
font-size 0.72s ease;
|
||||
}
|
||||
|
||||
@keyframes baby-object-intro-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -44%) scale(0.78);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes baby-object-intro-fly {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--baby-object-intro-target-x), var(--baby-object-intro-target-y))
|
||||
scale(0.68);
|
||||
}
|
||||
}
|
||||
|
||||
.baby-object-runtime__item--appearing {
|
||||
animation: baby-object-item-appear 0.62s cubic-bezier(0.2, 0.86, 0.28, 1.12);
|
||||
}
|
||||
|
||||
.baby-object-runtime__item--held {
|
||||
left: var(--baby-object-held-x, 50%);
|
||||
top: var(--baby-object-held-y, 37%);
|
||||
transform: translate(-50%, -56%) scale(0.88) rotate(-3deg);
|
||||
transition:
|
||||
left 90ms linear,
|
||||
top 90ms linear,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
@keyframes baby-object-item-appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -3261,9 +3386,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.baby-object-runtime__item-image {
|
||||
display: block;
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 76%;
|
||||
max-height: 76%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.baby-object-runtime__item-name {
|
||||
@@ -3281,6 +3412,54 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.baby-object-runtime__hands {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand {
|
||||
position: absolute;
|
||||
left: var(--baby-object-hand-x, 50%);
|
||||
top: var(--baby-object-hand-y, 50%);
|
||||
width: clamp(4.1rem, 9.4vw, 7.1rem);
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg));
|
||||
border-radius: 48% 48% 54% 54%;
|
||||
background: var(--baby-object-hand-image) center / contain no-repeat;
|
||||
filter: drop-shadow(0 10px 18px rgba(83, 78, 50, 0.16));
|
||||
opacity: 0.92;
|
||||
transition:
|
||||
left 90ms linear,
|
||||
top 90ms linear,
|
||||
transform 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand--left {
|
||||
--baby-object-hand-image: var(--baby-object-left-hand-image);
|
||||
--baby-object-hand-rotate: -10deg;
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand--right {
|
||||
--baby-object-hand-image: var(--baby-object-right-hand-image);
|
||||
--baby-object-hand-rotate: 10deg;
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand--holding {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.08);
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand--holding-left-corner {
|
||||
transform: translate(-112%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand--holding-right-corner {
|
||||
transform: translate(12%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
|
||||
}
|
||||
|
||||
.baby-object-runtime__feedback {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
@@ -3393,31 +3572,82 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-icon {
|
||||
.baby-object-runtime__basket-option {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 50%;
|
||||
top: -26%;
|
||||
top: -34%;
|
||||
display: grid;
|
||||
width: 54%;
|
||||
width: 58%;
|
||||
justify-items: center;
|
||||
gap: 0.18rem;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-option--ready {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-icon {
|
||||
--baby-object-basket-icon-size: clamp(4.6rem, 9vw, 7.1rem);
|
||||
display: grid;
|
||||
width: var(--baby-object-basket-icon-size);
|
||||
height: var(--baby-object-basket-icon-size);
|
||||
min-width: var(--baby-object-basket-icon-size);
|
||||
min-height: var(--baby-object-basket-icon-size);
|
||||
max-width: var(--baby-object-basket-icon-size);
|
||||
max-height: var(--baby-object-basket-icon-size);
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
transform: translateX(-50%);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border: 0.18rem solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 253, 244, 0.88);
|
||||
box-shadow: 0 10px 22px rgba(60, 112, 74, 0.12);
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-image {
|
||||
display: block;
|
||||
width: 74%;
|
||||
height: 74%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 74%;
|
||||
max-height: 74%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-name {
|
||||
display: inline-flex;
|
||||
max-width: min(7.5rem, 92%);
|
||||
min-height: 1.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 253, 244, 0.9);
|
||||
color: var(--baby-object-text);
|
||||
padding: 0.16rem 0.58rem;
|
||||
font-size: clamp(0.72rem, 1.35vw, 0.92rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.05;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 7px 16px rgba(60, 112, 74, 0.1);
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-body {
|
||||
position: absolute;
|
||||
inset: 20% 0 0;
|
||||
inset: 26% 0 0;
|
||||
border: 0.28rem solid rgba(139, 84, 40, 0.72);
|
||||
border-top-width: 0.42rem;
|
||||
border-radius: 0.8rem 0.8rem 2rem 2rem;
|
||||
@@ -3429,9 +3659,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.baby-object-runtime__basket-shell-image {
|
||||
position: absolute;
|
||||
inset: -24% -18% -8% -18%;
|
||||
width: calc(100% + 36%);
|
||||
height: calc(100% + 32%);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 142%;
|
||||
height: 136%;
|
||||
transform: translate(-50%, -50%);
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -7542,13 +7774,17 @@ button {
|
||||
--child-motion-asset-stage: url('/child-motion-demo/picture-book-grass-stage.png');
|
||||
--child-motion-asset-floor: url('/child-motion-demo/picture-book-foreground-grass-v2.png');
|
||||
--child-motion-asset-ring: url('/child-motion-demo/picture-book-ground-ring-v3.png');
|
||||
--child-motion-asset-avatar: url('/child-motion-demo/picture-book-character-outline-v2.png');
|
||||
--child-motion-asset-avatar: url('/child-motion-demo/picture-book-character-outline-v4.png');
|
||||
--child-motion-asset-hud: url('/child-motion-demo/picture-book-hud-strip-v2.png');
|
||||
--child-motion-asset-calibration: url('/child-motion-demo/picture-book-calibration-strip-v2.png');
|
||||
--child-motion-asset-start-panel: url('/child-motion-demo/picture-book-start-panel-v2.png');
|
||||
--child-motion-asset-button: url('/child-motion-demo/picture-book-ui-button-v2.png');
|
||||
--child-motion-asset-wave-cat-body: url('/child-motion-demo/picture-book-wave-cat-body-guide-v6.png');
|
||||
--child-motion-asset-wave-cat-arm: url('/child-motion-demo/picture-book-wave-cat-arm-guide-v6.png');
|
||||
--child-motion-asset-wave-cat-body: url('/child-motion-demo/picture-book-wave-cat-body-guide-v7.png');
|
||||
--child-motion-asset-wave-cat-arm: url('/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png');
|
||||
--child-motion-asset-wave-cat-paw-left: url('/child-motion-demo/picture-book-wave-cat-paw-left-v1.png');
|
||||
--child-motion-asset-wave-cat-paw-right: url('/child-motion-demo/picture-book-wave-cat-paw-right-v1.png');
|
||||
--baby-object-left-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png');
|
||||
--baby-object-right-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png');
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -7776,13 +8012,20 @@ button {
|
||||
padding: clamp(0.45rem, 1.2vw, 0.75rem) clamp(0.72rem, 2vw, 1.25rem);
|
||||
}
|
||||
|
||||
.child-motion-hud--top > div {
|
||||
.child-motion-hud__copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 0 clamp(0.35rem, 1vw, 0.75rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.child-motion-hud__copy--subtitle-only {
|
||||
display: flex;
|
||||
min-height: clamp(2.4rem, 6vw, 3.5rem);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.child-motion-hud h1 {
|
||||
margin: 0;
|
||||
color: var(--child-motion-text);
|
||||
@@ -7805,6 +8048,13 @@ button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.child-motion-hud__copy--subtitle-only p {
|
||||
margin-top: 0;
|
||||
font-size: clamp(0.86rem, 1.85vw, 1.25rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.child-motion-step-count,
|
||||
.child-motion-progress {
|
||||
display: inline-flex;
|
||||
@@ -7898,7 +8148,7 @@ button {
|
||||
position: absolute;
|
||||
bottom: 21.5%;
|
||||
z-index: 5;
|
||||
width: clamp(4.2rem, 8.4vw, 6.8rem);
|
||||
width: clamp(6.3rem, 12.6vw, 10.2rem);
|
||||
aspect-ratio: 2 / 3;
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
isolation: isolate;
|
||||
@@ -7942,6 +8192,10 @@ button {
|
||||
animation: child-motion-guide-appear 0.3s ease-out both;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide--greeting {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
@keyframes child-motion-guide-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -7954,58 +8208,40 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__jump {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 38%;
|
||||
display: inline-flex;
|
||||
width: clamp(4.5rem, 11vw, 8rem);
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid rgba(117, 186, 92, 0.56);
|
||||
border-radius: 999px;
|
||||
background: rgba(247, 251, 243, 0.18);
|
||||
color: var(--child-motion-text);
|
||||
font-size: clamp(1rem, 2.4vw, 1.55rem);
|
||||
font-weight: 900;
|
||||
box-shadow: 0 8px 24px rgba(79, 126, 67, 0.12);
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__wave-cat {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 42%;
|
||||
top: clamp(9.8rem, 31vh, 20rem);
|
||||
width: clamp(12.5rem, 25vw, 20.5rem);
|
||||
aspect-ratio: 1.16;
|
||||
transform: translate(-50%, -50%);
|
||||
transform-origin: center 72%;
|
||||
opacity: 0.86;
|
||||
filter: drop-shadow(0 0.65rem 1.2rem rgba(69, 121, 73, 0.16));
|
||||
animation: child-motion-wave-cat-bob 1.38s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__wave-cat-body {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
z-index: 2;
|
||||
width: 72%;
|
||||
aspect-ratio: 1;
|
||||
transform: translateX(-50%);
|
||||
background: var(--child-motion-asset-wave-cat-body) center bottom / contain
|
||||
no-repeat;
|
||||
animation: child-motion-wave-cat-body 1.38s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__wave-cat-arm {
|
||||
position: absolute;
|
||||
bottom: 16%;
|
||||
z-index: 3;
|
||||
bottom: 9.5%;
|
||||
z-index: 5;
|
||||
width: 34%;
|
||||
aspect-ratio: 1;
|
||||
background: none;
|
||||
transform-origin: var(--child-motion-wave-cat-arm-origin-x) 92%;
|
||||
animation: child-motion-wave-cat-arm 0.7s ease-in-out infinite alternate;
|
||||
transform-origin: var(--child-motion-wave-cat-arm-origin-x) 78%;
|
||||
animation: child-motion-wave-cat-arm 0.47s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__wave-cat-arm::before {
|
||||
@@ -8017,76 +8253,161 @@ button {
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__wave-cat-arm--left {
|
||||
left: 13%;
|
||||
--child-motion-wave-cat-arm-origin-x: 76%;
|
||||
left: 12%;
|
||||
--child-motion-wave-cat-arm-origin-x: 60%;
|
||||
--child-motion-wave-hand-direction: -1;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__wave-cat-arm--left::before {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__wave-cat-arm--right {
|
||||
right: 13%;
|
||||
--child-motion-wave-cat-arm-origin-x: 24%;
|
||||
right: 12%;
|
||||
--child-motion-wave-cat-arm-origin-x: 40%;
|
||||
--child-motion-wave-hand-direction: 1;
|
||||
animation-delay: -0.35s;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm {
|
||||
.child-motion-gesture-guide__wave-cat-arm--right::before {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing {
|
||||
--child-motion-arm-swing-origin-x: 18%;
|
||||
--child-motion-arm-swing-radius: clamp(5.2rem, 9.6vw, 7.4rem);
|
||||
--child-motion-arm-swing-angle-from: -43deg;
|
||||
--child-motion-arm-swing-angle-to: 43deg;
|
||||
--child-motion-arm-swing-paw-offset-x: calc(
|
||||
var(--child-motion-arm-swing-radius) * 1
|
||||
);
|
||||
--child-motion-arm-swing-paw-size: clamp(4.6rem, 9vw, 7.4rem);
|
||||
position: absolute;
|
||||
top: 22%;
|
||||
width: clamp(4.6rem, 9vw, 7.4rem);
|
||||
top: 14%;
|
||||
display: block;
|
||||
width: clamp(6.8rem, 15vw, 11rem);
|
||||
aspect-ratio: 0.62;
|
||||
overflow: visible;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing--left {
|
||||
left: 16%;
|
||||
--child-motion-arm-swing-origin-x: 82%;
|
||||
--child-motion-arm-swing-paw-offset-x: calc(
|
||||
var(--child-motion-arm-swing-radius) * -1
|
||||
);
|
||||
--child-motion-arm-swing-angle-from: -90deg;
|
||||
--child-motion-arm-swing-angle-to: 90deg;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing--right {
|
||||
right: 16%;
|
||||
--child-motion-arm-swing-origin-x: 18%;
|
||||
--child-motion-arm-swing-angle-from: 90deg;
|
||||
--child-motion-arm-swing-angle-to: -90deg;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing-track {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: var(--child-motion-arm-swing-origin-x);
|
||||
width: calc(var(--child-motion-arm-swing-radius) * 2);
|
||||
height: calc(var(--child-motion-arm-swing-radius) * 2);
|
||||
transform: translate(-50%, -50%);
|
||||
border: clamp(0.18rem, 0.45vw, 0.34rem) solid rgba(255, 249, 222, 0.92);
|
||||
border-inline-start-color: rgba(255, 221, 124, 0.78);
|
||||
border-inline-end-color: transparent;
|
||||
border-radius: 999px;
|
||||
box-shadow:
|
||||
0 0 0 0.14rem rgba(83, 136, 83, 0.12),
|
||||
0 0.55rem 1.2rem rgba(69, 121, 73, 0.12);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-track {
|
||||
border-inline-start-color: transparent;
|
||||
border-inline-end-color: rgba(255, 221, 124, 0.78);
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing-track::before,
|
||||
.child-motion-gesture-guide__arm-swing-track::after {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: clamp(0.7rem, 1.6vw, 1rem);
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 226, 0.94);
|
||||
box-shadow: 0 0 0 0.16rem rgba(255, 206, 104, 0.42);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing-track::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing-track::after {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing-paw {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: var(--child-motion-arm-swing-origin-x);
|
||||
width: 0;
|
||||
height: 0;
|
||||
filter: drop-shadow(0 0.5rem 1rem rgba(69, 121, 73, 0.13));
|
||||
transform-origin: center;
|
||||
animation: child-motion-arm-swing-guide 0.88s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing-paw-asset {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: var(--child-motion-arm-swing-paw-size);
|
||||
aspect-ratio: 1;
|
||||
opacity: 0.78;
|
||||
background: var(--child-motion-asset-wave-cat-arm) center bottom / contain
|
||||
no-repeat;
|
||||
filter: drop-shadow(0 0.5rem 1rem rgba(69, 121, 73, 0.13));
|
||||
transform-origin: 44% 86%;
|
||||
animation: child-motion-arm-guide 0.73s ease-in-out infinite alternate;
|
||||
transform: translate(-50%, -50%)
|
||||
translateX(var(--child-motion-arm-swing-paw-offset-x));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm--left {
|
||||
left: 22%;
|
||||
--child-motion-wave-hand-direction: -1;
|
||||
.child-motion-gesture-guide__arm-swing--left .child-motion-gesture-guide__arm-swing-paw-asset {
|
||||
background-image: var(--child-motion-asset-wave-cat-paw-left);
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm--right {
|
||||
right: 22%;
|
||||
--child-motion-wave-hand-direction: 1;
|
||||
.child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-paw-asset {
|
||||
background-image: var(--child-motion-asset-wave-cat-paw-right);
|
||||
}
|
||||
|
||||
@keyframes child-motion-arm-guide {
|
||||
@keyframes child-motion-arm-swing-guide {
|
||||
from {
|
||||
transform: scaleX(var(--child-motion-wave-hand-direction, 1))
|
||||
rotate(calc(var(--child-motion-wave-hand-direction, 1) * -7deg))
|
||||
translateY(2%);
|
||||
transform: rotate(var(--child-motion-arm-swing-angle-from));
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(var(--child-motion-wave-hand-direction, 1))
|
||||
rotate(calc(var(--child-motion-wave-hand-direction, 1) * 15deg))
|
||||
translateY(-9%);
|
||||
transform: rotate(var(--child-motion-arm-swing-angle-to));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes child-motion-wave-cat-body {
|
||||
@keyframes child-motion-wave-cat-bob {
|
||||
from {
|
||||
transform: translateX(-50%) translateY(1.5%) scale(0.985);
|
||||
transform: translate(-50%, -50%) translateY(1.2%) scale(0.992);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-50%) translateY(-1.5%) scale(1.015);
|
||||
transform: translate(-50%, -50%) translateY(-1.2%) scale(1.008);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes child-motion-wave-cat-arm {
|
||||
from {
|
||||
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * -12deg));
|
||||
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * -15deg));
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * 18deg));
|
||||
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * 22deg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8100,6 +8421,14 @@ button {
|
||||
box-shadow: 0 0 16px rgba(119, 194, 111, 0.56);
|
||||
}
|
||||
|
||||
.child-motion-hand-indicators {
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.child-motion-hand-indicator {
|
||||
width: clamp(3.7rem, 7.8vw, 6.4rem);
|
||||
}
|
||||
|
||||
.child-motion-floating-reward {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -69,14 +69,6 @@ describe('babyObjectMatchClient', () => {
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: 'background prompt',
|
||||
},
|
||||
{
|
||||
assetId: 'server-ui',
|
||||
assetKind: 'ui-frame',
|
||||
imageSrc: 'data:image/png;base64,ui',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: 'ui prompt',
|
||||
},
|
||||
{
|
||||
assetId: 'server-gift',
|
||||
assetKind: 'gift-box',
|
||||
@@ -93,14 +85,6 @@ describe('babyObjectMatchClient', () => {
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: 'basket prompt',
|
||||
},
|
||||
{
|
||||
assetId: 'server-smoke',
|
||||
assetKind: 'smoke-puff',
|
||||
imageSrc: 'data:image/png;base64,smoke',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
prompt: 'smoke prompt',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -127,7 +111,7 @@ describe('babyObjectMatchClient', () => {
|
||||
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
|
||||
'vector-engine-gpt-image-2',
|
||||
);
|
||||
expect(response.draft.visualPackage?.assets).toHaveLength(5);
|
||||
expect(response.draft.visualPackage?.assets).toHaveLength(3);
|
||||
expect(response.draft.visualPackage?.assets[0]?.generationProvider).toBe(
|
||||
'vector-engine-gpt-image-2',
|
||||
);
|
||||
@@ -169,7 +153,7 @@ describe('babyObjectMatchClient', () => {
|
||||
expect(response.draft.visualPackage?.themePrompt).toBe('果园主题视觉包装');
|
||||
expect(
|
||||
response.draft.visualPackage?.assets.map((asset) => asset.assetKind),
|
||||
).toEqual(['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff']);
|
||||
).toEqual(['background', 'gift-box', 'basket']);
|
||||
expect(response.draft.visualPackage?.assets[0]).toMatchObject({
|
||||
assetId: 'baby-object-visual-background',
|
||||
generationProvider: 'vector-engine-gpt-image-2',
|
||||
|
||||
@@ -28,7 +28,7 @@ const BABY_OBJECT_MATCH_ASSET_REQUEST_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 0,
|
||||
};
|
||||
const BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS: BabyObjectMatchVisualAssetKind[] =
|
||||
['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff'];
|
||||
['background', 'gift-box', 'basket'];
|
||||
const DRAFT_DB_NAME = 'genarrative-edutainment-baby-object-drafts';
|
||||
const DRAFT_DB_VERSION = 1;
|
||||
const DRAFT_STORE_NAME = 'drafts';
|
||||
|
||||
118
src/services/payment/paymentPlatform.test.ts
Normal file
118
src/services/payment/paymentPlatform.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveProfileRechargePaymentChannel,
|
||||
shouldShowRechargeEntry,
|
||||
WECHAT_H5_PAYMENT_CHANNEL,
|
||||
WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL,
|
||||
WECHAT_NATIVE_PAYMENT_CHANNEL,
|
||||
} from './paymentPlatform';
|
||||
|
||||
describe('resolveProfileRechargePaymentChannel', () => {
|
||||
test('小程序运行态选择 wechat_mp', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||
}),
|
||||
).toBe(WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('移动网页选择 wechat_h5', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('微信内 H5 首版仍选择 wechat_h5', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(WECHAT_H5_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('桌面网页选择 wechat_native', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
||||
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
|
||||
}),
|
||||
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('桌面微信内网页选择 wechat_native', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit MicroMessenger/8.0',
|
||||
},
|
||||
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
|
||||
}),
|
||||
).toBe(WECHAT_NATIVE_PAYMENT_CHANNEL);
|
||||
});
|
||||
|
||||
test('默认路径永远不会解析成 mock', () => {
|
||||
expect(
|
||||
resolveProfileRechargePaymentChannel({
|
||||
location: { search: '' },
|
||||
navigator: { userAgent: '' },
|
||||
matchMedia: () => ({ matches: false }) as unknown as MediaQueryList,
|
||||
}),
|
||||
).not.toBe('mock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowRechargeEntry', () => {
|
||||
test('小程序运行态显示充值入口', () => {
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '?clientRuntime=wechat_mini_program' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (iPhone)' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('微信内网页显示充值入口', () => {
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit MicroMessenger/8.0 Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('普通浏览器不显示充值入口', () => {
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '' },
|
||||
navigator: {
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile',
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowRechargeEntry({
|
||||
location: { search: '' },
|
||||
navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
95
src/services/payment/paymentPlatform.ts
Normal file
95
src/services/payment/paymentPlatform.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
||||
export const WECHAT_H5_PAYMENT_CHANNEL = 'wechat_h5';
|
||||
export const WECHAT_NATIVE_PAYMENT_CHANNEL = 'wechat_native';
|
||||
export const MOCK_PAYMENT_CHANNEL = 'mock';
|
||||
|
||||
export type ProfileRechargeWechatPaymentChannel =
|
||||
| typeof WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
|
||||
| typeof WECHAT_H5_PAYMENT_CHANNEL
|
||||
| typeof WECHAT_NATIVE_PAYMENT_CHANNEL;
|
||||
|
||||
type PaymentPlatformNavigator = Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
|
||||
|
||||
export type PaymentPlatformContext = {
|
||||
location?: Pick<Location, 'search'> | null;
|
||||
navigator?: Partial<PaymentPlatformNavigator> | null;
|
||||
matchMedia?: Window['matchMedia'] | null;
|
||||
};
|
||||
|
||||
export function shouldShowRechargeEntry(
|
||||
context: PaymentPlatformContext = {},
|
||||
) {
|
||||
const location =
|
||||
context.location ?? (typeof window !== 'undefined' ? window.location : null);
|
||||
const navigatorLike =
|
||||
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
|
||||
|
||||
return (
|
||||
isWechatMiniProgramRuntime(location) ||
|
||||
isWechatBrowserRuntime(navigatorLike)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveProfileRechargePaymentChannel(
|
||||
context: PaymentPlatformContext = {},
|
||||
): ProfileRechargeWechatPaymentChannel {
|
||||
const location =
|
||||
context.location ??
|
||||
(typeof window !== 'undefined' ? window.location : null);
|
||||
const navigatorLike =
|
||||
context.navigator ?? (typeof navigator !== 'undefined' ? navigator : null);
|
||||
const matchMedia =
|
||||
context.matchMedia ??
|
||||
(typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia.bind(window)
|
||||
: null);
|
||||
|
||||
if (isWechatMiniProgramRuntime(location)) {
|
||||
return WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
if (isMobileWebRuntime(navigatorLike, matchMedia)) {
|
||||
return WECHAT_H5_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
return WECHAT_NATIVE_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
export function isManualMockPaymentChannel(paymentChannel: string) {
|
||||
return paymentChannel.trim() === MOCK_PAYMENT_CHANNEL;
|
||||
}
|
||||
|
||||
function isWechatMiniProgramRuntime(
|
||||
location: Pick<Location, 'search'> | null | undefined,
|
||||
) {
|
||||
const params = new URLSearchParams(location?.search ?? '');
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program'
|
||||
);
|
||||
}
|
||||
|
||||
function isWechatBrowserRuntime(
|
||||
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
|
||||
) {
|
||||
return (
|
||||
navigatorLike?.userAgent?.toLowerCase().includes('micromessenger') ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
function isMobileWebRuntime(
|
||||
navigatorLike: Partial<PaymentPlatformNavigator> | null | undefined,
|
||||
matchMedia: Window['matchMedia'] | null | undefined,
|
||||
) {
|
||||
const userAgent = navigatorLike?.userAgent?.toLowerCase() ?? '';
|
||||
if (/android|iphone|ipad|ipod|mobile|windows phone/u.test(userAgent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((navigatorLike?.maxTouchPoints ?? 0) > 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(matchMedia?.('(max-width: 767px)').matches);
|
||||
}
|
||||
3
src/services/payment/paymentRedirect.ts
Normal file
3
src/services/payment/paymentRedirect.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function redirectToPaymentUrl(url: string) {
|
||||
window.location.assign(url);
|
||||
}
|
||||
@@ -67,9 +67,7 @@ export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
export function getRpgProfileWalletLedger(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
@@ -91,7 +89,7 @@ export function getRpgProfileRechargeCenter(
|
||||
|
||||
export function createRpgProfileRechargeOrder(
|
||||
productId: string,
|
||||
paymentChannel = 'mock',
|
||||
paymentChannel: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
|
||||
@@ -227,12 +225,13 @@ export async function resumeRpgProfileSaveArchive(
|
||||
worldKey: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
|
||||
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
|
||||
{ method: 'POST' },
|
||||
'恢复存档失败',
|
||||
options,
|
||||
);
|
||||
const response =
|
||||
await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
|
||||
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
|
||||
{ method: 'POST' },
|
||||
'恢复存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
|
||||
@@ -90,8 +90,10 @@ describe('parseMocapPacket', () => {
|
||||
limb_nodes: [
|
||||
{ name: 'left_shoulder', x: 0.28, y: 0.42 },
|
||||
{ name: 'left_elbow', x: 0.24, y: 0.5 },
|
||||
{ name: 'left_wrist', x: 0.2, y: 0.57 },
|
||||
{ name: 'right_shoulder', x: 0.72, y: 0.42 },
|
||||
{ name: 'right_elbow', x: 0.76, y: 0.5 },
|
||||
{ name: 'right_wrist', x: 0.8, y: 0.57 },
|
||||
],
|
||||
},
|
||||
actions: [{ gesture: 'wave-left-hand' }],
|
||||
@@ -120,8 +122,10 @@ describe('parseMocapPacket', () => {
|
||||
expect(command.bodyJoints).toEqual({
|
||||
leftShoulder: {x: 0.28, y: 0.42},
|
||||
leftElbow: {x: 0.24, y: 0.5},
|
||||
leftWrist: {x: 0.2, y: 0.57},
|
||||
rightShoulder: {x: 0.72, y: 0.42},
|
||||
rightElbow: {x: 0.76, y: 0.5},
|
||||
rightWrist: {x: 0.8, y: 0.57},
|
||||
});
|
||||
expect(command.actions).toEqual(
|
||||
expect.arrayContaining(['wave_left_hand', 'open_palm']),
|
||||
|
||||
@@ -27,6 +27,8 @@ export type MocapBodyJointsInput = {
|
||||
rightShoulder?: MocapPointInput | null;
|
||||
leftElbow?: MocapPointInput | null;
|
||||
rightElbow?: MocapPointInput | null;
|
||||
leftWrist?: MocapPointInput | null;
|
||||
rightWrist?: MocapPointInput | null;
|
||||
};
|
||||
|
||||
export type MocapInputCommand = {
|
||||
@@ -289,6 +291,14 @@ function normalizeBodyJointName(name: unknown) {
|
||||
return 'rightElbow' as const;
|
||||
}
|
||||
|
||||
if (normalized === 'left_wrist' || normalized === 'leftwrist') {
|
||||
return 'leftWrist' as const;
|
||||
}
|
||||
|
||||
if (normalized === 'right_wrist' || normalized === 'rightwrist') {
|
||||
return 'rightWrist' as const;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user