Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
This commit is contained in:
5
src/BarkBattlePlaygroundApp.tsx
Normal file
5
src/BarkBattlePlaygroundApp.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BarkBattleRuntimeShell } from './games/bark-battle/ui/BarkBattleRuntimeShell';
|
||||
|
||||
export default function BarkBattlePlaygroundApp() {
|
||||
return <BarkBattleRuntimeShell />;
|
||||
}
|
||||
@@ -6,51 +6,64 @@ import type {
|
||||
} from '../packages/shared/src/contracts/match3dRuntime';
|
||||
import { Match3DRuntimeShell } from './components/match3d-runtime';
|
||||
import {
|
||||
confirmLocalMatch3DClick,
|
||||
resolveLocalMatch3DTimer,
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
type Match3DRuntimeAdapter,
|
||||
startLocalMatch3DRun,
|
||||
} from './services/match3d-runtime';
|
||||
|
||||
function buildInitialRun() {
|
||||
type LocalMatch3DRuntimeSession = {
|
||||
adapter: Match3DRuntimeAdapter;
|
||||
initialRun: Match3DRunSnapshot;
|
||||
};
|
||||
|
||||
function resolveClearCountParam() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const clearCountParam = params.get('clearCount') ?? params.get('count');
|
||||
const clearCount =
|
||||
clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10);
|
||||
return startLocalMatch3DRun(
|
||||
Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12,
|
||||
);
|
||||
return Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12;
|
||||
}
|
||||
|
||||
function buildInitialRuntimeSession(): LocalMatch3DRuntimeSession {
|
||||
const initialRun = startLocalMatch3DRun(resolveClearCountParam());
|
||||
return {
|
||||
adapter: createLocalMatch3DRuntimeAdapter({ initialRun }),
|
||||
initialRun,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Match3DPlaygroundApp() {
|
||||
const [run, setRun] = useState<Match3DRunSnapshot>(buildInitialRun);
|
||||
const authorityRunRef = useRef(run);
|
||||
const runtimeSessionRef = useRef(buildInitialRuntimeSession());
|
||||
const [run, setRun] = useState<Match3DRunSnapshot>(
|
||||
runtimeSessionRef.current.initialRun,
|
||||
);
|
||||
|
||||
const syncRun = useCallback((nextRun: Match3DRunSnapshot) => {
|
||||
setRun(nextRun);
|
||||
}, []);
|
||||
|
||||
const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => {
|
||||
const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload);
|
||||
authorityRunRef.current = result.run;
|
||||
const runId = payload.runId ?? runtimeSessionRef.current.initialRun.runId;
|
||||
const result = await runtimeSessionRef.current.adapter.clickItem(runId, payload);
|
||||
setRun(result.run);
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
const nextRun = buildInitialRun();
|
||||
authorityRunRef.current = nextRun;
|
||||
setRun(nextRun);
|
||||
}, []);
|
||||
void runtimeSessionRef.current.adapter.restartRun(run.runId).then(({ run }) => {
|
||||
setRun(run);
|
||||
});
|
||||
}, [run.runId]);
|
||||
|
||||
const handleExit = useCallback(() => {
|
||||
window.location.assign('/');
|
||||
}, []);
|
||||
|
||||
const handleTimeExpired = useCallback(() => {
|
||||
const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current);
|
||||
authorityRunRef.current = nextRun;
|
||||
setRun(nextRun);
|
||||
}, []);
|
||||
void runtimeSessionRef.current.adapter.finishTimeUp(run.runId).then(({ run }) => {
|
||||
setRun(run);
|
||||
});
|
||||
}, [run.runId]);
|
||||
|
||||
return (
|
||||
<Match3DRuntimeShell
|
||||
|
||||
@@ -37,6 +37,18 @@ vi.mock('../../services/useMocapInput', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetChildMotionWarmupRuntimeSession();
|
||||
vi.restoreAllMocks();
|
||||
@@ -71,6 +83,18 @@ test('re-entering within the same runtime session opens the start button', () =>
|
||||
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('start button opens the baby object match level', () => {
|
||||
markChildMotionWarmupCompletedInRuntime();
|
||||
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始游戏' }));
|
||||
|
||||
expect(screen.getByTestId('baby-object-match-runtime')).toBeTruthy();
|
||||
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
|
||||
expect(screen.queryByText('下一关正在设计中')).toBeNull();
|
||||
});
|
||||
|
||||
test('developer keyboard input moves the avatar and triggers jump state', () => {
|
||||
render(<ChildMotionWarmupDemo />);
|
||||
|
||||
|
||||
@@ -4,12 +4,19 @@ import type {
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
MocapConnectionStatus,
|
||||
MocapHandInput,
|
||||
MocapInputCommand,
|
||||
} from '../../services/useMocapInput';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
|
||||
import {
|
||||
applyChildMotionWarmupCompletion,
|
||||
CHILD_MOTION_CENTER_X,
|
||||
@@ -33,6 +40,41 @@ type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
|
||||
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
|
||||
type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump';
|
||||
|
||||
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
|
||||
draftId: 'child-motion-demo-baby-object-draft',
|
||||
profileId: 'child-motion-demo-baby-object-profile',
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'child-motion-demo-baby-object-apple',
|
||||
itemName: '苹果',
|
||||
imageSrc:
|
||||
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23fff1d6%22/%3E%3Ccircle cx=%22256%22 cy=%22266%22 r=%22122%22 fill=%22%23ef5b5b%22/%3E%3Cpath d=%22M250 148c20-50 58-66 102-54-18 45-52 70-102 54Z%22 fill=%22%2351a45f%22/%3E%3Cpath d=%22M256 150c-8-34 2-62 28-84%22 stroke=%22%23734822%22 stroke-width=%2218%22 stroke-linecap=%22round%22 fill=%22none%22/%3E%3Ccircle cx=%22216%22 cy=%22226%22 r=%2218%22 fill=%22%23fff%22 opacity=%22.65%22/%3E%3C/svg%3E',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'child-motion-demo-baby-object-banana',
|
||||
itemName: '香蕉',
|
||||
imageSrc:
|
||||
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23e9f7ff%22/%3E%3Cpath d=%22M142 302c128 74 228 38 278-122 14 144-84 244-226 220-52-9-84-38-52-98Z%22 fill=%22%23ffd75d%22/%3E%3Cpath d=%22M406 180c6-20 18-34 38-44%22 stroke=%22%238b5b22%22 stroke-width=%2218%22 stroke-linecap=%22round%22/%3E%3Cpath d=%22M158 310c70 40 152 42 218-38%22 stroke=%22%23fff2a7%22 stroke-width=%2220%22 stroke-linecap=%22round%22 fill=%22none%22 opacity=%22.72%22/%3E%3C/svg%3E',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
publishedAt: '2026-05-11T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const WARMUP_MOCAP_WAVE_MIN_POINTS = 3;
|
||||
const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055;
|
||||
|
||||
@@ -246,7 +288,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
'play_placeholder',
|
||||
];
|
||||
return Math.max(0, order.indexOf(stepId));
|
||||
}
|
||||
@@ -377,6 +418,7 @@ export function ChildMotionWarmupDemo() {
|
||||
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
|
||||
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
|
||||
);
|
||||
const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false);
|
||||
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
|
||||
const [calibration, setCalibration] = useState(
|
||||
createEmptyChildMotionCalibration,
|
||||
@@ -778,16 +820,24 @@ export function ChildMotionWarmupDemo() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPlaceholderLevel = () => {
|
||||
setStepId('play_placeholder');
|
||||
};
|
||||
|
||||
const handleReturnToStart = () => {
|
||||
setStepId('level_select');
|
||||
const handleStartBabyObjectLevel = () => {
|
||||
setIsBabyObjectRuntimeOpen(true);
|
||||
};
|
||||
|
||||
const lineText = useMemo(() => step.spokenLines.join(','), [step.spokenLines]);
|
||||
|
||||
if (isBabyObjectRuntimeOpen) {
|
||||
return (
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT}
|
||||
onBack={() => {
|
||||
setIsBabyObjectRuntimeOpen(false);
|
||||
setStepId('level_select');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="child-motion-demo" data-testid="child-motion-demo">
|
||||
<div className="child-motion-orientation-tip" role="status">
|
||||
@@ -846,21 +896,12 @@ export function ChildMotionWarmupDemo() {
|
||||
|
||||
{step.kind === 'levelSelect' ? (
|
||||
<div className="child-motion-start-panel">
|
||||
<button type="button" onClick={handleStartPlaceholderLevel}>
|
||||
<button type="button" onClick={handleStartBabyObjectLevel}>
|
||||
开始游戏
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'placeholder' ? (
|
||||
<div className="child-motion-start-panel">
|
||||
<span>下一关正在设计中</span>
|
||||
<button type="button" onClick={handleReturnToStart}>
|
||||
回到开始
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ChildMotionCalibrationPanel calibration={calibration} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -25,14 +25,11 @@ describe('childMotionWarmupModel', () => {
|
||||
'jump_once',
|
||||
'warmup_finish',
|
||||
'level_select',
|
||||
'play_placeholder',
|
||||
]);
|
||||
expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe(
|
||||
'wave_greeting',
|
||||
);
|
||||
expect(resolveNextChildMotionWarmupStep('level_select')).toBe(
|
||||
'play_placeholder',
|
||||
);
|
||||
expect(resolveNextChildMotionWarmupStep('level_select')).toBe('level_select');
|
||||
});
|
||||
|
||||
it('checks position completion against the active green ring target', () => {
|
||||
|
||||
@@ -10,8 +10,7 @@ export type ChildMotionWarmupStepId =
|
||||
| 'wave_right_hand'
|
||||
| 'jump_once'
|
||||
| 'warmup_finish'
|
||||
| 'level_select'
|
||||
| 'play_placeholder';
|
||||
| 'level_select';
|
||||
|
||||
export type ChildMotionWarmupTarget = 'center' | 'left' | 'right';
|
||||
|
||||
@@ -20,8 +19,7 @@ export type ChildMotionWarmupStepKind =
|
||||
| 'gesture'
|
||||
| 'narration'
|
||||
| 'finish'
|
||||
| 'levelSelect'
|
||||
| 'placeholder';
|
||||
| 'levelSelect';
|
||||
|
||||
export type ChildMotionWarmupStep = {
|
||||
id: ChildMotionWarmupStepId;
|
||||
@@ -151,12 +149,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
|
||||
title: '准备开始',
|
||||
spokenLines: ['现在开始我们的游戏吧'],
|
||||
},
|
||||
{
|
||||
id: 'play_placeholder',
|
||||
kind: 'placeholder',
|
||||
title: '下一关',
|
||||
spokenLines: ['游戏关卡正在准备中'],
|
||||
},
|
||||
];
|
||||
|
||||
const STEP_BY_ID = new Map(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
@@ -65,6 +66,8 @@ type CustomWorldCreationHubProps = {
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
claimingPuzzleProfileId?: string | null;
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
|
||||
@@ -167,6 +170,8 @@ export function CustomWorldCreationHub({
|
||||
onDeletePuzzle = null,
|
||||
onClaimPuzzlePointIncentive = null,
|
||||
claimingPuzzleProfileId = null,
|
||||
babyObjectMatchItems = [],
|
||||
onOpenBabyObjectMatchDetail = null,
|
||||
visualNovelItems = [],
|
||||
onOpenVisualNovelDetail = null,
|
||||
onDeleteVisualNovel = null,
|
||||
@@ -189,6 +194,7 @@ export function CustomWorldCreationHub({
|
||||
match3dItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
visualNovelItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
@@ -197,11 +203,27 @@ export function CustomWorldCreationHub({
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
|
||||
onOpenRpgDraft: onOpenDraft,
|
||||
onEnterRpgPublished: onEnterPublished,
|
||||
onDeleteRpg: onDeletePublished ?? undefined,
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish: onDeleteBigFish ?? undefined,
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
|
||||
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
|
||||
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
|
||||
getItemState: getWorkState,
|
||||
}),
|
||||
[
|
||||
bigFishItems,
|
||||
isSquareHoleCreationVisible,
|
||||
babyObjectMatchItems,
|
||||
items,
|
||||
match3dItems,
|
||||
onDeleteBigFish,
|
||||
@@ -210,6 +232,15 @@ export function CustomWorldCreationHub({
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
onDeleteVisualNovel,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
onOpenMatch3DDetail,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onOpenPuzzleDetail,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
onEnterPublished,
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
@@ -238,12 +269,13 @@ export function CustomWorldCreationHub({
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
onOpenShelfItem?.(item);
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'baby-object-match':
|
||||
onOpenBabyObjectMatchDetail?.(item.source.item);
|
||||
return;
|
||||
case 'visual-novel':
|
||||
onOpenVisualNovelDetail?.(item.source.item);
|
||||
return;
|
||||
@@ -273,55 +305,11 @@ export function CustomWorldCreationHub({
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeletePuzzle?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'visual-novel': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeleteVisualNovel?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'big-fish': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeleteBigFish?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'match3d': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeleteMatch3D?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'square-hole': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeleteSquareHole?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'rpg': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeletePublished?.(sourceItem);
|
||||
};
|
||||
}
|
||||
}
|
||||
return item.actions.delete ?? null;
|
||||
}
|
||||
|
||||
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
|
||||
if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onClaimPuzzlePointIncentive(sourceItem);
|
||||
};
|
||||
return item.actions.claimPointIncentive ?? null;
|
||||
}
|
||||
|
||||
const showStartCard = mode !== 'works-only';
|
||||
@@ -390,7 +378,10 @@ export function CustomWorldCreationHub({
|
||||
previousMetricValues={
|
||||
metricSnapshot[buildWorkMetricCacheItemKey(item)]
|
||||
}
|
||||
onOpen={() => handleOpenShelfItem(item)}
|
||||
onOpen={() => {
|
||||
onOpenShelfItem?.(item);
|
||||
item.actions.open();
|
||||
}}
|
||||
onDelete={buildDeleteAction(item)}
|
||||
deleteBusy={deletingWorkId === item.id}
|
||||
onClaimPointIncentive={buildPointIncentiveAction(item)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { buildCreationWorkShelfItems } from './creationWorkShelf';
|
||||
|
||||
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
|
||||
@@ -45,3 +46,98 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
|
||||
expect(items[1]?.status).toBe('draft');
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
const onDeletePuzzle = vi.fn();
|
||||
const puzzleWork = {
|
||||
workId: 'puzzle:work-action',
|
||||
profileId: 'puzzle-profile-action',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '动作拼图',
|
||||
summary: '验证作品架动作 Adapter。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft' as const,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
};
|
||||
|
||||
const [item] = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [puzzleWork],
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
});
|
||||
|
||||
item?.actions.open();
|
||||
item?.actions.delete?.();
|
||||
|
||||
expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork);
|
||||
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
|
||||
const baseDraft: BabyObjectMatchDraft = {
|
||||
draftId: 'baby-object-draft-1',
|
||||
profileId: 'baby-object-profile-12345678',
|
||||
templateId: 'baby-object-match',
|
||||
templateName: '宝贝识物',
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'baby-object-item-1',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: '/banana.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: ['寓教于乐'],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
};
|
||||
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
babyObjectMatchItems: [
|
||||
baseDraft,
|
||||
{
|
||||
...baseDraft,
|
||||
draftId: 'baby-object-draft-2',
|
||||
profileId: 'baby-object-profile-87654321',
|
||||
publicationStatus: 'published',
|
||||
publishedAt: '2026-05-11T01:00:00.000Z',
|
||||
updatedAt: '2026-05-11T01:00:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items[0]?.kind).toBe('baby-object-match');
|
||||
expect(items[0]?.status).toBe('published');
|
||||
expect(items[0]?.publicWorkCode).toBe('BO-87654321');
|
||||
expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321');
|
||||
expect(items[1]?.status).toBe('draft');
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
@@ -7,6 +8,7 @@ import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contrac
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
@@ -21,6 +23,7 @@ export type CreationWorkShelfKind =
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'puzzle'
|
||||
| 'baby-object-match'
|
||||
| 'visual-novel';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
|
||||
@@ -77,8 +80,18 @@ export type CreationWorkShelfSource =
|
||||
| {
|
||||
kind: 'visual-novel';
|
||||
item: VisualNovelWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'baby-object-match';
|
||||
item: BabyObjectMatchDraft;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfActions = {
|
||||
open: () => void;
|
||||
delete?: () => void;
|
||||
claimPointIncentive?: () => void;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfItem = {
|
||||
id: string;
|
||||
kind: CreationWorkShelfKind;
|
||||
@@ -99,6 +112,7 @@ export type CreationWorkShelfItem = {
|
||||
badges: CreationWorkShelfBadge[];
|
||||
metrics: CreationWorkShelfMetric[];
|
||||
pointIncentive?: CreationWorkShelfPointIncentive;
|
||||
actions: CreationWorkShelfActions;
|
||||
source: CreationWorkShelfSource;
|
||||
};
|
||||
|
||||
@@ -109,6 +123,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
visualNovelItems?: VisualNovelWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
canDeleteBigFish?: boolean;
|
||||
@@ -116,6 +131,21 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteVisualNovel?: boolean;
|
||||
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterRpgPublished?: (profileId: string) => void;
|
||||
onDeleteRpg?: (item: CustomWorldWorkSummary) => void;
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onDeleteBigFish?: (item: BigFishWorkSummary) => void;
|
||||
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
|
||||
onDeleteMatch3D?: (item: Match3DWorkSummary) => void;
|
||||
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
|
||||
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
|
||||
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
|
||||
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
|
||||
getItemState?: (
|
||||
item: CreationWorkShelfItem,
|
||||
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
|
||||
@@ -127,6 +157,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems = [],
|
||||
squareHoleItems = [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems = [],
|
||||
visualNovelItems = [],
|
||||
canDeleteRpg = false,
|
||||
canDeleteBigFish = false,
|
||||
@@ -134,27 +165,67 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteSquareHole = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteVisualNovel = false,
|
||||
onOpenRpgDraft,
|
||||
onEnterRpgPublished,
|
||||
onDeleteRpg,
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish,
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D,
|
||||
onOpenSquareHoleDetail,
|
||||
onDeleteSquareHole,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBabyObjectMatchDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
onDeleteVisualNovel,
|
||||
getItemState,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
|
||||
onOpenDraft: onOpenRpgDraft,
|
||||
onEnterPublished: onEnterRpgPublished,
|
||||
onDelete: onDeleteRpg,
|
||||
}),
|
||||
),
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
|
||||
onOpen: onOpenBigFishDetail,
|
||||
onDelete: onDeleteBigFish,
|
||||
}),
|
||||
),
|
||||
...match3dItems.map((item) =>
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D),
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
|
||||
onOpen: onOpenMatch3DDetail,
|
||||
onDelete: onDeleteMatch3D,
|
||||
}),
|
||||
),
|
||||
...squareHoleItems.map((item) =>
|
||||
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole),
|
||||
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
|
||||
onOpen: onOpenSquareHoleDetail,
|
||||
onDelete: onDeleteSquareHole,
|
||||
}),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
onDelete: onDeletePuzzle,
|
||||
onClaimPointIncentive: onClaimPuzzlePointIncentive,
|
||||
}),
|
||||
),
|
||||
...babyObjectMatchItems.map((item) =>
|
||||
mapBabyObjectMatchDraftToShelfItem(item, {
|
||||
onOpen: onOpenBabyObjectMatchDetail,
|
||||
}),
|
||||
),
|
||||
...visualNovelItems.map((item) =>
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel),
|
||||
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
|
||||
onOpen: onOpenVisualNovelDetail,
|
||||
onDelete: onDeleteVisualNovel,
|
||||
}),
|
||||
),
|
||||
]
|
||||
.map((item) => {
|
||||
@@ -173,10 +244,26 @@ export function buildCreationWorkShelfItems(params: {
|
||||
);
|
||||
}
|
||||
|
||||
type RpgWorkShelfAdapter = {
|
||||
onOpenDraft?: (item: CustomWorldWorkSummary) => void;
|
||||
onEnterPublished?: (profileId: string) => void;
|
||||
onDelete?: (item: CustomWorldWorkSummary) => void;
|
||||
};
|
||||
|
||||
type WorkShelfAdapter<TItem> = {
|
||||
onOpen?: (item: TItem) => void;
|
||||
onDelete?: (item: TItem) => void;
|
||||
};
|
||||
|
||||
type PuzzleWorkShelfAdapter = WorkShelfAdapter<PuzzleWorkSummary> & {
|
||||
onClaimPointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
};
|
||||
|
||||
function mapRpgWorkToShelfItem(
|
||||
item: CustomWorldWorkSummary,
|
||||
canDelete: boolean,
|
||||
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
): CreationWorkShelfItem {
|
||||
const isDraft = item.status === 'draft';
|
||||
const libraryEntry = item.profileId
|
||||
@@ -217,6 +304,7 @@ function mapRpgWorkToShelfItem(
|
||||
: '查看详情',
|
||||
canDelete,
|
||||
canShare: item.status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildRpgWorkShelfActions(item, adapter),
|
||||
badges,
|
||||
metrics: isDraft ? [] : metrics,
|
||||
source: { kind: 'rpg', item },
|
||||
@@ -226,6 +314,7 @@ function mapRpgWorkToShelfItem(
|
||||
function mapBigFishWorkToShelfItem(
|
||||
item: BigFishWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<BigFishWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const isPublished = item.status === 'published';
|
||||
const publicWorkCode = isPublished
|
||||
@@ -250,6 +339,7 @@ function mapBigFishWorkToShelfItem(
|
||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||
canDelete,
|
||||
canShare: isPublished && Boolean(publicWorkCode),
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
badges: [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: '大鱼', tone: 'neutral' },
|
||||
@@ -268,6 +358,7 @@ function mapBigFishWorkToShelfItem(
|
||||
function mapMatch3DWorkToShelfItem(
|
||||
item: Match3DWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<Match3DWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -291,6 +382,7 @@ function mapMatch3DWorkToShelfItem(
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '抓鹅', tone: 'neutral' },
|
||||
@@ -310,6 +402,7 @@ function mapMatch3DWorkToShelfItem(
|
||||
function mapPuzzleWorkToShelfItem(
|
||||
item: PuzzleWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: PuzzleWorkShelfAdapter,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus;
|
||||
const publicWorkCode =
|
||||
@@ -337,6 +430,7 @@ function mapPuzzleWorkToShelfItem(
|
||||
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
actions: buildPuzzleWorkShelfActions(item, adapter),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '拼图', tone: 'neutral' },
|
||||
@@ -368,9 +462,59 @@ function mapPuzzleWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapBabyObjectMatchDraftToShelfItem(
|
||||
item: BabyObjectMatchDraft,
|
||||
adapter: WorkShelfAdapter<BabyObjectMatchDraft>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published'
|
||||
? buildBabyObjectMatchPublicWorkCode(item.profileId)
|
||||
: null;
|
||||
const coverImageSrc =
|
||||
item.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null;
|
||||
|
||||
return {
|
||||
id: item.profileId,
|
||||
kind: 'baby-object-match',
|
||||
status,
|
||||
title: item.workTitle.trim() || item.templateName,
|
||||
summary:
|
||||
item.workDescription.trim() ||
|
||||
`${item.itemNames[0]}和${item.itemNames[1]}识物分类`,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete: false,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '宝贝识物', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'baby-object-match', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapVisualNovelWorkToShelfItem(
|
||||
item: VisualNovelWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<VisualNovelWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publishStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -411,6 +555,7 @@ function mapVisualNovelWorkToShelfItem(
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'visual-novel', item },
|
||||
};
|
||||
}
|
||||
@@ -418,6 +563,7 @@ function mapVisualNovelWorkToShelfItem(
|
||||
function mapSquareHoleWorkToShelfItem(
|
||||
item: SquareHoleWorkSummary,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<SquareHoleWorkSummary>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
@@ -455,10 +601,64 @@ function mapSquareHoleWorkToShelfItem(
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'square-hole', item },
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkShelfActions<TItem>(
|
||||
item: TItem,
|
||||
adapter: WorkShelfAdapter<TItem>,
|
||||
): CreationWorkShelfActions {
|
||||
return {
|
||||
open: () => {
|
||||
adapter.onOpen?.(item);
|
||||
},
|
||||
delete: adapter.onDelete
|
||||
? () => {
|
||||
adapter.onDelete?.(item);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleWorkShelfActions(
|
||||
item: PuzzleWorkSummary,
|
||||
adapter: PuzzleWorkShelfAdapter,
|
||||
): CreationWorkShelfActions {
|
||||
return {
|
||||
...buildWorkShelfActions(item, adapter),
|
||||
claimPointIncentive: adapter.onClaimPointIncentive
|
||||
? () => {
|
||||
adapter.onClaimPointIncentive?.(item);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRpgWorkShelfActions(
|
||||
item: CustomWorldWorkSummary,
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
): CreationWorkShelfActions {
|
||||
return {
|
||||
open: () => {
|
||||
if (item.status === 'draft') {
|
||||
adapter.onOpenDraft?.(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.profileId) {
|
||||
adapter.onEnterPublished?.(item.profileId);
|
||||
}
|
||||
},
|
||||
delete: adapter.onDelete
|
||||
? () => {
|
||||
adapter.onDelete?.(item);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublishedMetrics(params: {
|
||||
playCount?: number | null;
|
||||
remixCount?: number | null;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { BabyObjectMatchWorkspace } from './BabyObjectMatchWorkspace';
|
||||
|
||||
test('baby object match workspace requires two item names before submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateDraft = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchWorkspace onBack={() => {}} onCreateDraft={onCreateDraft} />,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /生成宝贝识物草稿/u,
|
||||
});
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('物品 A'), '苹果');
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('物品 B'), '香蕉');
|
||||
expect(submitButton).toHaveProperty('disabled', false);
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onCreateDraft).toHaveBeenCalledWith({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
});
|
||||
|
||||
test('baby object match workspace calls back when return button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchWorkspace onBack={onBack} onCreateDraft={() => {}} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
187
src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx
Normal file
187
src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { ArrowLeft, Gift, Loader2, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { CreateBabyObjectMatchDraftRequest } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { validateBabyObjectMatchItemNames } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
|
||||
type BabyObjectMatchWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
initialPayload?: CreateBabyObjectMatchDraftRequest | null;
|
||||
onBack: () => void;
|
||||
onCreateDraft: (payload: CreateBabyObjectMatchDraftRequest) => void;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
type BabyObjectMatchFormState = {
|
||||
itemAName: string;
|
||||
itemBName: string;
|
||||
};
|
||||
|
||||
function resolveInitialFormState(
|
||||
initialPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
|
||||
): BabyObjectMatchFormState {
|
||||
return {
|
||||
itemAName: initialPayload?.itemAName ?? '',
|
||||
itemBName: initialPayload?.itemBName ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function BabyObjectMatchWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
initialPayload = null,
|
||||
onBack,
|
||||
onCreateDraft,
|
||||
showBackButton = true,
|
||||
title = null,
|
||||
}: BabyObjectMatchWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<BabyObjectMatchFormState>(() =>
|
||||
resolveInitialFormState(initialPayload),
|
||||
);
|
||||
const appliedInitialKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextInitialKey = JSON.stringify(initialPayload ?? null);
|
||||
if (appliedInitialKeyRef.current === nextInitialKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialKeyRef.current = nextInitialKey;
|
||||
setFormState(resolveInitialFormState(initialPayload));
|
||||
}, [initialPayload]);
|
||||
|
||||
const validation = useMemo(
|
||||
() => validateBabyObjectMatchItemNames(formState),
|
||||
[formState],
|
||||
);
|
||||
const canSubmit = validation.valid && !isBusy;
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateDraft({
|
||||
itemAName: validation.itemAName,
|
||||
itemBName: validation.itemBName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{title ? (
|
||||
<div className="mb-3 shrink-0 sm:mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={`grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.56fr)] ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<div className="grid min-h-0 gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
物品 A
|
||||
</span>
|
||||
<input
|
||||
value={formState.itemAName}
|
||||
disabled={isBusy}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
itemAName: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
|
||||
aria-label="物品 A"
|
||||
/>
|
||||
</label>
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
物品 B
|
||||
</span>
|
||||
<input
|
||||
value={formState.itemBName}
|
||||
disabled={isBusy}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
itemBName: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
|
||||
aria-label="物品 B"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[8rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(145deg,rgba(236,253,245,0.92),rgba(255,247,237,0.86))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
|
||||
<div className="absolute -right-8 -top-8 h-28 w-28 rounded-full bg-emerald-200/42" />
|
||||
<div className="absolute -bottom-10 left-6 h-24 w-24 rounded-full bg-amber-200/48" />
|
||||
<div className="relative flex h-full min-h-[7rem] flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="grid h-14 w-14 place-items-center rounded-[1.1rem] bg-white/82 text-emerald-600 shadow-[0_12px_30px_rgba(16,185,129,0.14)]">
|
||||
<Gift className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
宝贝识物
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submitForm}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
<span>生成宝贝识物草稿</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BabyObjectMatchWorkspace;
|
||||
@@ -0,0 +1,105 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BabyObjectMatchResultView } from './BabyObjectMatchResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
|
||||
const draft: BabyObjectMatchDraft = {
|
||||
draftId: 'baby-object-draft-1',
|
||||
profileId: 'baby-object-profile-1',
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'baby-object-item-1',
|
||||
itemName: '苹果',
|
||||
imageSrc: 'data:image/svg+xml;utf8,a',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: 'data:image/svg+xml;utf8,b',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: ['宝贝识物'],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
test('baby object result publishes with exact edutainment tag', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPublish = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchResultView
|
||||
draft={createDraft()}
|
||||
onBack={() => {}}
|
||||
onPublish={onPublish}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发布' }));
|
||||
|
||||
expect(onPublish).toHaveBeenCalledTimes(1);
|
||||
expect(onPublish.mock.calls[0]?.[0].themeTags[0]).toBe(
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
);
|
||||
expect(onPublish.mock.calls[0]?.[0].themeTags).toContain('宝贝识物');
|
||||
});
|
||||
|
||||
test('baby object result exposes save and test run actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveDraft = vi.fn();
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchResultView
|
||||
draft={createDraft()}
|
||||
onBack={() => {}}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存草稿' }));
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onSaveDraft).toHaveBeenCalledTimes(1);
|
||||
expect(onStartTestRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
166
src/components/edutainment-result/BabyObjectMatchResultView.tsx
Normal file
166
src/components/edutainment-result/BabyObjectMatchResultView.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { ArrowLeft, CheckCircle2, Loader2, Play, Save, Tag } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
hasBabyObjectMatchRequiredTag,
|
||||
normalizeBabyObjectMatchTags,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type BabyObjectMatchResultViewProps = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSaveDraft?: (draft: BabyObjectMatchDraft) => void;
|
||||
onPublish?: (draft: BabyObjectMatchDraft) => void;
|
||||
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
|
||||
};
|
||||
|
||||
function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
|
||||
return {
|
||||
...draft,
|
||||
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function BabyObjectMatchResultView({
|
||||
draft,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
onStartTestRun,
|
||||
}: BabyObjectMatchResultViewProps) {
|
||||
const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]);
|
||||
const publishReady =
|
||||
normalizedDraft.itemNames.every((itemName) => itemName.trim()) &&
|
||||
normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) &&
|
||||
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags);
|
||||
const isPublished = normalizedDraft.publicationStatus === 'published';
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
{isPublished ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
|
||||
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
|
||||
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
|
||||
<div className="text-sm font-black text-[var(--platform-text-soft)]">
|
||||
模板
|
||||
</div>
|
||||
<h1 className="mt-2 m-0 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
|
||||
{normalizedDraft.workTitle}
|
||||
</h1>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{normalizedDraft.themeTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-black ${
|
||||
tag === BABY_OBJECT_MATCH_EDUTAINMENT_TAG
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)]'
|
||||
}`}
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{normalizedDraft.itemAssets.map((asset) => (
|
||||
<article
|
||||
key={asset.itemId}
|
||||
className="overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]"
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(145deg,rgba(236,253,245,0.92),rgba(255,247,237,0.86))]">
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
alt={asset.itemName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{asset.generationProvider === 'placeholder' ? (
|
||||
<span className="absolute right-2 top-2 rounded-full bg-white/86 px-2 py-0.5 text-[10px] font-black text-[var(--platform-text-soft)] shadow-sm">
|
||||
占位图
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="truncate text-lg font-black text-[var(--platform-text-strong)]">
|
||||
{asset.itemName}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !onSaveDraft}
|
||||
onClick={() => onSaveDraft?.(normalizedDraft)}
|
||||
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
保存草稿
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !onStartTestRun}
|
||||
onClick={() => onStartTestRun?.(normalizedDraft)}
|
||||
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !publishReady || !onPublish}
|
||||
onClick={() => onPublish?.(normalizedDraft)}
|
||||
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BabyObjectMatchResultView;
|
||||
@@ -0,0 +1,692 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type { UseMocapInputResult } from '../../services/useMocapInput';
|
||||
import { BabyObjectMatchRuntimeShell } from './BabyObjectMatchRuntimeShell';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/useMocapInput', () => ({
|
||||
useMocapInput: () => ({
|
||||
status: 'idle',
|
||||
latestCommand: null,
|
||||
rawPacketPreview: null,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createDraft(): BabyObjectMatchDraft {
|
||||
return {
|
||||
draftId: 'baby-object-draft-1',
|
||||
profileId: 'baby-object-profile-1',
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'baby-object-item-1',
|
||||
itemName: '苹果',
|
||||
imageSrc: 'data:image/svg+xml;utf8,apple',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: 'data:image/svg+xml;utf8,banana',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:00.000Z',
|
||||
publishedAt: '2026-05-11T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function createMocapInput(
|
||||
overrides: Partial<UseMocapInputResult> = {},
|
||||
): UseMocapInputResult {
|
||||
return {
|
||||
status: 'connected',
|
||||
latestCommand: null,
|
||||
rawPacketPreview: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomSequence(values: number[]) {
|
||||
let index = 0;
|
||||
return () => {
|
||||
const value = values[index] ?? values[values.length - 1] ?? 0;
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchPointerEvent(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
options: {
|
||||
pointerId: number;
|
||||
button?: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
},
|
||||
) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.assign(event, options);
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function dragHand(stage: HTMLElement, button: 0 | 2) {
|
||||
Object.defineProperty(stage, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 240,
|
||||
width: 320,
|
||||
height: 240,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(stage, 'pointerdown', {
|
||||
pointerId: button + 1,
|
||||
button,
|
||||
clientX: 20,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(stage, 'pointermove', {
|
||||
pointerId: button + 1,
|
||||
button,
|
||||
clientX: 120,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(stage, 'pointerup', {
|
||||
pointerId: button + 1,
|
||||
button,
|
||||
clientX: 120,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('opens the gift box with F and shows the next item', () => {
|
||||
render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
|
||||
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('keeps left and right baskets fixed while only the gift item is random', () => {
|
||||
render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0.99])}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'香蕉',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
|
||||
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mocap open palm followed by grab opens the gift box', () => {
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
mocapInput={createMocapInput()}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mocap camera-right hand movement sends the player left hand item into the left basket', () => {
|
||||
vi.useFakeTimers();
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'right' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'open-camera-right', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'right' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-camera-right', 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-1', receivedAtMs: 3 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
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' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-right-horizontal-2', receivedAtMs: 4 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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: 5 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
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' },
|
||||
leftHand: null,
|
||||
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-right-horizontal-4', receivedAtMs: 6 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('真棒')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap camera-left hand movement sends the player right hand item into the right basket', () => {
|
||||
vi.useFakeTimers();
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-camera-left', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-camera-left', 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-1', receivedAtMs: 3 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
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' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 4 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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: 5 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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: 6 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('再想一想吧')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('mocap action names do not select a basket without horizontal hand movement', () => {
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: ['wave_left_hand', 'wave_right_hand', 'wave'],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 3 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
expect(screen.queryByText('再想一想吧')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mocap unknown hand horizontal movement does not select a basket', () => {
|
||||
const random = createRandomSequence([0, 0]);
|
||||
const { rerender } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' },
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'open-unknown', receivedAtMs: 1 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'unknown' }],
|
||||
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'unknown' },
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: { text: 'grab-unknown', receivedAtMs: 2 },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22;
|
||||
rerender(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={random}
|
||||
mocapInput={createMocapInput({
|
||||
latestCommand: {
|
||||
actions: [],
|
||||
hands: [{ x, y: 0.45, state: 'open_palm', side: 'unknown' }],
|
||||
primaryHand: { x, y: 0.45, state: 'open_palm', side: 'unknown' },
|
||||
leftHand: null,
|
||||
rightHand: null,
|
||||
},
|
||||
rawPacketPreview: {
|
||||
text: `unknown-horizontal-${index + 1}`,
|
||||
receivedAtMs: index + 3,
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
expect(screen.queryByText('再想一想吧')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('left hand horizontal drag sends a correct item into the left basket', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
/>,
|
||||
);
|
||||
const stage = container.querySelector('.baby-object-runtime__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing baby object runtime stage');
|
||||
}
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
dragHand(stage, 0);
|
||||
|
||||
expect(screen.getByText('真棒')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('真棒')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('wrong basket keeps the item active after feedback', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence([0, 0])}
|
||||
/>,
|
||||
);
|
||||
const stage = container.querySelector('.baby-object-runtime__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing baby object runtime stage');
|
||||
}
|
||||
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
dragHand(stage, 2);
|
||||
|
||||
expect(screen.getByText('再想一想吧')).toBeTruthy();
|
||||
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('再想一想吧')).toBeNull();
|
||||
expect(
|
||||
within(screen.getByTestId('baby-object-current-item')).getByAltText(
|
||||
'苹果',
|
||||
),
|
||||
).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('twenty correct placements completes the level', () => {
|
||||
vi.useFakeTimers();
|
||||
const randomValues = Array.from({ length: 40 }, () => 0);
|
||||
const { container } = render(
|
||||
<BabyObjectMatchRuntimeShell
|
||||
draft={createDraft()}
|
||||
random={createRandomSequence(randomValues)}
|
||||
/>,
|
||||
);
|
||||
const stage = container.querySelector('.baby-object-runtime__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing baby object runtime stage');
|
||||
}
|
||||
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
|
||||
dragHand(stage, 0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800);
|
||||
});
|
||||
}
|
||||
|
||||
expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: '再来一次' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '下一关' })).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -0,0 +1,583 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Gift,
|
||||
PartyPopper,
|
||||
RotateCcw,
|
||||
SkipForward,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
BabyObjectMatchItemAsset,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
MocapHandInput,
|
||||
MocapInputCommand,
|
||||
UseMocapInputResult,
|
||||
} from '../../services/useMocapInput';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
const BABY_OBJECT_MATCH_SUCCESS_TARGET = 20;
|
||||
const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 760;
|
||||
const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05;
|
||||
const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16;
|
||||
|
||||
type BabyObjectMatchRuntimeShellProps = {
|
||||
draft: BabyObjectMatchDraft;
|
||||
embedded?: boolean;
|
||||
enableMocapInput?: boolean;
|
||||
mocapInput?: UseMocapInputResult | null;
|
||||
random?: BabyObjectMatchRandom;
|
||||
onBack?: () => void;
|
||||
onNextLevel?: () => void;
|
||||
};
|
||||
|
||||
type BasketSide = 'left' | 'right';
|
||||
type RuntimePhase = 'waiting' | 'active' | 'correct' | 'wrong' | 'complete';
|
||||
|
||||
type RuntimeRound = {
|
||||
item: BabyObjectMatchItemAsset;
|
||||
baskets: Record<BasketSide, BabyObjectMatchItemAsset>;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
side: BasketSide;
|
||||
startX: number;
|
||||
lastX: number;
|
||||
};
|
||||
|
||||
type RuntimeHandPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type RuntimeMocapHandPaths = {
|
||||
left: RuntimeHandPoint[];
|
||||
right: RuntimeHandPoint[];
|
||||
};
|
||||
|
||||
type BabyObjectMatchRandom = () => number;
|
||||
|
||||
const OPEN_PALM_ACTIONS = [
|
||||
'open_palm',
|
||||
'open_palm_up',
|
||||
'open',
|
||||
'palm',
|
||||
'hand_open',
|
||||
];
|
||||
|
||||
const GRAB_ACTIONS = [
|
||||
'grab',
|
||||
'grabbing',
|
||||
'close',
|
||||
'fist',
|
||||
'closed_fist',
|
||||
'closed',
|
||||
];
|
||||
|
||||
function pickRandomIndex(length: number, random: BabyObjectMatchRandom) {
|
||||
if (length <= 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(length - 1, Math.floor(random() * length));
|
||||
}
|
||||
|
||||
function buildRuntimeRound(
|
||||
draft: BabyObjectMatchDraft,
|
||||
random: BabyObjectMatchRandom,
|
||||
): RuntimeRound {
|
||||
const items = draft.itemAssets;
|
||||
const item = items[pickRandomIndex(items.length, random)] ?? items[0]!;
|
||||
|
||||
return {
|
||||
item,
|
||||
baskets: {
|
||||
left: items[0]!,
|
||||
right: items[1]!,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isHorizontalDrag(dragState: DragState) {
|
||||
return (
|
||||
Math.abs(dragState.lastX - dragState.startX) >=
|
||||
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
function hasMocapAction(command: MocapInputCommand, actions: string[]) {
|
||||
return command.actions.some((action) => actions.includes(action));
|
||||
}
|
||||
|
||||
function mocapHandToRuntimePoint(
|
||||
hand: MocapHandInput | null | undefined,
|
||||
): RuntimeHandPoint | null {
|
||||
if (!hand) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x: hand.x, y: hand.y };
|
||||
}
|
||||
|
||||
function appendRuntimeHandPoint(
|
||||
points: RuntimeHandPoint[],
|
||||
point: RuntimeHandPoint,
|
||||
) {
|
||||
return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT);
|
||||
}
|
||||
|
||||
function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) {
|
||||
if (points.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const xValues = points.map((point) => point.x);
|
||||
return (
|
||||
Math.max(...xValues) - Math.min(...xValues) >=
|
||||
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
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 hasOpenPalmMocapHand(command: MocapInputCommand) {
|
||||
return (
|
||||
hasMocapAction(command, OPEN_PALM_ACTIONS) ||
|
||||
Boolean(command.hands?.some((hand) => hand.state === 'open_palm')) ||
|
||||
command.leftHand?.state === 'open_palm' ||
|
||||
command.rightHand?.state === 'open_palm' ||
|
||||
command.primaryHand?.state === 'open_palm'
|
||||
);
|
||||
}
|
||||
|
||||
function hasGrabMocapHand(command: MocapInputCommand) {
|
||||
return (
|
||||
hasMocapAction(command, GRAB_ACTIONS) ||
|
||||
Boolean(command.hands?.some((hand) => hand.state === 'grab')) ||
|
||||
command.leftHand?.state === 'grab' ||
|
||||
command.rightHand?.state === 'grab' ||
|
||||
command.primaryHand?.state === 'grab'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMocapHorizontalMoveSide(
|
||||
paths: RuntimeMocapHandPaths,
|
||||
): BasketSide | null {
|
||||
if (hasRuntimeHorizontalMovePath(paths.left)) {
|
||||
return 'left';
|
||||
}
|
||||
|
||||
if (hasRuntimeHorizontalMovePath(paths.right)) {
|
||||
return 'right';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildMocapPacketKey(
|
||||
command: MocapInputCommand,
|
||||
rawPacketPreview: UseMocapInputResult['rawPacketPreview'],
|
||||
) {
|
||||
return rawPacketPreview?.receivedAtMs !== undefined
|
||||
? `${rawPacketPreview.receivedAtMs}:${rawPacketPreview.text}`
|
||||
: JSON.stringify(command);
|
||||
}
|
||||
|
||||
export function BabyObjectMatchRuntimeShell({
|
||||
draft,
|
||||
embedded = false,
|
||||
enableMocapInput = true,
|
||||
mocapInput = null,
|
||||
random,
|
||||
onBack,
|
||||
onNextLevel,
|
||||
}: BabyObjectMatchRuntimeShellProps) {
|
||||
const randomRef = useRef<BabyObjectMatchRandom>(random ?? (() => Math.random()));
|
||||
const feedbackTimerRef = useRef<number | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const handledMocapPacketKeyRef = useRef<string | null>(null);
|
||||
const hasOpenPalmBeforeGrabRef = useRef(false);
|
||||
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
|
||||
left: [],
|
||||
right: [],
|
||||
});
|
||||
const [phase, setPhase] = useState<RuntimePhase>('waiting');
|
||||
const [successCount, setSuccessCount] = useState(0);
|
||||
const [round, setRound] = useState<RuntimeRound | null>(null);
|
||||
const [feedbackText, setFeedbackText] = useState<string | null>(null);
|
||||
const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null);
|
||||
const liveMocapInput = useMocapInput({
|
||||
enabled: enableMocapInput && !mocapInput,
|
||||
});
|
||||
const resolvedMocapInput = mocapInput ?? liveMocapInput;
|
||||
|
||||
const progressText = `${successCount}/${BABY_OBJECT_MATCH_SUCCESS_TARGET}`;
|
||||
const isComplete = phase === 'complete';
|
||||
const currentItem = round?.item ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
randomRef.current = random ?? (() => Math.random());
|
||||
}, [random]);
|
||||
|
||||
const clearFeedbackTimer = useCallback(() => {
|
||||
if (feedbackTimerRef.current !== null) {
|
||||
window.clearTimeout(feedbackTimerRef.current);
|
||||
feedbackTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openGiftBox = useCallback(() => {
|
||||
if (phase !== 'waiting') {
|
||||
return;
|
||||
}
|
||||
|
||||
clearFeedbackTimer();
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
setRound(buildRuntimeRound(draft, randomRef.current));
|
||||
setPhase('active');
|
||||
}, [clearFeedbackTimer, draft, phase]);
|
||||
|
||||
const resetRuntime = useCallback(() => {
|
||||
clearFeedbackTimer();
|
||||
dragStateRef.current = null;
|
||||
handledMocapPacketKeyRef.current = null;
|
||||
hasOpenPalmBeforeGrabRef.current = false;
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
setSuccessCount(0);
|
||||
setRound(null);
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
setPhase('waiting');
|
||||
}, [clearFeedbackTimer]);
|
||||
|
||||
const finishFeedback = useCallback(
|
||||
(nextSuccessCount: number, wasCorrect: boolean) => {
|
||||
clearFeedbackTimer();
|
||||
feedbackTimerRef.current = window.setTimeout(() => {
|
||||
feedbackTimerRef.current = null;
|
||||
if (wasCorrect) {
|
||||
if (nextSuccessCount >= BABY_OBJECT_MATCH_SUCCESS_TARGET) {
|
||||
setFeedbackText('恭喜你!小朋友!');
|
||||
setRound(null);
|
||||
setPhase('complete');
|
||||
return;
|
||||
}
|
||||
|
||||
setRound(null);
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
setPhase('waiting');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedbackText(null);
|
||||
setLastTargetSide(null);
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
setPhase('active');
|
||||
}, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS);
|
||||
},
|
||||
[clearFeedbackTimer],
|
||||
);
|
||||
|
||||
const sendItemToBasket = useCallback(
|
||||
(side: BasketSide) => {
|
||||
if (phase !== 'active' || !round) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCorrect = round.baskets[side].itemId === round.item.itemId;
|
||||
setLastTargetSide(side);
|
||||
if (isCorrect) {
|
||||
const nextSuccessCount = successCount + 1;
|
||||
setSuccessCount(nextSuccessCount);
|
||||
setFeedbackText('真棒');
|
||||
setPhase('correct');
|
||||
finishFeedback(nextSuccessCount, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedbackText('再想一想吧');
|
||||
setPhase('wrong');
|
||||
finishFeedback(successCount, false);
|
||||
},
|
||||
[finishFeedback, phase, round, successCount],
|
||||
);
|
||||
|
||||
useEffect(() => clearFeedbackTimer, [clearFeedbackTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'waiting') {
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
return;
|
||||
}
|
||||
hasOpenPalmBeforeGrabRef.current = false;
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
const command = resolvedMocapInput.latestCommand;
|
||||
if (!command || isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packetKey = buildMocapPacketKey(
|
||||
command,
|
||||
resolvedMocapInput.rawPacketPreview,
|
||||
);
|
||||
if (handledMocapPacketKeyRef.current === packetKey) {
|
||||
return;
|
||||
}
|
||||
handledMocapPacketKeyRef.current = packetKey;
|
||||
|
||||
if (phase === 'waiting') {
|
||||
if (hasGrabMocapHand(command) && hasOpenPalmBeforeGrabRef.current) {
|
||||
hasOpenPalmBeforeGrabRef.current = false;
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
openGiftBox();
|
||||
return;
|
||||
}
|
||||
if (hasOpenPalmMocapHand(command)) {
|
||||
hasOpenPalmBeforeGrabRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase !== 'active') {
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPaths = resolveMocapHandPaths(
|
||||
command,
|
||||
mocapHandPathsRef.current,
|
||||
);
|
||||
mocapHandPathsRef.current = nextPaths;
|
||||
|
||||
const targetSide = resolveMocapHorizontalMoveSide(nextPaths);
|
||||
if (targetSide) {
|
||||
sendItemToBasket(targetSide);
|
||||
mocapHandPathsRef.current = { left: [], right: [] };
|
||||
}
|
||||
}, [
|
||||
isComplete,
|
||||
openGiftBox,
|
||||
phase,
|
||||
resolvedMocapInput.latestCommand,
|
||||
resolvedMocapInput.rawPacketPreview,
|
||||
sendItemToBasket,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'f') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
openGiftBox();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [openGiftBox]);
|
||||
|
||||
const getPointerUnitX = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
element: HTMLElement,
|
||||
) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const width = rect.width || 1;
|
||||
return Math.max(0, Math.min(1, (event.clientX - rect.left) / width));
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 && event.button !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const side: BasketSide = event.button === 2 ? 'right' : 'left';
|
||||
const pointerX = getPointerUnitX(event, event.currentTarget);
|
||||
dragStateRef.current = {
|
||||
side,
|
||||
startX: pointerX,
|
||||
lastX: pointerX,
|
||||
};
|
||||
event.preventDefault();
|
||||
if (typeof event.currentTarget.setPointerCapture === 'function') {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!dragStateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragStateRef.current = {
|
||||
...dragStateRef.current,
|
||||
lastX: getPointerUnitX(event, event.currentTarget),
|
||||
};
|
||||
};
|
||||
|
||||
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 (
|
||||
<main
|
||||
className={`baby-object-runtime${embedded ? ' baby-object-runtime--embedded' : ''}`}
|
||||
data-testid="baby-object-match-runtime"
|
||||
>
|
||||
<section
|
||||
className="baby-object-runtime__stage"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="baby-object-runtime__back"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="baby-object-runtime__subtitle" role="status">
|
||||
将物品放入对应的篮子里
|
||||
</div>
|
||||
|
||||
<div className="baby-object-runtime__counter" aria-label="成功次数">
|
||||
{progressText}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`baby-object-runtime__gift${phase === 'active' || phase === 'correct' || phase === 'wrong' ? ' baby-object-runtime__gift--open' : ''}`}
|
||||
aria-label="礼物盒"
|
||||
>
|
||||
<Gift className="baby-object-runtime__gift-icon" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`baby-object-runtime__item${
|
||||
phase === 'correct'
|
||||
? ` baby-object-runtime__item--to-${lastTargetSide ?? 'left'}`
|
||||
: phase === 'wrong'
|
||||
? ` baby-object-runtime__item--wrong-${lastTargetSide ?? 'left'}`
|
||||
: ''
|
||||
}`}
|
||||
data-testid="baby-object-current-item"
|
||||
aria-live="polite"
|
||||
>
|
||||
{currentItem ? (
|
||||
<>
|
||||
<ResolvedAssetImage
|
||||
src={currentItem.imageSrc}
|
||||
alt={currentItem.itemName}
|
||||
className="baby-object-runtime__item-image"
|
||||
/>
|
||||
<span className="baby-object-runtime__item-name">
|
||||
{currentItem.itemName}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{feedbackText ? (
|
||||
<div
|
||||
className={`baby-object-runtime__feedback baby-object-runtime__feedback--${phase}`}
|
||||
>
|
||||
{feedbackText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isComplete ? (
|
||||
<div className="baby-object-runtime__complete" role="dialog">
|
||||
<PartyPopper className="h-8 w-8" />
|
||||
<div>恭喜你!小朋友!</div>
|
||||
<div className="baby-object-runtime__complete-actions">
|
||||
<button type="button" onClick={resetRuntime}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
再来一次
|
||||
</button>
|
||||
<button type="button" onClick={onNextLevel}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
下一关
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="baby-object-runtime__baskets">
|
||||
{(['left', 'right'] as const).map((side) => {
|
||||
const basketItem = round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={side}
|
||||
className={`baby-object-runtime__basket baby-object-runtime__basket--${side}`}
|
||||
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>
|
||||
<div className="baby-object-runtime__basket-body" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default BabyObjectMatchRuntimeShell;
|
||||
@@ -21,6 +21,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onSelectPuzzle: () => void;
|
||||
onSelectCreativeAgent: () => void;
|
||||
onSelectVisualNovel: () => void;
|
||||
onSelectBabyObjectMatch: () => void;
|
||||
}
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
@@ -101,6 +102,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
onSelectPuzzle,
|
||||
onSelectCreativeAgent,
|
||||
onSelectVisualNovel,
|
||||
onSelectBabyObjectMatch,
|
||||
}: PlatformEntryCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({
|
||||
if (item.id === 'visual-novel') {
|
||||
onSelectVisualNovel();
|
||||
}
|
||||
if (item.id === 'baby-object-match') {
|
||||
onSelectBabyObjectMatch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -52,6 +57,31 @@ function createPuzzleEntry(): PlatformPuzzleGalleryCard {
|
||||
};
|
||||
}
|
||||
|
||||
function createBabyObjectMatchEntry(): PlatformEdutainmentGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '百梦主',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
playCount: 12,
|
||||
remixCount: 0,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T12:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -140,6 +170,23 @@ test('PlatformWorkDetailView switches remix action label for owned work edit', (
|
||||
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView labels baby object match works', () => {
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
entry={createBabyObjectMatchEntry()}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={vi.fn()}
|
||||
onLike={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onRemix={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('宝贝识物')).toBeTruthy();
|
||||
expect(screen.getByText('EDU-BABY01')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isEdutainmentGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
@@ -66,6 +67,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
|
||||
return '视觉小说';
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
filterEdutainmentPublicWorks,
|
||||
@@ -28,6 +32,27 @@ function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard {
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchCard(themeTags: string[]): PlatformPublicGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-education',
|
||||
authorDisplayName: '动作 Demo 作者',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: '宝贝识物',
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
@@ -56,4 +81,14 @@ describe('platformEdutainmentVisibility', () => {
|
||||
expect(canExposePublicWork(exact)).toBe(false);
|
||||
expect(canExposePublicWork(general)).toBe(true);
|
||||
});
|
||||
|
||||
test('applies the same exact tag rule to baby object match cards', () => {
|
||||
const exact = buildBabyObjectMatchCard(['寓教于乐', '宝贝识物']);
|
||||
const fuzzy = buildBabyObjectMatchCard(['寓教于乐 ', '宝贝识物']);
|
||||
|
||||
expect(isEdutainmentPublicWork(exact)).toBe(true);
|
||||
expect(isEdutainmentPublicWork(fuzzy)).toBe(false);
|
||||
expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]);
|
||||
expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
derivePlatformCreationTypes,
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
isPlatformCreationTypeVisible,
|
||||
} from './platformEntryCreationTypes';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test('database entry config controls visibility open state and display order', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
@@ -100,10 +104,9 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual([
|
||||
'open',
|
||||
'locked',
|
||||
]);
|
||||
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
|
||||
['open', 'locked'],
|
||||
);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
|
||||
expect(
|
||||
@@ -113,3 +116,65 @@ test('visible platform creation types hide invisible cards and put locked cards
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('edutainment switch hides baby object match creation entry from database config', () => {
|
||||
const cards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'baby-object-match',
|
||||
title: '宝贝识物',
|
||||
subtitle: '亲子识物分类',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/baby-object-match.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 1,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 2,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true);
|
||||
|
||||
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
|
||||
|
||||
const hiddenCards = derivePlatformCreationTypes([
|
||||
{
|
||||
id: 'baby-object-match',
|
||||
title: '宝贝识物',
|
||||
subtitle: '亲子识物分类',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/baby-object-match.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 1,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '拼图',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 2,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(isPlatformCreationTypeVisible(hiddenCards, 'baby-object-match')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
getVisiblePlatformCreationTypes(hiddenCards).map((item) => item.id),
|
||||
).toEqual(['puzzle']);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
|
||||
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
|
||||
|
||||
export type PlatformCreationTypeId = string;
|
||||
|
||||
@@ -46,7 +47,9 @@ export function derivePlatformCreationTypes(
|
||||
badge: item.badge,
|
||||
imageSrc: item.imageSrc,
|
||||
locked: !item.open,
|
||||
hidden: !item.visible,
|
||||
hidden:
|
||||
!item.visible ||
|
||||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
|
||||
}));
|
||||
|
||||
return [
|
||||
|
||||
@@ -37,6 +37,10 @@ export type SelectionStage =
|
||||
| 'visual-novel-result'
|
||||
| 'visual-novel-gallery-detail'
|
||||
| 'visual-novel-runtime'
|
||||
| 'baby-object-match-workspace'
|
||||
| 'baby-object-match-generating'
|
||||
| 'baby-object-match-result'
|
||||
| 'baby-object-match-runtime'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-onboarding'
|
||||
|
||||
@@ -56,13 +56,7 @@ import {
|
||||
streamCreativeDraftEdit,
|
||||
} from '../../services/creative-agent';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
|
||||
import {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
@@ -445,14 +439,30 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
|
||||
preloadMatch3DGeneratedModelAssets: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', () => ({
|
||||
clickMatch3DItem: vi.fn(),
|
||||
finishMatch3DTimeUp: vi.fn(),
|
||||
restartMatch3DRun: vi.fn(),
|
||||
startMatch3DRun: vi.fn(),
|
||||
stopMatch3DRun: vi.fn(),
|
||||
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
|
||||
createServerMatch3DRuntimeAdapter: vi.fn(),
|
||||
}));
|
||||
|
||||
const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
|
||||
clickItem: vi.fn(),
|
||||
finishTimeUp: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
restartRun: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/match3d-runtime')
|
||||
>('../../services/match3d-runtime');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
...match3dRuntimeServiceMocks,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/square-hole-creation', () => ({
|
||||
squareHoleCreationClient: {
|
||||
createSession: vi.fn(),
|
||||
@@ -1608,6 +1618,24 @@ function TestWrapper({
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
|
||||
match3dServerRuntimeAdapterMock,
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
|
||||
new Error('未启动抓大鹅运行态'),
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.clickItem.mockRejectedValue(
|
||||
new Error('未执行抓大鹅点击'),
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.restartRun.mockRejectedValue(
|
||||
new Error('未重新开始抓大鹅运行态'),
|
||||
);
|
||||
match3dServerRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-time-up'),
|
||||
});
|
||||
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||||
});
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
@@ -2235,17 +2263,6 @@ beforeEach(() => {
|
||||
vi.mocked(deleteMatch3DWork).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockRejectedValue(new Error('未启动抓大鹅运行态'));
|
||||
vi.mocked(clickMatch3DItem).mockRejectedValue(new Error('未执行抓大鹅点击'));
|
||||
vi.mocked(restartMatch3DRun).mockRejectedValue(
|
||||
new Error('未重新开始抓大鹅运行态'),
|
||||
);
|
||||
vi.mocked(finishMatch3DTimeUp).mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-time-up'),
|
||||
});
|
||||
vi.mocked(stopMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||||
});
|
||||
vi.mocked(squareHoleCreationClient.createSession).mockResolvedValue({
|
||||
session: buildMockSquareHoleAgentSession(),
|
||||
});
|
||||
@@ -2722,7 +2739,7 @@ test('match3d result trial passes generated models into first runtime mount', as
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDraftWork,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dDraftWork.profileId),
|
||||
});
|
||||
|
||||
@@ -2736,7 +2753,10 @@ test('match3d result trial passes generated models into first runtime mount', as
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-draft-1');
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-draft-1',
|
||||
{},
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
@@ -2805,7 +2825,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: generatedProfile,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(generatedProfile.profileId),
|
||||
});
|
||||
|
||||
@@ -2818,7 +2838,9 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-auto-1');
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-auto-1',
|
||||
);
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
@@ -3874,14 +3896,14 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDetail,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dCard.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith(
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-card-1',
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
@@ -3951,7 +3973,7 @@ test('home recommendation Match3D runtime refetches detail when stale card only
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDetail,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dCard.profileId),
|
||||
});
|
||||
|
||||
@@ -5076,7 +5098,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dWork,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
|
||||
@@ -5093,7 +5115,10 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-public-1',
|
||||
{},
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText(
|
||||
@@ -5147,7 +5172,7 @@ test('published Match3D runtime receives persisted generated models', async () =
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dWork],
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
|
||||
@@ -5161,6 +5186,12 @@ test('published Match3D runtime receives persisted generated models', async () =
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
|
||||
'match3d-profile-model-1',
|
||||
{},
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
|
||||
@@ -29,9 +29,12 @@ import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
} from './RpgEntryHomeView';
|
||||
import type {
|
||||
PlatformPublicGalleryCard,
|
||||
PlatformPuzzleGalleryCard,
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
const {
|
||||
@@ -449,6 +452,37 @@ function buildTaggedPuzzleEntry(
|
||||
} satisfies PlatformPuzzleGalleryCard;
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchEntry(
|
||||
id: string,
|
||||
worldName: string,
|
||||
themeTags: string[] = ['寓教于乐'],
|
||||
overrides: Partial<PlatformEdutainmentGalleryCard> = {},
|
||||
) {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: `baby-object-match-work-${id}`,
|
||||
profileId: `baby-object-match-profile-${id}`,
|
||||
publicWorkCode: `EDU-${id.toUpperCase()}`,
|
||||
ownerUserId: 'user-edutainment',
|
||||
authorDisplayName: '动作 Demo 作者',
|
||||
worldName,
|
||||
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags,
|
||||
playCount: 8,
|
||||
remixCount: 0,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 5,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T10:00:00.000Z',
|
||||
...overrides,
|
||||
} satisfies PlatformEdutainmentGalleryCard;
|
||||
}
|
||||
|
||||
function mockDesktopLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
@@ -1353,6 +1387,49 @@ test('mobile discover hides edutainment channel and work when switch is disabled
|
||||
expect(onSearchPublicCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile discover keeps baby object match works in edutainment channel only', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
const babyObjectMatchEntry = buildBabyObjectMatchEntry(
|
||||
'baby01',
|
||||
'宝贝识物水果篮',
|
||||
);
|
||||
const generalEntry = buildTaggedPuzzleEntry('normal02', '普通拼图作品', [
|
||||
'儿童教育',
|
||||
]);
|
||||
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [babyObjectMatchEntry, generalEntry],
|
||||
onOpenGalleryDetail,
|
||||
onSearchPublicCode,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
const discoverPanel = document.getElementById('platform-tab-panel-category');
|
||||
if (!discoverPanel) {
|
||||
throw new Error('缺少发现面板');
|
||||
}
|
||||
|
||||
expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
|
||||
const babyObjectMatchButton = within(discoverPanel).getByRole('button', {
|
||||
name: /宝贝识物水果篮/u,
|
||||
});
|
||||
expect(within(babyObjectMatchButton).getByText('宝贝识物')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();
|
||||
|
||||
await user.click(babyObjectMatchButton);
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, '宝贝识物水果篮{enter}');
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||
expect(onSearchPublicCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('discover search keeps public code fallback when local works do not match', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
formatPlatformWorkDisplayTag,
|
||||
formatPlatformWorldTime,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
@@ -1204,7 +1205,9 @@ function DesktopTrendingItem({
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePublicGalleryCardKind(entry)}
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePublicGalleryCardKind(entry)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1521,7 +1524,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: 'rpg';
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
@@ -1633,7 +1638,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
? '方洞'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? '视觉'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? entry.templateName
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPuzzleWorkCoverSlides,
|
||||
buildPlatformWorldDisplayTags,
|
||||
buildPuzzleWorkCoverSlides,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isEdutainmentGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
mapBabyObjectMatchDraftToPlatformGalleryCard,
|
||||
mapVisualNovelWorkToPlatformGalleryCard,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
@@ -132,3 +137,73 @@ test('maps visual novel work to platform gallery card with VN public code', () =
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
|
||||
});
|
||||
|
||||
test('keeps baby object match public card code and template label intact', () => {
|
||||
const card: PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
sourceSessionId: 'baby-object-match-session-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '百梦主',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
playCount: 3,
|
||||
remixCount: 0,
|
||||
likeCount: 1,
|
||||
recentPlayCount7d: 3,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T10:00:00.000Z',
|
||||
};
|
||||
|
||||
expect(isEdutainmentGalleryEntry(card)).toBe(true);
|
||||
expect(resolvePlatformPublicWorkCode(card)).toBe('EDU-BABY01');
|
||||
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['寓教于乐']);
|
||||
});
|
||||
|
||||
test('maps baby object match draft to edutainment public card', () => {
|
||||
const card = mapBabyObjectMatchDraftToPlatformGalleryCard({
|
||||
draftId: 'baby-object-draft-1',
|
||||
profileId: 'baby-object-profile-12345678',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物水果篮',
|
||||
workDescription: '苹果和香蕉识物分类',
|
||||
itemNames: ['苹果', '香蕉'],
|
||||
itemAssets: [
|
||||
{
|
||||
itemId: 'baby-object-item-1',
|
||||
itemName: '苹果',
|
||||
imageSrc: '/apple.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '苹果',
|
||||
},
|
||||
{
|
||||
itemId: 'baby-object-item-2',
|
||||
itemName: '香蕉',
|
||||
imageSrc: '/banana.png',
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: '香蕉',
|
||||
},
|
||||
],
|
||||
themeTags: ['寓教于乐', '宝贝识物'],
|
||||
publicationStatus: 'published',
|
||||
createdAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T12:00:00.000Z',
|
||||
publishedAt: '2026-05-11T12:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(isEdutainmentGalleryEntry(card)).toBe(true);
|
||||
expect(card.publicWorkCode).toBe('BO-12345678');
|
||||
expect(card.coverImageSrc).toBe('/apple.png');
|
||||
expect(card.themeTags[0]).toBe('寓教于乐');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkSummary,
|
||||
@@ -18,6 +20,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
@@ -28,6 +31,8 @@ import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
|
||||
export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
|
||||
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
|
||||
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
@@ -36,7 +41,8 @@ export type PlatformWorldCardLike =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard;
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle';
|
||||
@@ -164,13 +170,38 @@ export type PlatformVisualNovelGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment';
|
||||
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
|
||||
templateName: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME;
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformVisualNovelGalleryCard;
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
@@ -208,6 +239,12 @@ export function isVisualNovelGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
|
||||
}
|
||||
|
||||
export function isEdutainmentGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformEdutainmentGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'edutainment';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -286,8 +323,7 @@ export function mapSquareHoleWorkToPlatformGalleryCard(
|
||||
holeOptions: work.holeOptions,
|
||||
shapeCount: work.shapeCount,
|
||||
difficulty: work.difficulty,
|
||||
themeTags:
|
||||
work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
|
||||
themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
@@ -349,6 +385,40 @@ export function mapVisualNovelWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): PlatformEdutainmentGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
sourceSessionId: draft.draftId,
|
||||
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
|
||||
ownerUserId: 'current-user',
|
||||
authorDisplayName: '百梦主',
|
||||
worldName: draft.workTitle.trim() || draft.templateName,
|
||||
subtitle: draft.templateName,
|
||||
summaryText:
|
||||
draft.workDescription.trim() ||
|
||||
`${draft.itemNames[0]}和${draft.itemNames[1]}识物分类`,
|
||||
coverImageSrc:
|
||||
draft.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null,
|
||||
themeTags:
|
||||
draft.themeTags.length > 0
|
||||
? draft.themeTags
|
||||
: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG],
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: draft.publishedAt,
|
||||
updatedAt: draft.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
|
||||
return {
|
||||
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
|
||||
@@ -488,9 +558,7 @@ export function formatPlatformWorkDisplayTags(
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
tags
|
||||
.map((tag) => formatPlatformWorkDisplayTag(tag))
|
||||
.filter(Boolean),
|
||||
tags.map((tag) => formatPlatformWorkDisplayTag(tag)).filter(Boolean),
|
||||
),
|
||||
].slice(0, limit);
|
||||
}
|
||||
@@ -512,13 +580,13 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅'];
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['抓大鹅'];
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['方洞'];
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['方洞'];
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
@@ -527,6 +595,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
: ['视觉小说'];
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: [entry.templateName];
|
||||
}
|
||||
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
@@ -613,6 +687,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
|
||||
25
src/games/bark-battle/application/BarkBattleConfig.ts
Normal file
25
src/games/bark-battle/application/BarkBattleConfig.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type BarkBattleConfig = {
|
||||
roundDurationMs: number;
|
||||
countdownMs: number;
|
||||
drawThreshold: number;
|
||||
barkThreshold: number;
|
||||
minBarkGapMs: number;
|
||||
minBarkDurationMs: number;
|
||||
maxBarkDurationMs: number;
|
||||
balanceFactor: number;
|
||||
calibrationMaxWaitMs: number;
|
||||
opponentBasePower: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_BARK_BATTLE_CONFIG: BarkBattleConfig = {
|
||||
roundDurationMs: 30_000,
|
||||
countdownMs: 3_000,
|
||||
drawThreshold: 12,
|
||||
barkThreshold: 0.5,
|
||||
minBarkGapMs: 300,
|
||||
minBarkDurationMs: 90,
|
||||
maxBarkDurationMs: 900,
|
||||
balanceFactor: 32,
|
||||
calibrationMaxWaitMs: 4_000,
|
||||
opponentBasePower: 0.22,
|
||||
};
|
||||
92
src/games/bark-battle/application/BarkBattleController.ts
Normal file
92
src/games/bark-battle/application/BarkBattleController.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { type BarkBattleSession, createBarkBattleSession } from '../domain/BarkBattleSession';
|
||||
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
|
||||
import { BarkDetector } from '../domain/BarkDetector';
|
||||
import type { BarkBattleConfig } from './BarkBattleConfig';
|
||||
|
||||
export class BarkBattleController {
|
||||
private session: BarkBattleSession;
|
||||
private detector: BarkDetector;
|
||||
private sampleClockMs = 0;
|
||||
|
||||
constructor(private config: BarkBattleConfig) {
|
||||
this.session = createBarkBattleSession(config);
|
||||
this.detector = this.createDetector();
|
||||
}
|
||||
|
||||
getSnapshot() {
|
||||
return this.session.snapshot;
|
||||
}
|
||||
|
||||
getSampleClockMs() {
|
||||
return this.sampleClockMs;
|
||||
}
|
||||
|
||||
updateConfig(config: BarkBattleConfig) {
|
||||
this.config = config;
|
||||
this.restart();
|
||||
}
|
||||
|
||||
finishNow() {
|
||||
if (this.session.snapshot.phase !== 'playing') {
|
||||
this.session = this.session.startMockRound();
|
||||
}
|
||||
if (this.session.snapshot.phase === 'countdown') {
|
||||
this.session = this.session.tick(this.session.snapshot.countdownMs);
|
||||
}
|
||||
this.session = this.session.tick(this.session.snapshot.remainingMs + 1);
|
||||
}
|
||||
|
||||
startWithMockInput() {
|
||||
this.session = createBarkBattleSession(this.config).startMockRound();
|
||||
this.detector = this.createDetector();
|
||||
this.sampleClockMs = 0;
|
||||
}
|
||||
|
||||
forcePlayerBark(volume = 0.9) {
|
||||
if (this.session.snapshot.phase !== 'playing') {
|
||||
this.session = this.session.startMockRound();
|
||||
}
|
||||
if (this.session.snapshot.phase === 'countdown') {
|
||||
this.session = this.session.tick(this.session.snapshot.countdownMs);
|
||||
}
|
||||
this.session = this.session.applyPlayerBark({
|
||||
side: 'player',
|
||||
atMs: this.sampleClockMs,
|
||||
peakVolume: volume,
|
||||
durationMs: this.config.minBarkDurationMs,
|
||||
});
|
||||
}
|
||||
|
||||
submitInputSample(volume: number, atMs = this.sampleClockMs) {
|
||||
const events = this.detector.acceptSample({ atMs, volume });
|
||||
for (const event of events) {
|
||||
this.session = this.session.applyPlayerBark(event);
|
||||
}
|
||||
}
|
||||
|
||||
submitMockSample(volume: number) {
|
||||
this.submitInputSample(volume);
|
||||
}
|
||||
|
||||
tick(deltaMs: number) {
|
||||
this.sampleClockMs += deltaMs;
|
||||
this.session = this.session.tick(deltaMs);
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.session = createBarkBattleSession(this.config);
|
||||
this.detector = this.createDetector();
|
||||
this.sampleClockMs = 0;
|
||||
}
|
||||
|
||||
failMicrophone(reason: MicrophoneFailureReason) {
|
||||
this.session = this.session.failMicrophone(reason);
|
||||
}
|
||||
|
||||
private createDetector() {
|
||||
return new BarkDetector({
|
||||
threshold: this.config.barkThreshold,
|
||||
minBarkGapMs: this.config.minBarkGapMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_BARK_BATTLE_CONFIG } from '../BarkBattleConfig';
|
||||
import { BarkBattleController } from '../BarkBattleController';
|
||||
|
||||
describe('BarkBattleController', () => {
|
||||
it('mock 模式可跑通完整一局并生成结算', () => {
|
||||
const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1200, countdownMs: 300 });
|
||||
|
||||
controller.startWithMockInput();
|
||||
controller.tick(300);
|
||||
expect(controller.getSnapshot().phase).toBe('playing');
|
||||
|
||||
controller.submitMockSample(0.92);
|
||||
controller.tick(160);
|
||||
controller.submitMockSample(0.12);
|
||||
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(1);
|
||||
expect(controller.getSnapshot().energy).toBeGreaterThan(0);
|
||||
|
||||
controller.tick(1200);
|
||||
expect(controller.getSnapshot().phase).toBe('finished');
|
||||
expect(controller.getSnapshot().result?.winner).toBe('player');
|
||||
});
|
||||
|
||||
it('麦克风失败时进入 unavailable 且不会进入 playing', () => {
|
||||
const controller = new BarkBattleController(DEFAULT_BARK_BATTLE_CONFIG);
|
||||
|
||||
controller.failMicrophone('permission-denied');
|
||||
controller.tick(5000);
|
||||
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
phase: 'unavailable',
|
||||
uiState: 'microphone-unavailable',
|
||||
errorReason: 'permission-denied',
|
||||
statusMessageKey: 'microphone-permission-denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('restart 会重置上一局计数、能量和结果', () => {
|
||||
const controller = new BarkBattleController({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 });
|
||||
|
||||
controller.startWithMockInput();
|
||||
controller.tick(1);
|
||||
controller.submitMockSample(1);
|
||||
controller.tick(120);
|
||||
controller.submitMockSample(0.1);
|
||||
controller.tick(2);
|
||||
expect(controller.getSnapshot().result).not.toBeNull();
|
||||
|
||||
controller.restart();
|
||||
expect(controller.getSnapshot().phase).toBe('permission');
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(0);
|
||||
expect(controller.getSnapshot().energy).toBe(0);
|
||||
expect(controller.getSnapshot().result).toBeNull();
|
||||
});
|
||||
|
||||
it('真实输入采样可使用高精度采样时间戳连续触发 100ms 级别叫声', () => {
|
||||
const controller = new BarkBattleController({
|
||||
...DEFAULT_BARK_BATTLE_CONFIG,
|
||||
countdownMs: 0,
|
||||
barkThreshold: 0.5,
|
||||
minBarkDurationMs: 40,
|
||||
minBarkGapMs: 100,
|
||||
});
|
||||
|
||||
controller.startWithMockInput();
|
||||
controller.submitInputSample(0.82, 0);
|
||||
controller.submitInputSample(0.1, 60);
|
||||
controller.submitInputSample(0.9, 120);
|
||||
controller.submitInputSample(0.1, 180);
|
||||
|
||||
expect(controller.getSnapshot().player.barkCount).toBe(2);
|
||||
});
|
||||
});
|
||||
30
src/games/bark-battle/domain/BarkBattleScoring.ts
Normal file
30
src/games/bark-battle/domain/BarkBattleScoring.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { BarkBattleResult, BarkBattleWinner } from './BarkBattleTypes';
|
||||
|
||||
export function decideBarkBattleWinner(
|
||||
energy: number,
|
||||
drawThreshold: number,
|
||||
): BarkBattleWinner {
|
||||
if (energy > drawThreshold) {
|
||||
return 'player';
|
||||
}
|
||||
if (energy < -drawThreshold) {
|
||||
return 'opponent';
|
||||
}
|
||||
return 'draw';
|
||||
}
|
||||
|
||||
export function buildBarkBattleResult(input: {
|
||||
energy: number;
|
||||
drawThreshold: number;
|
||||
playerBarkCount: number;
|
||||
opponentBarkCount: number;
|
||||
}): BarkBattleResult {
|
||||
const winner = decideBarkBattleWinner(input.energy, input.drawThreshold);
|
||||
return {
|
||||
winner,
|
||||
playerBarkCount: input.playerBarkCount,
|
||||
opponentBarkCount: input.opponentBarkCount,
|
||||
finalEnergy: input.energy,
|
||||
score: Math.max(0, Math.round(input.energy + input.playerBarkCount * 120)),
|
||||
};
|
||||
}
|
||||
154
src/games/bark-battle/domain/BarkBattleSession.ts
Normal file
154
src/games/bark-battle/domain/BarkBattleSession.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { BarkBattleConfig } from '../application/BarkBattleConfig';
|
||||
import { buildBarkBattleResult } from './BarkBattleScoring';
|
||||
import type { BarkBattleEvent, BarkBattleSnapshot } from './BarkBattleTypes';
|
||||
import { advanceEnergy, clampEnergy } from './EnergyTugOfWar';
|
||||
import { computeOpponentPower } from './OpponentStrategy';
|
||||
|
||||
export class BarkBattleSession {
|
||||
constructor(
|
||||
private readonly config: BarkBattleConfig,
|
||||
readonly snapshot: BarkBattleSnapshot,
|
||||
) {}
|
||||
|
||||
startMockRound() {
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
phase: this.config.countdownMs > 0 ? 'countdown' : 'playing',
|
||||
uiState: this.config.countdownMs > 0 ? 'ready-countdown' : 'playing',
|
||||
countdownMs: this.config.countdownMs,
|
||||
remainingMs: this.config.roundDurationMs,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
|
||||
tick(deltaMs: number) {
|
||||
if (this.snapshot.phase === 'finished' || this.snapshot.phase === 'unavailable') {
|
||||
return this.withEvents([]);
|
||||
}
|
||||
|
||||
if (this.snapshot.phase === 'countdown') {
|
||||
const countdownMs = Math.max(0, this.snapshot.countdownMs - deltaMs);
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
phase: countdownMs <= 0 ? 'playing' : 'countdown',
|
||||
uiState: countdownMs <= 0 ? 'playing' : 'ready-countdown',
|
||||
countdownMs,
|
||||
remainingMs: this.config.roundDurationMs,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (this.snapshot.phase !== 'playing') {
|
||||
return this.withEvents([]);
|
||||
}
|
||||
|
||||
const elapsedMs = this.snapshot.elapsedMs + deltaMs;
|
||||
const remainingMs = Math.max(0, this.snapshot.remainingMs - deltaMs);
|
||||
const opponentPower = computeOpponentPower(this.config, elapsedMs);
|
||||
const energy = advanceEnergy({
|
||||
energy: this.snapshot.energy,
|
||||
playerPower: this.snapshot.player.power,
|
||||
opponentPower,
|
||||
deltaMs,
|
||||
balanceFactor: this.config.balanceFactor,
|
||||
});
|
||||
const nextSnapshot: BarkBattleSnapshot = {
|
||||
...this.snapshot,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
energy,
|
||||
opponent: {
|
||||
...this.snapshot.opponent,
|
||||
power: opponentPower,
|
||||
},
|
||||
player: {
|
||||
...this.snapshot.player,
|
||||
power: Math.max(0, this.snapshot.player.power * 0.78),
|
||||
},
|
||||
lastEvents: [],
|
||||
};
|
||||
|
||||
if (remainingMs > 0) {
|
||||
return new BarkBattleSession(this.config, nextSnapshot);
|
||||
}
|
||||
|
||||
const result = buildBarkBattleResult({
|
||||
energy,
|
||||
drawThreshold: this.config.drawThreshold,
|
||||
playerBarkCount: nextSnapshot.player.barkCount,
|
||||
opponentBarkCount: nextSnapshot.opponent.barkCount,
|
||||
});
|
||||
return new BarkBattleSession(this.config, {
|
||||
...nextSnapshot,
|
||||
phase: 'finished',
|
||||
uiState: 'finished',
|
||||
winner: result.winner,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
applyPlayerBark(event: BarkBattleEvent) {
|
||||
if (this.snapshot.phase !== 'playing') {
|
||||
return this.withEvents([]);
|
||||
}
|
||||
|
||||
const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume));
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
energy: clampEnergy(this.snapshot.energy + event.peakVolume * 12),
|
||||
player: {
|
||||
barkCount: this.snapshot.player.barkCount + 1,
|
||||
power: playerPower,
|
||||
},
|
||||
lastEvents: [event],
|
||||
});
|
||||
}
|
||||
|
||||
failMicrophone(reason: BarkBattleSnapshot['errorReason']) {
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
phase: 'unavailable',
|
||||
uiState: 'microphone-unavailable',
|
||||
errorReason: reason,
|
||||
statusMessageKey: reason ? MICROPHONE_STATUS_KEYS[reason] : null,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
|
||||
private withEvents(lastEvents: BarkBattleEvent[]) {
|
||||
return new BarkBattleSession(this.config, {
|
||||
...this.snapshot,
|
||||
lastEvents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const MICROPHONE_STATUS_KEYS = {
|
||||
unsupported: 'microphone-unsupported',
|
||||
'permission-denied': 'microphone-permission-denied',
|
||||
'non-secure-context': 'microphone-non-secure-context',
|
||||
'not-found': 'microphone-not-found',
|
||||
'not-readable': 'microphone-not-readable',
|
||||
'audio-context-blocked': 'microphone-audio-context-blocked',
|
||||
'calibration-timeout': 'microphone-calibration-timeout',
|
||||
'calibration-sample-unreadable': 'microphone-calibration-sample-unreadable',
|
||||
unknown: 'microphone-unknown-error',
|
||||
} as const;
|
||||
|
||||
export function createBarkBattleSession(config: BarkBattleConfig) {
|
||||
return new BarkBattleSession(config, {
|
||||
phase: 'permission',
|
||||
uiState: 'permission-ready',
|
||||
errorReason: null,
|
||||
statusMessageKey: null,
|
||||
elapsedMs: 0,
|
||||
remainingMs: config.roundDurationMs,
|
||||
countdownMs: config.countdownMs,
|
||||
energy: 0,
|
||||
player: { barkCount: 0, power: 0 },
|
||||
opponent: { barkCount: 0, power: config.opponentBasePower },
|
||||
winner: null,
|
||||
result: null,
|
||||
lastEvents: [],
|
||||
});
|
||||
}
|
||||
84
src/games/bark-battle/domain/BarkBattleTypes.ts
Normal file
84
src/games/bark-battle/domain/BarkBattleTypes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export type BarkBattlePhase =
|
||||
| 'permission'
|
||||
| 'calibration'
|
||||
| 'countdown'
|
||||
| 'playing'
|
||||
| 'finished'
|
||||
| 'unavailable';
|
||||
|
||||
export type BarkBattleSide = 'player' | 'opponent';
|
||||
export type BarkBattleWinner = BarkBattleSide | 'draw' | null;
|
||||
export type BarkBattleDifficulty = 'easy' | 'normal' | 'hard';
|
||||
|
||||
export type BarkBattleUiState =
|
||||
| 'idle'
|
||||
| 'permission-ready'
|
||||
| 'microphone-authorized'
|
||||
| 'calibrating'
|
||||
| 'ready-countdown'
|
||||
| 'playing'
|
||||
| 'finished'
|
||||
| 'microphone-unavailable';
|
||||
|
||||
export type MicrophoneFailureReason =
|
||||
| 'unsupported'
|
||||
| 'permission-denied'
|
||||
| 'non-secure-context'
|
||||
| 'not-found'
|
||||
| 'not-readable'
|
||||
| 'audio-context-blocked'
|
||||
| 'calibration-timeout'
|
||||
| 'calibration-sample-unreadable'
|
||||
| 'unknown';
|
||||
|
||||
export type BarkBattleStatusMessageKey =
|
||||
| 'microphone-unsupported'
|
||||
| 'microphone-permission-denied'
|
||||
| 'microphone-non-secure-context'
|
||||
| 'microphone-not-found'
|
||||
| 'microphone-not-readable'
|
||||
| 'microphone-audio-context-blocked'
|
||||
| 'microphone-calibration-timeout'
|
||||
| 'microphone-calibration-sample-unreadable'
|
||||
| 'microphone-unknown-error';
|
||||
|
||||
export type BarkAudioSample = {
|
||||
atMs: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
export type BarkBattleEvent = {
|
||||
side: BarkBattleSide;
|
||||
atMs: number;
|
||||
peakVolume: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type BarkBattleParticipantState = {
|
||||
barkCount: number;
|
||||
power: number;
|
||||
};
|
||||
|
||||
export type BarkBattleResult = {
|
||||
winner: BarkBattleWinner;
|
||||
playerBarkCount: number;
|
||||
opponentBarkCount: number;
|
||||
finalEnergy: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type BarkBattleSnapshot = {
|
||||
phase: BarkBattlePhase;
|
||||
uiState: BarkBattleUiState;
|
||||
errorReason: MicrophoneFailureReason | null;
|
||||
statusMessageKey: BarkBattleStatusMessageKey | null;
|
||||
elapsedMs: number;
|
||||
remainingMs: number;
|
||||
countdownMs: number;
|
||||
energy: number;
|
||||
player: BarkBattleParticipantState;
|
||||
opponent: BarkBattleParticipantState;
|
||||
winner: BarkBattleWinner;
|
||||
result: BarkBattleResult | null;
|
||||
lastEvents: BarkBattleEvent[];
|
||||
};
|
||||
41
src/games/bark-battle/domain/BarkDetector.ts
Normal file
41
src/games/bark-battle/domain/BarkDetector.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { BarkAudioSample, BarkBattleEvent } from './BarkBattleTypes';
|
||||
|
||||
export type BarkDetectorConfig = {
|
||||
threshold: number;
|
||||
minBarkGapMs: number;
|
||||
};
|
||||
|
||||
export class BarkDetector {
|
||||
private lastAcceptedAtMs = Number.NEGATIVE_INFINITY;
|
||||
|
||||
constructor(private readonly config: BarkDetectorConfig) {}
|
||||
|
||||
acceptSample(sample: BarkAudioSample): BarkBattleEvent[] {
|
||||
const volume = clamp01(sample.volume);
|
||||
if (volume < this.config.threshold) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accepted = sample.atMs - this.lastAcceptedAtMs >= this.config.minBarkGapMs;
|
||||
if (!accepted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.lastAcceptedAtMs = sample.atMs;
|
||||
return [
|
||||
{
|
||||
side: 'player',
|
||||
atMs: sample.atMs,
|
||||
peakVolume: volume,
|
||||
durationMs: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function clamp01(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
20
src/games/bark-battle/domain/EnergyTugOfWar.ts
Normal file
20
src/games/bark-battle/domain/EnergyTugOfWar.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type AdvanceEnergyInput = {
|
||||
energy: number;
|
||||
playerPower: number;
|
||||
opponentPower: number;
|
||||
deltaMs: number;
|
||||
balanceFactor: number;
|
||||
};
|
||||
|
||||
export function advanceEnergy(input: AdvanceEnergyInput) {
|
||||
const deltaSeconds = Math.max(0, input.deltaMs) / 1000;
|
||||
const powerDelta = input.playerPower - input.opponentPower;
|
||||
return clampEnergy(input.energy + powerDelta * input.balanceFactor * deltaSeconds);
|
||||
}
|
||||
|
||||
export function clampEnergy(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, Math.max(-100, value));
|
||||
}
|
||||
6
src/games/bark-battle/domain/OpponentStrategy.ts
Normal file
6
src/games/bark-battle/domain/OpponentStrategy.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { BarkBattleConfig } from '../application/BarkBattleConfig';
|
||||
|
||||
export function computeOpponentPower(config: BarkBattleConfig, elapsedMs: number) {
|
||||
const pulse = 0.05 * Math.sin(elapsedMs / 480);
|
||||
return Math.min(1, Math.max(0, config.opponentBasePower + pulse));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
|
||||
import { decideBarkBattleWinner } from '../BarkBattleScoring';
|
||||
import { createBarkBattleSession } from '../BarkBattleSession';
|
||||
|
||||
describe('BarkBattleSession', () => {
|
||||
it('能从校准完成进入倒计时、playing 并在归零后结算', () => {
|
||||
let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1000, countdownMs: 600 });
|
||||
|
||||
expect(session.snapshot.phase).toBe('permission');
|
||||
session = session.startMockRound();
|
||||
expect(session.snapshot.phase).toBe('countdown');
|
||||
|
||||
session = session.tick(600);
|
||||
expect(session.snapshot.phase).toBe('playing');
|
||||
expect(session.snapshot.remainingMs).toBe(1000);
|
||||
|
||||
session = session.tick(400);
|
||||
expect(session.snapshot.remainingMs).toBe(600);
|
||||
|
||||
session = session.applyPlayerBark({ atMs: 700, peakVolume: 0.9, durationMs: 140, side: 'player' });
|
||||
expect(session.snapshot.player.barkCount).toBe(1);
|
||||
expect(session.snapshot.energy).toBeGreaterThan(0);
|
||||
|
||||
session = session.tick(600);
|
||||
expect(session.snapshot.phase).toBe('finished');
|
||||
expect(session.snapshot.result?.winner).toBe('player');
|
||||
});
|
||||
|
||||
it('finished 后输入不再改变本局叫声计数和能量', () => {
|
||||
let session = createBarkBattleSession({ ...DEFAULT_BARK_BATTLE_CONFIG, roundDurationMs: 1, countdownMs: 0 }).startMockRound().tick(1);
|
||||
session = session.tick(1);
|
||||
const before = session.snapshot;
|
||||
|
||||
session = session.applyPlayerBark({ atMs: 200, peakVolume: 1, durationMs: 120, side: 'player' });
|
||||
|
||||
expect(session.snapshot.player.barkCount).toBe(before.player.barkCount);
|
||||
expect(session.snapshot.energy).toBe(before.energy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideBarkBattleWinner', () => {
|
||||
it('按 drawThreshold 判定玩家胜、对手胜和平局', () => {
|
||||
expect(decideBarkBattleWinner(16, 12)).toBe('player');
|
||||
expect(decideBarkBattleWinner(-16, 12)).toBe('opponent');
|
||||
expect(decideBarkBattleWinner(8, 12)).toBe('draw');
|
||||
});
|
||||
});
|
||||
83
src/games/bark-battle/domain/__tests__/BarkDetector.test.ts
Normal file
83
src/games/bark-battle/domain/__tests__/BarkDetector.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
|
||||
import { BarkDetector } from '../BarkDetector';
|
||||
|
||||
describe('BarkDetector', () => {
|
||||
it('每个监测点只检测瞬时响度,超过阈值立即触发', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.45,
|
||||
minBarkGapMs: DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: 0.2 })).toEqual([]);
|
||||
const events = detector.acceptSample({ atMs: 40, volume: 0.72 });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ side: 'player', atMs: 40, peakVolume: 0.72, durationMs: 0 });
|
||||
});
|
||||
|
||||
it('持续噪音按冷却间隔触发,不需要等待响度回落', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.4,
|
||||
minBarkGapMs: 250,
|
||||
});
|
||||
|
||||
const allEvents = [
|
||||
...detector.acceptSample({ atMs: 0, volume: 0.7 }),
|
||||
...detector.acceptSample({ atMs: 100, volume: 0.72 }),
|
||||
...detector.acceptSample({ atMs: 200, volume: 0.73 }),
|
||||
...detector.acceptSample({ atMs: 300, volume: 0.75 }),
|
||||
...detector.acceptSample({ atMs: 500, volume: 0.2 }),
|
||||
...detector.acceptSample({ atMs: 560, volume: 0.76 }),
|
||||
];
|
||||
|
||||
expect(allEvents.map((event) => event.atMs)).toEqual([0, 300, 560]);
|
||||
});
|
||||
|
||||
it('低于阈值的背景噪音和冷却内峰值不计数,最短持续时长不再参与判断', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.5,
|
||||
minBarkGapMs: 300,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: 0.48 })).toEqual([]);
|
||||
expect(detector.acceptSample({ atMs: 20, volume: 0.9 })).toHaveLength(1);
|
||||
expect(detector.acceptSample({ atMs: 60, volume: 0.95 })).toEqual([]);
|
||||
|
||||
expect(detector.acceptSample({ atMs: 320, volume: 0.88 })).toHaveLength(1);
|
||||
expect(detector.acceptSample({ atMs: 420, volume: 0.2 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('支持 100ms 级别间隔的快速连续有效叫声', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.5,
|
||||
minBarkGapMs: 100,
|
||||
});
|
||||
|
||||
const allEvents = [
|
||||
...detector.acceptSample({ atMs: 0, volume: 0.86 }),
|
||||
...detector.acceptSample({ atMs: 60, volume: 0.9 }),
|
||||
...detector.acceptSample({ atMs: 120, volume: 0.91 }),
|
||||
...detector.acceptSample({ atMs: 180, volume: 0.92 }),
|
||||
...detector.acceptSample({ atMs: 240, volume: 0.93 }),
|
||||
];
|
||||
|
||||
expect(allEvents).toHaveLength(3);
|
||||
expect(allEvents.map((event) => event.atMs)).toEqual([0, 120, 240]);
|
||||
});
|
||||
|
||||
it('非有限音量会归零,超过 1 的音量会夹到 1', () => {
|
||||
const detector = new BarkDetector({
|
||||
threshold: 0.5,
|
||||
minBarkGapMs: 100,
|
||||
});
|
||||
|
||||
expect(detector.acceptSample({ atMs: 0, volume: Number.NaN })).toEqual([]);
|
||||
expect(detector.acceptSample({ atMs: 120, volume: Number.POSITIVE_INFINITY })).toEqual([]);
|
||||
const events = detector.acceptSample({ atMs: 240, volume: 2 });
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.peakVolume).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { advanceEnergy } from '../EnergyTugOfWar';
|
||||
|
||||
describe('advanceEnergy', () => {
|
||||
it('玩家推动力高于对手时能量增加', () => {
|
||||
expect(advanceEnergy({ energy: 0, playerPower: 0.8, opponentPower: 0.2, deltaMs: 1000, balanceFactor: 40 })).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('对手推动力高于玩家时能量减少', () => {
|
||||
expect(advanceEnergy({ energy: 0, playerPower: 0.1, opponentPower: 0.7, deltaMs: 1000, balanceFactor: 40 })).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('能量被限制在 -100 到 100 且双方相等时保持稳定', () => {
|
||||
expect(advanceEnergy({ energy: 98, playerPower: 1, opponentPower: 0, deltaMs: 2000, balanceFactor: 40 })).toBe(100);
|
||||
expect(advanceEnergy({ energy: -98, playerPower: 0, opponentPower: 1, deltaMs: 2000, balanceFactor: 40 })).toBe(-100);
|
||||
expect(advanceEnergy({ energy: 12, playerPower: 0.5, opponentPower: 0.5, deltaMs: 1000, balanceFactor: 40 })).toBeCloseTo(12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
|
||||
|
||||
export function mapGetUserMediaError(error: unknown): MicrophoneFailureReason {
|
||||
const name = error && typeof error === 'object' && 'name' in error ? String((error as { name?: unknown }).name) : '';
|
||||
if (name === 'NotAllowedError' || name === 'SecurityError') return 'permission-denied';
|
||||
if (name === 'NotFoundError' || name === 'DevicesNotFoundError') return 'not-found';
|
||||
if (name === 'NotReadableError' || name === 'TrackStartError') return 'not-readable';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function isMicrophoneApiSupported(windowLike: { isSecureContext?: boolean; navigator?: Navigator | { mediaDevices?: { getUserMedia?: unknown } } }) {
|
||||
if (windowLike.isSecureContext === false) {
|
||||
return { ok: false as const, reason: 'non-secure-context' as const };
|
||||
}
|
||||
const getUserMedia = windowLike.navigator?.mediaDevices?.getUserMedia;
|
||||
if (typeof getUserMedia !== 'function') {
|
||||
return { ok: false as const, reason: 'unsupported' as const };
|
||||
}
|
||||
return { ok: true as const, reason: null };
|
||||
}
|
||||
|
||||
export function stopMediaStreamTracks(stream: MediaStream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
export type BrowserMicrophoneSampler = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export type BrowserMicrophoneVolumeHandler = (volume: number, atMs: number) => void;
|
||||
|
||||
export async function startBrowserMicrophoneSampler(onVolume: BrowserMicrophoneVolumeHandler): Promise<BrowserMicrophoneSampler> {
|
||||
const supported = isMicrophoneApiSupported(window);
|
||||
if (!supported.ok) {
|
||||
throw Object.assign(new Error(supported.reason), { reason: supported.reason });
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
stopMediaStreamTracks(stream);
|
||||
throw Object.assign(new Error('audio-context-blocked'), { reason: 'audio-context-blocked' });
|
||||
}
|
||||
const audioContext = new AudioContextCtor();
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
source.connect(analyser);
|
||||
const data = new Uint8Array(analyser.fftSize);
|
||||
const sampleStartedAtMs = window.performance.now();
|
||||
let rafId = 0;
|
||||
const sample = () => {
|
||||
analyser.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (const value of data) {
|
||||
const centered = (value - 128) / 128;
|
||||
sum += centered * centered;
|
||||
}
|
||||
const volume = Math.min(1, Math.sqrt(sum / data.length) * 3.5);
|
||||
onVolume(volume, window.performance.now() - sampleStartedAtMs);
|
||||
rafId = window.requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
return {
|
||||
stop: () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
source.disconnect();
|
||||
void audioContext.close();
|
||||
stopMediaStreamTracks(stream);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error && typeof error === 'object' && 'reason' in error ? (error as { reason: MicrophoneFailureReason }).reason : mapGetUserMediaError(error);
|
||||
throw Object.assign(new Error(reason), { reason });
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isMicrophoneApiSupported, mapGetUserMediaError, stopMediaStreamTracks } from '../BrowserMicrophoneInput';
|
||||
|
||||
describe('BrowserMicrophoneInput', () => {
|
||||
it('区分非安全上下文和不支持 getUserMedia', () => {
|
||||
expect(isMicrophoneApiSupported({ isSecureContext: false })).toEqual({ ok: false, reason: 'non-secure-context' });
|
||||
expect(isMicrophoneApiSupported({ isSecureContext: true, navigator: {} })).toEqual({ ok: false, reason: 'unsupported' });
|
||||
});
|
||||
|
||||
it('映射常见 getUserMedia 错误', () => {
|
||||
expect(mapGetUserMediaError({ name: 'NotAllowedError' })).toBe('permission-denied');
|
||||
expect(mapGetUserMediaError({ name: 'NotFoundError' })).toBe('not-found');
|
||||
expect(mapGetUserMediaError({ name: 'NotReadableError' })).toBe('not-readable');
|
||||
expect(mapGetUserMediaError({ name: 'OtherError' })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('停止 MediaStream 的所有音轨', () => {
|
||||
const stopA = vi.fn();
|
||||
const stopB = vi.fn();
|
||||
stopMediaStreamTracks({ getTracks: () => [{ stop: stopA }, { stop: stopB }] } as unknown as MediaStream);
|
||||
expect(stopA).toHaveBeenCalledTimes(1);
|
||||
expect(stopB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
278
src/games/bark-battle/ui/BarkBattleHud.css
Normal file
278
src/games/bark-battle/ui/BarkBattleHud.css
Normal file
@@ -0,0 +1,278 @@
|
||||
.bark-battle-hud {
|
||||
min-height: 100svh;
|
||||
color: #fff7ed;
|
||||
background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: max(18px, env(safe-area-inset-top)) 16px max(18px, env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bark-battle-hud__topline {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bark-battle-hud__timer {
|
||||
justify-self: center;
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(15, 23, 42, 0.56);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.bark-battle-energy {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 247, 237, 0.78);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
}
|
||||
|
||||
.bark-battle-energy__side--player { background: linear-gradient(90deg, #f97316, #facc15); }
|
||||
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
|
||||
|
||||
.bark-battle-arena {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto 1fr;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.bark-battle-dog {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
animation: barkBattleDogPulse 420ms ease-out;
|
||||
}
|
||||
|
||||
.bark-battle-dog__body {
|
||||
font-size: clamp(92px, 30vw, 150px);
|
||||
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
|
||||
}
|
||||
|
||||
.bark-battle-dog--player .bark-battle-dog__body {
|
||||
transform: rotateY(180deg) translateY(4px);
|
||||
}
|
||||
|
||||
.bark-battle-dog__label,
|
||||
.bark-battle-dog__burst,
|
||||
.bark-battle-vs {
|
||||
font-weight: 900;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.bark-battle-dog__burst {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
color: #1f1147;
|
||||
background: #facc15;
|
||||
box-shadow: 0 0 22px rgba(250, 204, 21, 0.72);
|
||||
animation: barkBattleBurst 640ms ease-out both;
|
||||
}
|
||||
|
||||
.bark-battle-dog--opponent .bark-battle-dog__burst {
|
||||
color: #fff7ed;
|
||||
background: #7c3aed;
|
||||
box-shadow: 0 0 22px rgba(124, 58, 237, 0.72);
|
||||
}
|
||||
|
||||
.bark-battle-vs {
|
||||
border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.bark-battle-controls,
|
||||
.bark-battle-result__stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bark-battle-controls button,
|
||||
.bark-battle-primary-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
color: #1f1147;
|
||||
background: #fff7ed;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bark-battle-primary-button {
|
||||
background: linear-gradient(135deg, #facc15, #fb7185);
|
||||
}
|
||||
|
||||
.bark-battle-status-card,
|
||||
.bark-battle-result {
|
||||
margin: auto;
|
||||
width: min(92vw, 420px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 28px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
background: rgba(15, 23, 42, 0.68);
|
||||
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.bark-battle-result__stats span {
|
||||
min-width: 84px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bark-battle-result__stats strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.bark-battle-particles {
|
||||
position: absolute;
|
||||
inset: 18% 0 auto;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
font-size: clamp(30px, 10vw, 70px);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 247, 237, 0.88);
|
||||
text-shadow: 0 0 18px rgba(250, 204, 21, 0.75);
|
||||
animation: barkBattleParticlePop 820ms ease-out both;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
z-index: 8;
|
||||
width: min(78vw, 240px);
|
||||
max-height: 56px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 22px;
|
||||
padding: 10px 12px;
|
||||
color: #fff7ed;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel--expanded {
|
||||
width: min(92vw, 340px);
|
||||
max-height: 42svh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel header,
|
||||
.bark-battle-debug-panel label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel header {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__toggle {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
color: #1f1147;
|
||||
background: #facc15;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel--expanded .bark-battle-debug-panel__body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel label {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel output {
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics,
|
||||
.bark-battle-debug-events {
|
||||
margin: 10px 0 0;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-metrics__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.bark-battle-debug-events {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.bark-battle-debug-panel__controls button {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 8px 10px;
|
||||
color: #1f1147;
|
||||
background: #fff7ed;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@keyframes barkBattleDogPulse {
|
||||
from { transform: scale(1); }
|
||||
45% { transform: scale(1.08); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes barkBattleBurst {
|
||||
from { transform: translateY(18px) scale(0.72); opacity: 0; }
|
||||
35% { opacity: 1; }
|
||||
to { transform: translateY(-38px) scale(1.16); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes barkBattleParticlePop {
|
||||
from { transform: translateY(28px) scale(0.7); opacity: 0; }
|
||||
42% { opacity: 1; }
|
||||
to { transform: translateY(-80px) scale(1.14); opacity: 0; }
|
||||
}
|
||||
97
src/games/bark-battle/ui/BarkBattleHud.tsx
Normal file
97
src/games/bark-battle/ui/BarkBattleHud.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import './BarkBattleHud.css';
|
||||
|
||||
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
|
||||
|
||||
type BarkBattleHudProps = {
|
||||
snapshot: BarkBattleSnapshot;
|
||||
playerPulseKey?: number;
|
||||
opponentPulseKey?: number;
|
||||
onStartMicrophone?: () => void;
|
||||
onMockBark?: () => void;
|
||||
onMockQuiet?: () => void;
|
||||
onRestart?: () => void;
|
||||
};
|
||||
|
||||
const failureText = {
|
||||
unsupported: '当前浏览器不支持麦克风输入',
|
||||
'permission-denied': '麦克风授权被拒绝',
|
||||
'non-secure-context': '当前环境无法使用麦克风',
|
||||
'not-found': '未检测到麦克风',
|
||||
'not-readable': '麦克风暂时不可读',
|
||||
'audio-context-blocked': '音频上下文被拦截',
|
||||
'calibration-timeout': '校准超时',
|
||||
'calibration-sample-unreadable': '校准样本不可读',
|
||||
unknown: '麦克风暂时不可用',
|
||||
};
|
||||
|
||||
export function BarkBattleHud({
|
||||
snapshot,
|
||||
playerPulseKey = 0,
|
||||
opponentPulseKey = 0,
|
||||
onStartMicrophone,
|
||||
onMockBark,
|
||||
onMockQuiet,
|
||||
onRestart,
|
||||
}: BarkBattleHudProps) {
|
||||
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
|
||||
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
|
||||
const isUnavailable = snapshot.phase === 'unavailable';
|
||||
|
||||
return (
|
||||
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
|
||||
<header className="bark-battle-hud__topline">
|
||||
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
|
||||
<div
|
||||
className="bark-battle-energy"
|
||||
role="meter"
|
||||
aria-label="声浪能量条"
|
||||
aria-valuemin={-100}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(snapshot.energy)}
|
||||
>
|
||||
<div className="bark-battle-energy__side bark-battle-energy__side--player" data-testid="player-energy-fill" style={{ width: playerWidth }} />
|
||||
<div className="bark-battle-energy__side bark-battle-energy__side--opponent" data-testid="opponent-energy-fill" style={{ width: opponentWidth }} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isUnavailable ? (
|
||||
<div className="bark-battle-status-card">
|
||||
<h1>{snapshot.errorReason ? failureText[snapshot.errorReason] : '麦克风暂时不可用'}</h1>
|
||||
{snapshot.errorReason !== 'unsupported' ? (
|
||||
<button type="button" className="bark-battle-primary-button" onClick={onStartMicrophone}>
|
||||
{snapshot.errorReason === 'permission-denied' ? '重新授权' : '重试'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
|
||||
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">汪</span>
|
||||
<span className="bark-battle-dog__body">🐕</span>
|
||||
<span className="bark-battle-dog__label">你 · {snapshot.player.barkCount}</span>
|
||||
</div>
|
||||
<div className="bark-battle-vs">VS</div>
|
||||
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
|
||||
<span className="bark-battle-dog__burst" aria-hidden="true">反击</span>
|
||||
<span className="bark-battle-dog__body">🐶</span>
|
||||
<span className="bark-battle-dog__label">对手 · {snapshot.opponent.barkCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="bark-battle-controls">
|
||||
{snapshot.phase === 'permission' ? (
|
||||
<button className="bark-battle-primary-button" type="button" onClick={onStartMicrophone}>
|
||||
开始声控
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
|
||||
模拟叫声
|
||||
</button>
|
||||
{snapshot.phase === 'finished' ? (
|
||||
<button type="button" onClick={onRestart}>再来一局</button>
|
||||
) : null}
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
src/games/bark-battle/ui/BarkBattleResultPanel.tsx
Normal file
34
src/games/bark-battle/ui/BarkBattleResultPanel.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { BarkBattleResult } from '../domain/BarkBattleTypes';
|
||||
|
||||
type BarkBattleResultPanelProps = {
|
||||
result: BarkBattleResult;
|
||||
onRestart: () => void;
|
||||
};
|
||||
|
||||
export function BarkBattleResultPanel({ result, onRestart }: BarkBattleResultPanelProps) {
|
||||
const title = result.winner === 'player' ? '汪力压制成功' : result.winner === 'opponent' ? '对手声浪更强' : '势均力敌';
|
||||
|
||||
return (
|
||||
<section className="bark-battle-result" role="dialog" aria-label="对战结算">
|
||||
<p className="bark-battle-result__eyebrow">本局结束</p>
|
||||
<h2>{title}</h2>
|
||||
<div className="bark-battle-result__stats">
|
||||
<span>
|
||||
<strong>{result.playerBarkCount}</strong>
|
||||
玩家叫声
|
||||
</span>
|
||||
<span>
|
||||
<strong>{result.opponentBarkCount}</strong>
|
||||
对手压制
|
||||
</span>
|
||||
<span>
|
||||
<strong>{result.score}</strong>
|
||||
声浪分
|
||||
</span>
|
||||
</div>
|
||||
<button className="bark-battle-primary-button" type="button" onClick={onRestart}>
|
||||
再来一局
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
265
src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx
Normal file
265
src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type BarkBattleConfig,
|
||||
DEFAULT_BARK_BATTLE_CONFIG,
|
||||
} from '../application/BarkBattleConfig';
|
||||
import { BarkBattleController } from '../application/BarkBattleController';
|
||||
import type { MicrophoneFailureReason } from '../domain/BarkBattleTypes';
|
||||
import {
|
||||
type BrowserMicrophoneSampler,
|
||||
startBrowserMicrophoneSampler,
|
||||
} from '../infrastructure/BrowserMicrophoneInput';
|
||||
import { BarkBattleHud } from './BarkBattleHud';
|
||||
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
|
||||
|
||||
type BarkBattleRuntimeShellProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type DebugEvent = {
|
||||
id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const DEBUG_CONFIG_FIELDS: Array<{
|
||||
key: keyof Pick<
|
||||
BarkBattleConfig,
|
||||
| 'roundDurationMs'
|
||||
| 'countdownMs'
|
||||
| 'drawThreshold'
|
||||
| 'barkThreshold'
|
||||
| 'minBarkGapMs'
|
||||
| 'balanceFactor'
|
||||
| 'opponentBasePower'
|
||||
>;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
}> = [
|
||||
{ key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 },
|
||||
{ key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 },
|
||||
{ key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 },
|
||||
{ key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 },
|
||||
{ key: 'minBarkGapMs', label: '叫声间隔(ms)', min: 100, max: 1200, step: 50 },
|
||||
{ key: 'balanceFactor', label: '拉锯速度', min: 5, max: 80, step: 1 },
|
||||
{ key: 'opponentBasePower', label: '对手基础力', min: 0, max: 1, step: 0.05 },
|
||||
];
|
||||
|
||||
const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
|
||||
'unsupported',
|
||||
'permission-denied',
|
||||
'non-secure-context',
|
||||
'not-found',
|
||||
'not-readable',
|
||||
'audio-context-blocked',
|
||||
'calibration-timeout',
|
||||
'calibration-sample-unreadable',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason {
|
||||
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason);
|
||||
}
|
||||
|
||||
export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: BarkBattleRuntimeShellProps) {
|
||||
const [config, setConfig] = useState(DEFAULT_BARK_BATTLE_CONFIG);
|
||||
const controllerRef = useRef<BarkBattleController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new BarkBattleController(config);
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
|
||||
const [particleText, setParticleText] = useState('');
|
||||
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock');
|
||||
const [liveInputVolume, setLiveInputVolume] = useState(0);
|
||||
const [isDebugExpanded, setIsDebugExpanded] = useState(false);
|
||||
const [playerPulseKey, setPlayerPulseKey] = useState(0);
|
||||
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
|
||||
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
|
||||
const heldRef = useRef(false);
|
||||
const lastPlayerBarkCountRef = useRef(0);
|
||||
const lastOpponentPowerRef = useRef(0);
|
||||
const debugEventIdRef = useRef(0);
|
||||
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
|
||||
|
||||
const appendDebugEvent = useCallback((text: string) => {
|
||||
debugEventIdRef.current += 1;
|
||||
const event = { id: debugEventIdRef.current, text };
|
||||
setDebugEvents((current) => [event, ...current].slice(0, 5));
|
||||
}, []);
|
||||
|
||||
const syncSnapshot = useCallback(() => {
|
||||
const nextSnapshot = controller.getSnapshot();
|
||||
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
|
||||
setPlayerPulseKey((current) => current + 1);
|
||||
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
|
||||
}
|
||||
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
|
||||
setOpponentPulseKey((current) => current + 1);
|
||||
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
|
||||
}
|
||||
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
|
||||
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
|
||||
setSnapshot(nextSnapshot);
|
||||
}, [appendDebugEvent, controller]);
|
||||
|
||||
const stopMicrophone = useCallback(() => {
|
||||
microphoneSamplerRef.current?.stop();
|
||||
microphoneSamplerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const startMicrophone = useCallback(async () => {
|
||||
stopMicrophone();
|
||||
try {
|
||||
controller.startWithMockInput();
|
||||
const sampler = await startBrowserMicrophoneSampler((volume, atMs) => {
|
||||
setLiveInputVolume(volume);
|
||||
if (volume >= config.barkThreshold) {
|
||||
appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`);
|
||||
}
|
||||
controller.submitInputSample(volume, atMs);
|
||||
});
|
||||
microphoneSamplerRef.current = sampler;
|
||||
setInputMode('microphone');
|
||||
appendDebugEvent('真实麦克风已开启');
|
||||
syncSnapshot();
|
||||
} catch (error) {
|
||||
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'unknown';
|
||||
const failureReason = isMicrophoneFailureReason(reason) ? reason : 'unknown';
|
||||
controller.failMicrophone(failureReason);
|
||||
appendDebugEvent(`麦克风不可用:${failureReason}`);
|
||||
syncSnapshot();
|
||||
}
|
||||
}, [appendDebugEvent, config.barkThreshold, controller, stopMicrophone, syncSnapshot]);
|
||||
|
||||
useEffect(() => stopMicrophone, [stopMicrophone]);
|
||||
|
||||
useEffect(() => {
|
||||
controller.updateConfig(config);
|
||||
syncSnapshot();
|
||||
}, [config, controller, syncSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
controller.tick(100);
|
||||
if (inputMode === 'mock') {
|
||||
if (heldRef.current) {
|
||||
controller.submitMockSample(0.88);
|
||||
} else {
|
||||
controller.submitMockSample(0.12);
|
||||
setLiveInputVolume(0);
|
||||
}
|
||||
}
|
||||
syncSnapshot();
|
||||
}, 100);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [controller, inputMode, syncSnapshot]);
|
||||
|
||||
const restart = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.restart();
|
||||
setParticleText('');
|
||||
setDebugEvents([]);
|
||||
lastPlayerBarkCountRef.current = 0;
|
||||
lastOpponentPowerRef.current = 0;
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const startMock = () => {
|
||||
stopMicrophone();
|
||||
setInputMode('mock');
|
||||
setLiveInputVolume(0);
|
||||
controller.startWithMockInput();
|
||||
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const finishNow = () => {
|
||||
heldRef.current = false;
|
||||
stopMicrophone();
|
||||
controller.finishNow();
|
||||
appendDebugEvent('人工结束对局');
|
||||
syncSnapshot();
|
||||
};
|
||||
|
||||
const bark = () => {
|
||||
controller.forcePlayerBark(0.9);
|
||||
syncSnapshot();
|
||||
setParticleText('汪!');
|
||||
window.setTimeout(() => setParticleText(''), 680);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="bark-battle-runtime" aria-label={title}>
|
||||
<BarkBattleHud
|
||||
snapshot={snapshot}
|
||||
playerPulseKey={playerPulseKey}
|
||||
opponentPulseKey={opponentPulseKey}
|
||||
onStartMicrophone={startMicrophone}
|
||||
onMockBark={bark}
|
||||
onMockQuiet={() => {
|
||||
heldRef.current = false;
|
||||
}}
|
||||
onRestart={restart}
|
||||
/>
|
||||
<aside className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`} aria-label="调试面板">
|
||||
<header>
|
||||
<strong>调试面板</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="bark-battle-debug-panel__toggle"
|
||||
aria-expanded={isDebugExpanded}
|
||||
onClick={() => setIsDebugExpanded((current) => !current)}
|
||||
>
|
||||
{isDebugExpanded ? '收起' : '展开'}
|
||||
</button>
|
||||
<span>{snapshot.phase}</span>
|
||||
</header>
|
||||
<div className="bark-battle-debug-panel__body">
|
||||
<div className="bark-battle-debug-panel__controls">
|
||||
<button type="button" onClick={startMock}>开始</button>
|
||||
<button type="button" onClick={finishNow}>结束</button>
|
||||
<button type="button" onClick={restart}>重置</button>
|
||||
</div>
|
||||
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
|
||||
<span className="bark-battle-debug-metrics__wide">输入模式:{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}</span>
|
||||
<span>实时音量:{(liveInputVolume * 100).toFixed(0)}%</span>
|
||||
<span>采样时钟:{controller.getSampleClockMs()}ms</span>
|
||||
<span>玩家触发:{snapshot.player.barkCount}</span>
|
||||
<span>玩家强度:{(snapshot.player.power * 100).toFixed(0)}%</span>
|
||||
<span>对手强度:{(snapshot.opponent.power * 100).toFixed(0)}%</span>
|
||||
<span>能量:{Math.round(snapshot.energy)}</span>
|
||||
</div>
|
||||
<ol className="bark-battle-debug-events" aria-label="触发日志">
|
||||
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li>等待输入触发</li>}
|
||||
</ol>
|
||||
{DEBUG_CONFIG_FIELDS.map((field) => (
|
||||
<label key={field.key}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="range"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={config[field.key]}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
setConfig((current) => ({ ...current, [field.key]: value }));
|
||||
}}
|
||||
/>
|
||||
<output>{config[field.key]}</output>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
|
||||
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
57
src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx
Normal file
57
src/games/bark-battle/ui/__tests__/BarkBattleHud.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
|
||||
import { BarkBattleHud } from '../BarkBattleHud';
|
||||
|
||||
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
|
||||
return {
|
||||
phase: 'playing',
|
||||
uiState: 'playing',
|
||||
errorReason: null,
|
||||
statusMessageKey: null,
|
||||
elapsedMs: 0,
|
||||
remainingMs: 12_000,
|
||||
countdownMs: 0,
|
||||
energy: 40,
|
||||
player: { barkCount: 3, power: 0.8 },
|
||||
opponent: { barkCount: 1, power: 0.25 },
|
||||
winner: null,
|
||||
result: null,
|
||||
lastEvents: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BarkBattleHud', () => {
|
||||
it('playing 阶段展示竖屏核心元素、倒计时和双方狗狗朝向', () => {
|
||||
render(<BarkBattleHud snapshot={buildSnapshot()} onMockBark={() => {}} onMockQuiet={() => {}} />);
|
||||
|
||||
expect(screen.getByText('12.0s')).toBeTruthy();
|
||||
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
|
||||
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
|
||||
});
|
||||
|
||||
it('energy 正负值会改变玩家侧和对手侧占比', () => {
|
||||
const { rerender } = render(<BarkBattleHud snapshot={buildSnapshot({ energy: 60 })} />);
|
||||
expect(screen.getByTestId('player-energy-fill').getAttribute('style')).toContain('width: 80%');
|
||||
|
||||
rerender(<BarkBattleHud snapshot={buildSnapshot({ energy: -60 })} />);
|
||||
expect(screen.getByTestId('opponent-energy-fill').getAttribute('style')).toContain('width: 80%');
|
||||
});
|
||||
|
||||
it('unsupported 不展示开始声控按钮,permission-denied 展示重试授权入口', () => {
|
||||
const { rerender } = render(
|
||||
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'unsupported' })} onStartMicrophone={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<BarkBattleHud snapshot={buildSnapshot({ phase: 'unavailable', errorReason: 'permission-denied' })} onStartMicrophone={() => {}} />,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BarkBattleResultPanel } from '../BarkBattleResultPanel';
|
||||
|
||||
describe('BarkBattleResultPanel', () => {
|
||||
it('展示胜负、叫声次数并支持再来一局', async () => {
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<BarkBattleResultPanel
|
||||
result={{ winner: 'player', playerBarkCount: 6, opponentBarkCount: 2, finalEnergy: 72, score: 792 }}
|
||||
onRestart={onRestart}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
|
||||
expect(screen.getByText('汪力压制成功')).toBeTruthy();
|
||||
expect(screen.getByText('6')).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '再来一局' }));
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
|
||||
|
||||
describe('BarkBattleRuntimeShell 调试面板', () => {
|
||||
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
expect(debugPanel).toBeTruthy();
|
||||
expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy();
|
||||
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('叫声阈值')).toBeTruthy();
|
||||
expect(screen.getByLabelText('触发反馈')).toBeTruthy();
|
||||
expect(screen.getByLabelText('触发日志')).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
expect(screen.getByText(/countdown|playing/u)).toBeTruthy();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
|
||||
expect(screen.getAllByText(/玩家叫声触发 #1/u).length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '结束' }));
|
||||
expect(screen.getByRole('dialog', { name: '对战结算' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('真实声控入口在不支持麦克风时展示失败原因,mock 开始不请求权限', async () => {
|
||||
render(<BarkBattleRuntimeShell />);
|
||||
|
||||
const debugPanel = screen.getByLabelText('调试面板');
|
||||
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
|
||||
|
||||
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
|
||||
expect(screen.getAllByText(/麦克风不可用:unsupported/u).length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始' }));
|
||||
expect(screen.getAllByText(/开始 mock 对局(不会请求浏览器麦克风权限)/u).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/输入模式:Mock 输入/u)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
755
src/index.css
755
src/index.css
@@ -1,4 +1,4 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@source not "../dist";
|
||||
@source not "../dist_check";
|
||||
@@ -2263,6 +2263,371 @@ body {
|
||||
color: var(--puzzle-runtime-text-soft);
|
||||
}
|
||||
|
||||
.baby-object-runtime {
|
||||
--baby-object-sky: #cfefff;
|
||||
--baby-object-ground: #7bc36f;
|
||||
--baby-object-ground-deep: #3f8b48;
|
||||
--baby-object-panel: rgba(255, 253, 244, 0.84);
|
||||
--baby-object-panel-border: rgba(72, 118, 72, 0.2);
|
||||
--baby-object-text: #24422b;
|
||||
--baby-object-soft: rgba(36, 66, 43, 0.72);
|
||||
--baby-object-coral: #ff7a7a;
|
||||
--baby-object-yellow: #ffd166;
|
||||
--baby-object-blue: #77c8ff;
|
||||
min-height: 100dvh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.9) 0 6%, transparent 6.4%),
|
||||
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.78) 0 7%, transparent 7.4%),
|
||||
linear-gradient(180deg, #f8fcff 0%, var(--baby-object-sky) 56%, #dff2cf 57%, #b8df9d 100%);
|
||||
color: var(--baby-object-text);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.baby-object-runtime--embedded {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.baby-object-runtime__stage {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
min-height: 32rem;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.baby-object-runtime--embedded .baby-object-runtime__stage {
|
||||
height: 100%;
|
||||
min-height: 28rem;
|
||||
}
|
||||
|
||||
.baby-object-runtime__stage::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: auto -10% 0;
|
||||
height: 39%;
|
||||
border-radius: 50% 50% 0 0 / 24% 24% 0 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 12%, rgba(255, 255, 255, 0.3) 0 7%, transparent 7.4%),
|
||||
linear-gradient(180deg, var(--baby-object-ground), var(--baby-object-ground-deep));
|
||||
box-shadow: inset 0 24px 42px rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
.baby-object-runtime__back,
|
||||
.baby-object-runtime__counter {
|
||||
position: absolute;
|
||||
z-index: 8;
|
||||
top: max(0.75rem, env(safe-area-inset-top));
|
||||
display: inline-flex;
|
||||
min-height: 2.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--baby-object-panel-border);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 253, 244, 0.78);
|
||||
color: var(--baby-object-text);
|
||||
box-shadow: 0 14px 34px rgba(60, 112, 74, 0.16);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.baby-object-runtime__back {
|
||||
left: max(0.75rem, env(safe-area-inset-left));
|
||||
width: 2.4rem;
|
||||
}
|
||||
|
||||
.baby-object-runtime__counter {
|
||||
right: max(0.75rem, env(safe-area-inset-right));
|
||||
min-width: 4.7rem;
|
||||
padding: 0 1rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.baby-object-runtime__subtitle {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
left: 50%;
|
||||
top: max(0.85rem, env(safe-area-inset-top));
|
||||
transform: translateX(-50%);
|
||||
max-width: min(72vw, 34rem);
|
||||
border: 1px solid var(--baby-object-panel-border);
|
||||
border-radius: 999px;
|
||||
background: var(--baby-object-panel);
|
||||
padding: 0.68rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: clamp(1rem, 2.1vw, 1.55rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.18;
|
||||
box-shadow: 0 18px 42px rgba(60, 112, 74, 0.16);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.baby-object-runtime__gift {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
left: 50%;
|
||||
bottom: 29%;
|
||||
display: grid;
|
||||
width: clamp(5.5rem, 14vw, 9rem);
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
transform: translateX(-50%);
|
||||
border: 0.45rem solid #ffe7a8;
|
||||
border-radius: 1.35rem;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 42%, rgba(255, 255, 255, 0.35) 42% 58%, transparent 58%),
|
||||
linear-gradient(180deg, #ff8f70, #ff5d78);
|
||||
color: #fff7d7;
|
||||
box-shadow:
|
||||
0 18px 0 rgba(146, 67, 47, 0.14),
|
||||
0 24px 48px rgba(119, 75, 44, 0.18);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-radius 180ms ease;
|
||||
}
|
||||
|
||||
.baby-object-runtime__gift--open {
|
||||
transform: translateX(-50%) translateY(0.35rem) scale(0.94);
|
||||
border-radius: 1.8rem;
|
||||
}
|
||||
|
||||
.baby-object-runtime__gift::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -22% -8% auto;
|
||||
height: 32%;
|
||||
border-radius: 1.1rem;
|
||||
background: #ffe7a8;
|
||||
transform-origin: 20% 100%;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.baby-object-runtime__gift--open::before {
|
||||
transform: rotate(-17deg) translateY(-0.5rem);
|
||||
}
|
||||
|
||||
.baby-object-runtime__gift-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 42%;
|
||||
height: 42%;
|
||||
}
|
||||
|
||||
.baby-object-runtime__item {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
left: 50%;
|
||||
top: 37%;
|
||||
display: grid;
|
||||
width: clamp(6.2rem, 15vw, 9.5rem);
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 0.2rem solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 253, 244, 0.74);
|
||||
box-shadow:
|
||||
0 18px 42px rgba(61, 106, 72, 0.17),
|
||||
inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32);
|
||||
transition: transform 260ms ease;
|
||||
}
|
||||
|
||||
.baby-object-runtime__item:empty {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.baby-object-runtime__item--to-left {
|
||||
transform: translate(-210%, 118%) scale(0.68) rotate(-12deg);
|
||||
}
|
||||
|
||||
.baby-object-runtime__item--to-right {
|
||||
transform: translate(110%, 118%) scale(0.68) rotate(12deg);
|
||||
}
|
||||
|
||||
.baby-object-runtime__item--wrong-left,
|
||||
.baby-object-runtime__item--wrong-right {
|
||||
animation: baby-object-wrong-bounce 0.62s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes baby-object-wrong-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
35% {
|
||||
transform: translate(-50%, -58%) scale(0.92);
|
||||
}
|
||||
62% {
|
||||
transform: translate(-50%, -44%) scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.baby-object-runtime__item-image {
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.baby-object-runtime__item-name {
|
||||
position: absolute;
|
||||
bottom: -0.9rem;
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 253, 244, 0.86);
|
||||
padding: 0.22rem 0.7rem;
|
||||
color: var(--baby-object-text);
|
||||
font-size: clamp(0.78rem, 1.5vw, 1rem);
|
||||
font-weight: 900;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.baby-object-runtime__feedback {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
left: 50%;
|
||||
top: 22%;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-size: clamp(1.5rem, 4vw, 3rem);
|
||||
font-weight: 1000;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
animation: baby-object-feedback-pop 0.7s ease-out;
|
||||
}
|
||||
|
||||
.baby-object-runtime__feedback--correct {
|
||||
background: rgba(255, 250, 210, 0.9);
|
||||
color: #2f7d39;
|
||||
box-shadow: 0 18px 42px rgba(65, 146, 76, 0.2);
|
||||
}
|
||||
|
||||
.baby-object-runtime__feedback--wrong {
|
||||
background: rgba(255, 236, 236, 0.92);
|
||||
color: #cb4b57;
|
||||
box-shadow: 0 18px 42px rgba(202, 75, 87, 0.18);
|
||||
}
|
||||
|
||||
.baby-object-runtime__feedback--complete {
|
||||
background: rgba(255, 246, 204, 0.94);
|
||||
color: #c47013;
|
||||
box-shadow: 0 18px 42px rgba(196, 112, 19, 0.18);
|
||||
}
|
||||
|
||||
@keyframes baby-object-feedback-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(0.8rem) scale(0.8);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.baby-object-runtime__baskets {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
inset: auto 0 3.5%;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
padding: 0 max(5vw, env(safe-area-inset-right)) 0 max(5vw, env(safe-area-inset-left));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket {
|
||||
position: relative;
|
||||
width: clamp(6.4rem, 18vw, 11rem);
|
||||
aspect-ratio: 1 / 0.88;
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-icon {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 50%;
|
||||
top: -26%;
|
||||
display: grid;
|
||||
width: 54%;
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
transform: translateX(-50%);
|
||||
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);
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-image {
|
||||
width: 74%;
|
||||
height: 74%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.baby-object-runtime__basket-body {
|
||||
position: absolute;
|
||||
inset: 20% 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;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, rgba(139, 84, 40, 0.18) 0 0.7rem, transparent 0.7rem 1.4rem),
|
||||
linear-gradient(180deg, #ffd980, #d99845);
|
||||
box-shadow: 0 18px 28px rgba(95, 84, 54, 0.2);
|
||||
}
|
||||
|
||||
.baby-object-runtime__complete {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
width: min(88vw, 24rem);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid rgba(255, 221, 124, 0.8);
|
||||
border-radius: 1.6rem;
|
||||
background: rgba(255, 253, 244, 0.92);
|
||||
padding: 1.35rem;
|
||||
text-align: center;
|
||||
font-size: clamp(1.35rem, 3vw, 2rem);
|
||||
font-weight: 1000;
|
||||
color: #c47013;
|
||||
box-shadow: 0 24px 70px rgba(107, 84, 41, 0.22);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.baby-object-runtime__complete-actions {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.baby-object-runtime__complete-actions button {
|
||||
display: inline-flex;
|
||||
min-height: 2.8rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #ffe7a8, #ffc867);
|
||||
color: #5d3b15;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 10px 22px rgba(129, 83, 24, 0.16);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
:root {
|
||||
--platform-bottom-nav-height: 3.85rem;
|
||||
@@ -5759,6 +6124,14 @@ button {
|
||||
--child-motion-soft: rgba(39, 65, 42, 0.74);
|
||||
--child-motion-green: #70c16b;
|
||||
--child-motion-sky-accent: #95d2ff;
|
||||
--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-v2.png');
|
||||
--child-motion-asset-avatar: url('/child-motion-demo/picture-book-character-outline-v2.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');
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -5899,28 +6272,16 @@ button {
|
||||
|
||||
.child-motion-stage::before {
|
||||
z-index: 0;
|
||||
background-image: url('/child-motion-demo/picture-book-grass-stage.webp');
|
||||
background-image: var(--child-motion-asset-stage);
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
opacity: 0.88;
|
||||
filter: saturate(1.02) contrast(0.98) brightness(1.02);
|
||||
opacity: 1;
|
||||
filter: saturate(1.01) contrast(0.99);
|
||||
}
|
||||
|
||||
.child-motion-stage::after {
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
transparent 18%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 50% 82%,
|
||||
rgba(255, 245, 220, 0.16),
|
||||
transparent 42%
|
||||
),
|
||||
linear-gradient(180deg, transparent 0 58%, rgba(80, 141, 72, 0.14) 100%);
|
||||
opacity: 0.95;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.child-motion-camera-layer {
|
||||
@@ -5930,25 +6291,9 @@ button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.58),
|
||||
rgba(255, 255, 255, 0.08)
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 33%,
|
||||
rgba(255, 255, 255, 0.42),
|
||||
transparent 30%
|
||||
),
|
||||
linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0.1) 0 11%,
|
||||
transparent 11% 20%,
|
||||
rgba(255, 255, 255, 0.08) 20% 30%,
|
||||
transparent 30% 100%
|
||||
);
|
||||
background: transparent;
|
||||
filter: blur(8px) saturate(0.92);
|
||||
opacity: 0.34;
|
||||
opacity: 0.22;
|
||||
transform: scale(1.04);
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
@@ -5976,105 +6321,19 @@ button {
|
||||
|
||||
.child-motion-floor {
|
||||
position: absolute;
|
||||
right: -8%;
|
||||
bottom: -19%;
|
||||
left: -8%;
|
||||
right: 0;
|
||||
bottom: -11%;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
height: 47%;
|
||||
border-radius: 50% 50% 0 0;
|
||||
background: radial-gradient(
|
||||
ellipse at 50% 10%,
|
||||
rgba(255, 255, 255, 0.22),
|
||||
transparent 30%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 42% 30%,
|
||||
rgba(255, 246, 205, 0.2) 0 8%,
|
||||
transparent 18%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse at 70% 25%,
|
||||
rgba(255, 255, 255, 0.18) 0 5%,
|
||||
transparent 14%
|
||||
),
|
||||
linear-gradient(180deg, rgba(135, 194, 104, 0.92), rgba(69, 145, 76, 0.98));
|
||||
box-shadow:
|
||||
inset 0 26px 70px rgba(255, 255, 255, 0.16),
|
||||
inset 0 -38px 68px rgba(52, 94, 46, 0.18);
|
||||
height: 30%;
|
||||
border-radius: 0;
|
||||
background: var(--child-motion-asset-floor) center bottom / 100% auto no-repeat;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.child-motion-floor::before,
|
||||
.child-motion-floor::after {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.child-motion-floor::before {
|
||||
inset: 14% 10% auto 16%;
|
||||
height: 18%;
|
||||
background: radial-gradient(
|
||||
circle at 8% 50%,
|
||||
rgba(96, 148, 60, 0.68) 0 12%,
|
||||
transparent 13%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 21% 42%,
|
||||
rgba(96, 148, 60, 0.58) 0 9%,
|
||||
transparent 10%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 33% 55%,
|
||||
rgba(255, 255, 255, 0.2) 0 7%,
|
||||
transparent 8%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 45% 40%,
|
||||
rgba(96, 148, 60, 0.62) 0 11%,
|
||||
transparent 12%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 58% 52%,
|
||||
rgba(255, 255, 255, 0.16) 0 6%,
|
||||
transparent 7%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 69% 42%,
|
||||
rgba(96, 148, 60, 0.62) 0 10%,
|
||||
transparent 11%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 82% 50%,
|
||||
rgba(255, 255, 255, 0.18) 0 7%,
|
||||
transparent 8%
|
||||
);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.child-motion-floor::after {
|
||||
inset: auto 6% 10%;
|
||||
height: 15%;
|
||||
background: radial-gradient(
|
||||
circle at 18% 50%,
|
||||
rgba(55, 104, 53, 0.42) 0 10%,
|
||||
transparent 11%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 38% 50%,
|
||||
rgba(255, 255, 255, 0.12) 0 6%,
|
||||
transparent 7%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 60% 48%,
|
||||
rgba(55, 104, 53, 0.38) 0 11%,
|
||||
transparent 12%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 52%,
|
||||
rgba(255, 255, 255, 0.1) 0 5%,
|
||||
transparent 6%
|
||||
);
|
||||
opacity: 0.68;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.child-motion-hud {
|
||||
@@ -6083,103 +6342,87 @@ button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(0.6rem, 1.8vw, 1rem);
|
||||
border: 1px solid var(--child-motion-panel-border);
|
||||
border: 0;
|
||||
border-radius: clamp(0.75rem, 2vw, 1.25rem);
|
||||
background: var(--child-motion-panel);
|
||||
box-shadow: 0 18px 48px rgba(72, 112, 68, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.child-motion-hud--top {
|
||||
top: 4.2%;
|
||||
top: 3.2%;
|
||||
left: 50%;
|
||||
width: min(72%, 48rem);
|
||||
min-height: clamp(4.2rem, 11vh, 6.25rem);
|
||||
justify-content: space-between;
|
||||
width: min(56%, 46rem);
|
||||
height: clamp(4.1rem, 12.5%, 6.75rem);
|
||||
transform: translateX(-50%);
|
||||
padding: clamp(0.65rem, 1.8vw, 1rem) clamp(0.8rem, 2.2vw, 1.25rem);
|
||||
background: var(--child-motion-asset-hud) center center / cover no-repeat;
|
||||
padding: clamp(0.45rem, 1.2vw, 0.75rem) clamp(0.72rem, 2vw, 1.25rem);
|
||||
}
|
||||
|
||||
.child-motion-hud--top > div {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 0 clamp(0.35rem, 1vw, 0.75rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.child-motion-hud h1 {
|
||||
margin: 0;
|
||||
color: var(--child-motion-text);
|
||||
font-size: clamp(1.2rem, 3.2vw, 2rem);
|
||||
overflow: hidden;
|
||||
font-size: clamp(1rem, 2.4vw, 1.6rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.08;
|
||||
line-height: 1.05;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.child-motion-hud p {
|
||||
margin: 0.28rem 0 0;
|
||||
color: var(--child-motion-soft);
|
||||
font-size: clamp(0.72rem, 1.45vw, 0.98rem);
|
||||
overflow: hidden;
|
||||
font-size: clamp(0.64rem, 1.25vw, 0.86rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
line-height: 1.28;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.child-motion-step-count,
|
||||
.child-motion-progress {
|
||||
display: inline-flex;
|
||||
width: clamp(2.7rem, 7vw, 4rem);
|
||||
height: clamp(2.7rem, 7vw, 4rem);
|
||||
min-width: clamp(2.4rem, 6vw, 3.5rem);
|
||||
min-height: clamp(2.4rem, 6vw, 3.5rem);
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(112, 143, 97, 0.2);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.8),
|
||||
rgba(242, 248, 236, 0.92)
|
||||
);
|
||||
background: transparent;
|
||||
color: var(--child-motion-text);
|
||||
font-size: clamp(0.72rem, 1.45vw, 0.95rem);
|
||||
font-weight: 900;
|
||||
box-shadow: 0 8px 20px rgba(96, 132, 82, 0.12);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.child-motion-ring {
|
||||
position: absolute;
|
||||
bottom: 20.5%;
|
||||
bottom: 18.8%;
|
||||
z-index: 3;
|
||||
width: clamp(5.8rem, 13vw, 9rem);
|
||||
aspect-ratio: 1;
|
||||
transform: translateX(-50%) rotateX(66deg);
|
||||
width: clamp(7.8rem, 17vw, 11.6rem);
|
||||
aspect-ratio: 1200 / 520;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: conic-gradient(
|
||||
from -90deg,
|
||||
rgba(255, 255, 255, 0.88) 0 var(--child-motion-ring-progress),
|
||||
rgba(102, 190, 95, 0.22) var(--child-motion-ring-progress) 360deg
|
||||
);
|
||||
box-shadow:
|
||||
0 0 18px rgba(120, 191, 110, 0.34),
|
||||
0 0 0 6px rgba(255, 255, 255, 0.12),
|
||||
inset 0 0 24px rgba(255, 255, 255, 0.2);
|
||||
background: var(--child-motion-asset-ring) center / contain no-repeat;
|
||||
box-shadow: 0 0 20px rgba(120, 191, 110, 0.22);
|
||||
}
|
||||
|
||||
.child-motion-ring::before {
|
||||
position: absolute;
|
||||
inset: 14%;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(
|
||||
circle at 50% 45%,
|
||||
rgba(255, 255, 255, 0.1),
|
||||
transparent 40%
|
||||
),
|
||||
linear-gradient(180deg, rgba(151, 215, 139, 0.82), rgba(73, 151, 74, 0.94));
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.38);
|
||||
content: '';
|
||||
display: none;
|
||||
}
|
||||
|
||||
.child-motion-ring__core {
|
||||
position: absolute;
|
||||
inset: 34%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.68),
|
||||
rgba(150, 231, 137, 0.86)
|
||||
);
|
||||
opacity: 0.62;
|
||||
box-shadow: 0 0 22px rgba(124, 199, 112, 0.44);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.child-motion-ring--active {
|
||||
@@ -6198,15 +6441,23 @@ button {
|
||||
|
||||
.child-motion-avatar {
|
||||
position: absolute;
|
||||
bottom: 24%;
|
||||
bottom: 21.5%;
|
||||
z-index: 5;
|
||||
width: clamp(3.4rem, 7vw, 5.6rem);
|
||||
height: clamp(6rem, 13vw, 10rem);
|
||||
width: clamp(4.2rem, 8.4vw, 6.8rem);
|
||||
aspect-ratio: 2 / 3;
|
||||
transform: translateX(-50%);
|
||||
transition:
|
||||
left 260ms ease,
|
||||
transform 220ms ease;
|
||||
filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.18));
|
||||
isolation: isolate;
|
||||
transition: left 260ms ease, transform 220ms ease;
|
||||
filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.16));
|
||||
}
|
||||
|
||||
.child-motion-avatar::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
background: var(--child-motion-asset-avatar) center bottom / contain no-repeat;
|
||||
opacity: 0.88;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.child-motion-avatar--jumping {
|
||||
@@ -6217,70 +6468,7 @@ button {
|
||||
.child-motion-avatar__body,
|
||||
.child-motion-avatar__arm,
|
||||
.child-motion-avatar__leg {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(77, 109, 79, 0.44),
|
||||
rgba(41, 65, 44, 0.7)
|
||||
),
|
||||
rgba(245, 250, 245, 0.1);
|
||||
opacity: 0.6;
|
||||
border: 1px solid rgba(239, 249, 235, 0.18);
|
||||
box-shadow: 0 0 24px rgba(143, 216, 255, 0.12);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.child-motion-avatar__head {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 34%;
|
||||
aspect-ratio: 1;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.child-motion-avatar__body {
|
||||
top: 27%;
|
||||
left: 50%;
|
||||
width: 42%;
|
||||
height: 36%;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px 999px 45% 45%;
|
||||
}
|
||||
|
||||
.child-motion-avatar__arm {
|
||||
top: 33%;
|
||||
width: 15%;
|
||||
height: 34%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.child-motion-avatar__arm--left {
|
||||
left: 17%;
|
||||
transform: rotate(18deg);
|
||||
}
|
||||
|
||||
.child-motion-avatar__arm--right {
|
||||
right: 17%;
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.child-motion-avatar__leg {
|
||||
bottom: 0;
|
||||
width: 15%;
|
||||
height: 34%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.child-motion-avatar__leg--left {
|
||||
left: 36%;
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
|
||||
.child-motion-avatar__leg--right {
|
||||
right: 36%;
|
||||
transform: rotate(-7deg);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide {
|
||||
@@ -6376,40 +6564,51 @@ button {
|
||||
.child-motion-calibration {
|
||||
position: absolute;
|
||||
right: 3.2%;
|
||||
bottom: 4%;
|
||||
bottom: 8.8%;
|
||||
z-index: 8;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, auto));
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: clamp(0.12rem, 0.55vw, 0.45rem);
|
||||
width: min(34%, 30rem);
|
||||
max-width: 82%;
|
||||
border: 1px solid var(--child-motion-panel-border);
|
||||
height: clamp(3.1rem, 7.6%, 4.55rem);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--child-motion-panel);
|
||||
padding: 0.45rem;
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: 0 14px 32px rgba(82, 124, 72, 0.1);
|
||||
background: var(--child-motion-asset-calibration) center center / cover no-repeat;
|
||||
padding: clamp(0.4rem, 1.1vw, 0.56rem) clamp(0.66rem, 1.5vw, 0.9rem);
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.child-motion-calibration div {
|
||||
display: grid;
|
||||
min-width: clamp(3.2rem, 7vw, 4.8rem);
|
||||
min-width: 0;
|
||||
gap: 0.08rem;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.48);
|
||||
padding: 0.36rem 0.55rem;
|
||||
background: transparent;
|
||||
padding: 0.32rem 0.18rem;
|
||||
transform: translateY(6%);
|
||||
}
|
||||
|
||||
.child-motion-calibration span {
|
||||
color: var(--child-motion-soft);
|
||||
font-size: clamp(0.55rem, 1.2vw, 0.72rem);
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
font-size: clamp(0.52rem, 1vw, 0.66rem);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.child-motion-calibration strong {
|
||||
color: var(--child-motion-text);
|
||||
font-size: clamp(0.72rem, 1.5vw, 0.95rem);
|
||||
font-size: clamp(0.7rem, 1.25vw, 0.88rem);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.child-motion-start-panel {
|
||||
@@ -6418,23 +6617,27 @@ button {
|
||||
top: 53%;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
width: min(28%, 19rem);
|
||||
height: clamp(3.8rem, 9%, 5.2rem);
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.85rem;
|
||||
border: 1px solid rgba(143, 176, 124, 0.24);
|
||||
border: 0;
|
||||
border-radius: 1.4rem;
|
||||
background: rgba(255, 250, 241, 0.76);
|
||||
padding: clamp(0.85rem, 2vw, 1.15rem);
|
||||
box-shadow: 0 24px 70px rgba(82, 124, 72, 0.18);
|
||||
backdrop-filter: blur(14px);
|
||||
background: var(--child-motion-asset-start-panel) center center / cover no-repeat;
|
||||
padding: clamp(0.45rem, 1.2vw, 0.7rem);
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.child-motion-start-panel button {
|
||||
min-width: clamp(8rem, 18vw, 12rem);
|
||||
min-height: clamp(3rem, 7vw, 4.2rem);
|
||||
width: min(82%, 12rem);
|
||||
min-width: clamp(7.5rem, 14vw, 10.5rem);
|
||||
min-height: clamp(2.5rem, 5.8vw, 3.4rem);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #88cf74, #9dd3ff);
|
||||
background: var(--child-motion-asset-button) center center / cover no-repeat;
|
||||
color: #214228;
|
||||
font-size: clamp(1rem, 2.5vw, 1.4rem);
|
||||
font-weight: 950;
|
||||
|
||||
@@ -26,6 +26,10 @@ const STAGE_ROUTE_ENTRIES = [
|
||||
['visual-novel-result', '/creation/visual-novel/result'],
|
||||
['visual-novel-gallery-detail', '/gallery/visual-novel/detail'],
|
||||
['visual-novel-runtime', '/runtime/visual-novel'],
|
||||
['baby-object-match-workspace', '/creation/baby-object-match'],
|
||||
['baby-object-match-generating', '/creation/baby-object-match/generating'],
|
||||
['baby-object-match-result', '/creation/baby-object-match/result'],
|
||||
['baby-object-match-runtime', '/runtime/baby-object-match'],
|
||||
['puzzle-agent-workspace', '/creation/puzzle/agent'],
|
||||
['puzzle-result', '/creation/puzzle/result'],
|
||||
['puzzle-gallery-detail', '/gallery/puzzle/detail'],
|
||||
|
||||
@@ -19,6 +19,9 @@ export type AppRouteMatch =
|
||||
| {
|
||||
kind: 'match3d-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'bark-battle-playground';
|
||||
}
|
||||
| {
|
||||
kind: 'child-motion-demo';
|
||||
}
|
||||
@@ -37,6 +40,7 @@ export type ResolvedAppRoute = {
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
|
||||
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
|
||||
const BarkBattlePlaygroundApp = lazy(() => import('../BarkBattlePlaygroundApp')) as AppRouteComponent;
|
||||
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
|
||||
const ChildMotionDemoApp = lazy(() => import('../ChildMotionDemoApp')) as AppRouteComponent;
|
||||
|
||||
@@ -65,6 +69,12 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedPath === '/bark-battle') {
|
||||
return {
|
||||
kind: 'bark-battle-playground',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedPath === '/child-motion-demo' &&
|
||||
isEdutainmentEntryEnabled()
|
||||
@@ -109,6 +119,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRoute.kind === 'bark-battle-playground') {
|
||||
return {
|
||||
kind: 'bark-battle-playground',
|
||||
loadingEyebrow: '正在载入汪汪声浪',
|
||||
loadingText: '正在进入竖屏声浪竞技场...',
|
||||
Component: BarkBattlePlaygroundApp,
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRoute.kind === 'child-motion-demo') {
|
||||
return {
|
||||
kind: 'child-motion-demo',
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatBindPhoneRequest,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
@@ -193,15 +194,16 @@ export async function redeemRegistrationInviteCode(inviteCode: string) {
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const payload: AuthWechatBindPhoneRequest = {
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
};
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
hasBabyObjectMatchRequiredTag,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
createBabyObjectMatchDraft,
|
||||
deleteLocalBabyObjectMatchDraft,
|
||||
listLocalBabyObjectMatchDrafts,
|
||||
publishBabyObjectMatchWork,
|
||||
} from './babyObjectMatchClient';
|
||||
|
||||
describe('babyObjectMatchClient', () => {
|
||||
beforeEach(() => {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('creates local demo draft with exact edutainment tag', async () => {
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => '11111111-2222-3333-4444-555555555555',
|
||||
});
|
||||
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: ' 苹果 ',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(response.draft.templateName).toBe('宝贝识物');
|
||||
expect(response.draft.itemNames).toEqual(['苹果', '香蕉']);
|
||||
expect(response.draft.itemAssets).toHaveLength(2);
|
||||
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
|
||||
'placeholder',
|
||||
);
|
||||
expect(response.draft.themeTags).toContain(
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
);
|
||||
expect(hasBabyObjectMatchRequiredTag(response.draft.themeTags)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects draft creation when any item name is empty', async () => {
|
||||
await expect(
|
||||
createBabyObjectMatchDraft({
|
||||
itemAName: '苹果',
|
||||
itemBName: ' ',
|
||||
}),
|
||||
).rejects.toThrow('请填写两个物品名称。');
|
||||
});
|
||||
|
||||
test('publish normalizes exact edutainment tag into payload', async () => {
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '杯子',
|
||||
itemBName: '勺子',
|
||||
});
|
||||
const published = await publishBabyObjectMatchWork({
|
||||
draft: {
|
||||
...response.draft,
|
||||
themeTags: ['儿童教育', '寓教于乐 '],
|
||||
},
|
||||
});
|
||||
|
||||
expect(published.publicWorkCode).toMatch(/^BO-/u);
|
||||
expect(published.draft.publicationStatus).toBe('published');
|
||||
expect(published.draft.themeTags[0]).toBe(
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
);
|
||||
expect(hasBabyObjectMatchRequiredTag(published.draft.themeTags)).toBe(true);
|
||||
});
|
||||
|
||||
test('deletes local baby object match draft by profile id', async () => {
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(1);
|
||||
|
||||
const nextItems = deleteLocalBabyObjectMatchDraft(response.draft.profileId);
|
||||
|
||||
expect(nextItems).toHaveLength(0);
|
||||
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
231
src/services/edutainment-baby-object/babyObjectMatchClient.ts
Normal file
231
src/services/edutainment-baby-object/babyObjectMatchClient.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
BabyObjectMatchItemAsset,
|
||||
BabyObjectMatchPublishRequest,
|
||||
BabyObjectMatchPublishResponse,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
SaveBabyObjectMatchDraftRequest,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import {
|
||||
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
normalizeBabyObjectMatchTags,
|
||||
validateBabyObjectMatchItemNames,
|
||||
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { buildBabyObjectMatchPublicWorkCode } from '../publicWorkCode';
|
||||
|
||||
const STORAGE_KEY = 'genarrative.edutainmentBabyObject.localDrafts.v1';
|
||||
|
||||
type LocalDraftStore = Record<string, BabyObjectMatchDraft>;
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return (
|
||||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function readLocalDraftStore(): LocalDraftStore {
|
||||
if (!canUseLocalStorage()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!rawValue) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(rawValue) as LocalDraftStore;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalDraftStore(store: LocalDraftStore) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||
}
|
||||
|
||||
function createLocalId(prefix: string) {
|
||||
const randomPart =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID().replace(/-/gu, '')
|
||||
: Math.random().toString(36).slice(2);
|
||||
|
||||
return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function encodeSvgDataUri(svg: string) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
function buildPlaceholderItemImage(itemName: string, index: number) {
|
||||
const palettes = [
|
||||
{
|
||||
bg: '#fef3c7',
|
||||
accent: '#fb7185',
|
||||
shadow: '#f59e0b',
|
||||
text: '#7c2d12',
|
||||
},
|
||||
{
|
||||
bg: '#dbeafe',
|
||||
accent: '#34d399',
|
||||
shadow: '#60a5fa',
|
||||
text: '#064e3b',
|
||||
},
|
||||
] as const;
|
||||
const palette = palettes[index % palettes.length]!;
|
||||
const displayText = itemName.slice(0, 6);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="96" fill="${palette.bg}"/><circle cx="256" cy="238" r="132" fill="${palette.accent}" opacity=".92"/><ellipse cx="256" cy="356" rx="132" ry="34" fill="${palette.shadow}" opacity=".22"/><circle cx="210" cy="202" r="24" fill="#fff" opacity=".82"/><circle cx="302" cy="202" r="24" fill="#fff" opacity=".82"/><path d="M204 276c30 30 74 30 104 0" fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round"/><text x="256" y="438" text-anchor="middle" font-family="Arial,'Microsoft YaHei',sans-serif" font-size="42" font-weight="700" fill="${palette.text}">${displayText}</text></svg>`;
|
||||
|
||||
return encodeSvgDataUri(svg);
|
||||
}
|
||||
|
||||
function buildItemAsset(
|
||||
itemName: string,
|
||||
index: number,
|
||||
): BabyObjectMatchItemAsset {
|
||||
return {
|
||||
itemId: `baby-object-item-${index + 1}`,
|
||||
itemName,
|
||||
imageSrc: buildPlaceholderItemImage(itemName, index),
|
||||
assetObjectId: null,
|
||||
generationProvider: 'placeholder',
|
||||
prompt: `生成适合 4-8 岁儿童识物分类游戏的${itemName}物品图,绘本草地舞台风格,单个物体,透明或干净背景,无文字、无水印、无按钮。`,
|
||||
};
|
||||
}
|
||||
|
||||
function saveDraftToLocalStore(draft: BabyObjectMatchDraft) {
|
||||
const store = readLocalDraftStore();
|
||||
store[draft.profileId] = draft;
|
||||
writeLocalDraftStore(store);
|
||||
}
|
||||
|
||||
export function normalizeBabyObjectMatchDraft(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): BabyObjectMatchDraft {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
...draft,
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: draft.workTitle.trim() || '宝贝识物',
|
||||
workDescription: draft.workDescription.trim(),
|
||||
itemNames: [
|
||||
draft.itemNames[0].trim(),
|
||||
draft.itemNames[1].trim(),
|
||||
],
|
||||
itemAssets: [
|
||||
{
|
||||
...draft.itemAssets[0],
|
||||
itemName: draft.itemNames[0].trim(),
|
||||
},
|
||||
{
|
||||
...draft.itemAssets[1],
|
||||
itemName: draft.itemNames[1].trim(),
|
||||
},
|
||||
],
|
||||
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
|
||||
updatedAt: draft.updatedAt || now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前为本地 Demo 创作链路。真实 image-2 接入后替换为后端接口,
|
||||
* 但返回契约保持 BabyObjectMatchDraftResponse。
|
||||
*/
|
||||
export async function createBabyObjectMatchDraft(
|
||||
payload: CreateBabyObjectMatchDraftRequest,
|
||||
) {
|
||||
const validated = validateBabyObjectMatchItemNames(payload);
|
||||
if (!validated.valid) {
|
||||
throw new Error('请填写两个物品名称。');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const draftId = createLocalId('baby-object-draft');
|
||||
const profileId = createLocalId('baby-object-profile');
|
||||
const itemNames: [string, string] = [
|
||||
validated.itemAName,
|
||||
validated.itemBName,
|
||||
];
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
draftId,
|
||||
profileId,
|
||||
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workTitle: '宝贝识物',
|
||||
workDescription: `${itemNames[0]}和${itemNames[1]}识物分类`,
|
||||
itemNames,
|
||||
itemAssets: [buildItemAsset(itemNames[0], 0), buildItemAsset(itemNames[1], 1)],
|
||||
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
|
||||
publicationStatus: 'draft',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
saveDraftToLocalStore(draft);
|
||||
return { draft };
|
||||
}
|
||||
|
||||
export async function saveBabyObjectMatchDraft(
|
||||
payload: SaveBabyObjectMatchDraftRequest,
|
||||
) {
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
...payload.draft,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
saveDraftToLocalStore(draft);
|
||||
|
||||
return { draft };
|
||||
}
|
||||
|
||||
export async function publishBabyObjectMatchWork(
|
||||
payload: BabyObjectMatchPublishRequest,
|
||||
): Promise<BabyObjectMatchPublishResponse> {
|
||||
const draft = normalizeBabyObjectMatchDraft({
|
||||
...payload.draft,
|
||||
publicationStatus: 'published',
|
||||
publishedAt: payload.draft.publishedAt ?? new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
saveDraftToLocalStore(draft);
|
||||
|
||||
return {
|
||||
draft,
|
||||
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
|
||||
};
|
||||
}
|
||||
|
||||
export function listLocalBabyObjectMatchDrafts() {
|
||||
return Object.values(readLocalDraftStore()).sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteLocalBabyObjectMatchDraft(profileId: string) {
|
||||
const normalizedProfileId = profileId.trim();
|
||||
if (!normalizedProfileId) {
|
||||
return listLocalBabyObjectMatchDrafts();
|
||||
}
|
||||
|
||||
const store = readLocalDraftStore();
|
||||
delete store[normalizedProfileId];
|
||||
writeLocalDraftStore(store);
|
||||
|
||||
return listLocalBabyObjectMatchDrafts();
|
||||
}
|
||||
|
||||
export const babyObjectMatchClient = {
|
||||
createDraft: createBabyObjectMatchDraft,
|
||||
deleteDraft: deleteLocalBabyObjectMatchDraft,
|
||||
saveDraft: saveBabyObjectMatchDraft,
|
||||
publish: publishBabyObjectMatchWork,
|
||||
listLocalDrafts: listLocalBabyObjectMatchDrafts,
|
||||
};
|
||||
1
src/services/edutainment-baby-object/index.ts
Normal file
1
src/services/edutainment-baby-object/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './babyObjectMatchClient';
|
||||
@@ -8,6 +8,11 @@ export {
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
export {
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
type Match3DRuntimeAdapter,
|
||||
} from './match3dRuntimeAdapter';
|
||||
export {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
|
||||
@@ -496,12 +496,17 @@ export function buildLocalMatch3DOptimisticRun(
|
||||
};
|
||||
}
|
||||
|
||||
function waitForLocalConfirmation(delayMs: number) {
|
||||
const scheduler = globalThis.setTimeout;
|
||||
return new Promise((resolve) => scheduler(resolve, delayMs));
|
||||
}
|
||||
|
||||
export async function confirmLocalMatch3DClick(
|
||||
run: Match3DRunSnapshot,
|
||||
request: Match3DClickItemRequest,
|
||||
): Promise<Match3DClickItemResult> {
|
||||
// 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
||||
await waitForLocalConfirmation(180);
|
||||
const timedRun = normalizeRemainingMs(run);
|
||||
if (timedRun.status !== 'Running') {
|
||||
return {
|
||||
|
||||
144
src/services/match3d-runtime/match3dRuntimeAdapter.test.ts
Normal file
144
src/services/match3d-runtime/match3dRuntimeAdapter.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DRunResponse,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
createLocalMatch3DRuntimeAdapter,
|
||||
createServerMatch3DRuntimeAdapter,
|
||||
type Match3DRuntimeAdapter,
|
||||
startLocalMatch3DRun,
|
||||
} from './index';
|
||||
|
||||
function buildMockRun(runId: string): Match3DRunSnapshot {
|
||||
return {
|
||||
runId,
|
||||
profileId: 'server-profile-1',
|
||||
ownerUserId: 'server-owner-1',
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1_700_000_000_000,
|
||||
durationLimitMs: 30_000,
|
||||
serverNowMs: 1_700_000_000_000,
|
||||
remainingMs: 30_000,
|
||||
clearCount: 3,
|
||||
totalItemCount: 0,
|
||||
clearedItemCount: 0,
|
||||
boardVersion: 1,
|
||||
items: [],
|
||||
traySlots: [],
|
||||
failureReason: null,
|
||||
lastConfirmedActionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
test('server Match3D runtime adapter forwards the full runtime seam lazily', async () => {
|
||||
const startResponse: Match3DRunResponse = { run: buildMockRun('server-run-start') };
|
||||
const getResponse: Match3DRunResponse = { run: buildMockRun('server-run-get') };
|
||||
const restartResponse: Match3DRunResponse = { run: buildMockRun('server-run-restart') };
|
||||
const stopResponse: Match3DRunResponse = {
|
||||
run: { ...buildMockRun('server-run-stop'), status: 'Stopped' },
|
||||
};
|
||||
const finishResponse: Match3DRunResponse = {
|
||||
run: { ...buildMockRun('server-run-finish'), status: 'Timeout' },
|
||||
};
|
||||
const clickPayload: Match3DClickItemRequest = {
|
||||
runId: 'server-run-start',
|
||||
itemInstanceId: 'item-1',
|
||||
clientActionId: 'action-1',
|
||||
clientEventId: 'event-1',
|
||||
clickedAtMs: 1_700_000_000_001,
|
||||
clientSnapshotVersion: 1,
|
||||
};
|
||||
const dependencies = {
|
||||
clickItem: vi.fn().mockResolvedValue({
|
||||
status: 'Accepted' as const,
|
||||
run: buildMockRun('server-run-click'),
|
||||
}),
|
||||
finishTimeUp: vi.fn().mockResolvedValue(finishResponse),
|
||||
getRun: vi.fn().mockResolvedValue(getResponse),
|
||||
restartRun: vi.fn().mockResolvedValue(restartResponse),
|
||||
startRun: vi.fn().mockResolvedValue(startResponse),
|
||||
stopRun: vi.fn().mockResolvedValue(stopResponse),
|
||||
};
|
||||
const adapter = createServerMatch3DRuntimeAdapter(dependencies);
|
||||
|
||||
expect(await adapter.startRun('server-profile-1', { skipRefresh: true })).toBe(
|
||||
startResponse,
|
||||
);
|
||||
expect(await adapter.getRun('server-run-start')).toBe(getResponse);
|
||||
expect(await adapter.clickItem('server-run-start', clickPayload)).toEqual({
|
||||
status: 'Accepted',
|
||||
run: buildMockRun('server-run-click'),
|
||||
});
|
||||
expect(await adapter.restartRun('server-run-start')).toBe(restartResponse);
|
||||
expect(await adapter.stopRun('server-run-restart')).toBe(stopResponse);
|
||||
expect(await adapter.finishTimeUp('server-run-start')).toBe(finishResponse);
|
||||
|
||||
expect(dependencies.startRun).toHaveBeenCalledWith('server-profile-1', {
|
||||
skipRefresh: true,
|
||||
});
|
||||
expect(dependencies.getRun).toHaveBeenCalledWith('server-run-start');
|
||||
expect(dependencies.clickItem).toHaveBeenCalledWith(
|
||||
'server-run-start',
|
||||
clickPayload,
|
||||
);
|
||||
expect(dependencies.restartRun).toHaveBeenCalledWith('server-run-start');
|
||||
expect(dependencies.stopRun).toHaveBeenCalledWith('server-run-restart');
|
||||
expect(dependencies.finishTimeUp).toHaveBeenCalledWith('server-run-start');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter exposes the same runtime seam as the server client', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ clearCount: 1 });
|
||||
const started = await adapter.startRun('ignored-local-profile');
|
||||
const clickableItem = started.run.items.find((item) => item.clickable);
|
||||
|
||||
expect(started.run.profileId).toBe('local-match3d-profile');
|
||||
expect(clickableItem).toBeTruthy();
|
||||
|
||||
const clickResult = await adapter.clickItem(started.run.runId, {
|
||||
runId: started.run.runId,
|
||||
itemInstanceId: clickableItem!.itemInstanceId,
|
||||
clientActionId: 'local-click-1',
|
||||
clientEventId: 'local-event-1',
|
||||
clickedAtMs: started.run.serverNowMs ?? Date.now(),
|
||||
clientSnapshotVersion: started.run.snapshotVersion,
|
||||
});
|
||||
|
||||
expect(clickResult.status).toBe('Accepted');
|
||||
expect(clickResult.run.snapshotVersion).toBe(started.run.snapshotVersion + 1);
|
||||
|
||||
const restarted = await adapter.restartRun(started.run.runId);
|
||||
expect(restarted.run.runId).not.toBe(started.run.runId);
|
||||
|
||||
const stopped = await adapter.stopRun(restarted.run.runId);
|
||||
expect(stopped.run.status).toBe('Stopped');
|
||||
});
|
||||
|
||||
test('local Match3D runtime adapter keeps authority run local to the adapter', async () => {
|
||||
const adapter = createLocalMatch3DRuntimeAdapter({ initialRun: startLocalMatch3DRun(1) });
|
||||
const first = await adapter.getRun('unused-run-id');
|
||||
const timedOut = await adapter.finishTimeUp(first.run.runId);
|
||||
|
||||
expect(timedOut.run.status).toBe('Running');
|
||||
expect(timedOut.run.runId).toBe(first.run.runId);
|
||||
});
|
||||
|
||||
test('server and local Match3D runtime adapters share the same runtime seam', () => {
|
||||
const adapters: Match3DRuntimeAdapter[] = [
|
||||
createLocalMatch3DRuntimeAdapter({ clearCount: 1 }),
|
||||
createServerMatch3DRuntimeAdapter(),
|
||||
];
|
||||
|
||||
expect(adapters).toHaveLength(2);
|
||||
for (const adapter of adapters) {
|
||||
expect(typeof adapter.startRun).toBe('function');
|
||||
expect(typeof adapter.getRun).toBe('function');
|
||||
expect(typeof adapter.clickItem).toBe('function');
|
||||
expect(typeof adapter.restartRun).toBe('function');
|
||||
expect(typeof adapter.stopRun).toBe('function');
|
||||
expect(typeof adapter.finishTimeUp).toBe('function');
|
||||
}
|
||||
});
|
||||
106
src/services/match3d-runtime/match3dRuntimeAdapter.ts
Normal file
106
src/services/match3d-runtime/match3dRuntimeAdapter.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DRunResponse,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
confirmLocalMatch3DClick,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
} from './match3dLocalRuntime';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
getMatch3DRun,
|
||||
type Match3DRuntimeRequestOptions,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from './match3dRuntimeClient';
|
||||
|
||||
export type Match3DRuntimeAdapter = {
|
||||
startRun: (
|
||||
profileId: string,
|
||||
options?: Match3DRuntimeRequestOptions,
|
||||
) => Promise<Match3DRunResponse>;
|
||||
getRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
clickItem: (
|
||||
runId: string,
|
||||
payload: Match3DClickItemRequest,
|
||||
) => Promise<Match3DClickItemResult>;
|
||||
restartRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
stopRun: (runId: string) => Promise<Match3DRunResponse>;
|
||||
finishTimeUp: (runId: string) => Promise<Match3DRunResponse>;
|
||||
};
|
||||
|
||||
export type LocalMatch3DRuntimeAdapterOptions = {
|
||||
clearCount?: number;
|
||||
initialRun?: Match3DRunResponse['run'];
|
||||
};
|
||||
|
||||
type ServerMatch3DRuntimeAdapterDependencies = {
|
||||
clickItem: typeof clickMatch3DItem;
|
||||
finishTimeUp: typeof finishMatch3DTimeUp;
|
||||
getRun: typeof getMatch3DRun;
|
||||
restartRun: typeof restartMatch3DRun;
|
||||
startRun: typeof startMatch3DRun;
|
||||
stopRun: typeof stopMatch3DRun;
|
||||
};
|
||||
|
||||
const defaultServerMatch3DRuntimeAdapterDependencies: ServerMatch3DRuntimeAdapterDependencies = {
|
||||
clickItem: clickMatch3DItem,
|
||||
finishTimeUp: finishMatch3DTimeUp,
|
||||
getRun: getMatch3DRun,
|
||||
restartRun: restartMatch3DRun,
|
||||
startRun: startMatch3DRun,
|
||||
stopRun: stopMatch3DRun,
|
||||
};
|
||||
|
||||
export function createServerMatch3DRuntimeAdapter(
|
||||
dependencies: ServerMatch3DRuntimeAdapterDependencies =
|
||||
defaultServerMatch3DRuntimeAdapterDependencies,
|
||||
): Match3DRuntimeAdapter {
|
||||
return {
|
||||
clickItem: (runId, payload) => dependencies.clickItem(runId, payload),
|
||||
finishTimeUp: (runId) => dependencies.finishTimeUp(runId),
|
||||
getRun: (runId) => dependencies.getRun(runId),
|
||||
restartRun: (runId) => dependencies.restartRun(runId),
|
||||
startRun: (profileId, options) => dependencies.startRun(profileId, options),
|
||||
stopRun: (runId) => dependencies.stopRun(runId),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocalMatch3DRuntimeAdapter(
|
||||
options: LocalMatch3DRuntimeAdapterOptions = {},
|
||||
): Match3DRuntimeAdapter {
|
||||
let authorityRun = options.initialRun ?? startLocalMatch3DRun(options.clearCount);
|
||||
|
||||
return {
|
||||
async startRun() {
|
||||
authorityRun = startLocalMatch3DRun(options.clearCount);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async getRun() {
|
||||
authorityRun = resolveLocalMatch3DTimer(authorityRun);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async clickItem(_runId, payload) {
|
||||
const result = await confirmLocalMatch3DClick(authorityRun, payload);
|
||||
authorityRun = result.run;
|
||||
return result;
|
||||
},
|
||||
async restartRun() {
|
||||
authorityRun = startLocalMatch3DRun(options.clearCount);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async stopRun() {
|
||||
authorityRun = stopLocalMatch3DRun(authorityRun);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
async finishTimeUp() {
|
||||
authorityRun = resolveLocalMatch3DTimer(authorityRun);
|
||||
return { run: authorityRun };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
type Match3DRuntimeRequestOptions = Pick<
|
||||
export type Match3DRuntimeRequestOptions = Pick<
|
||||
ApiRequestOptions,
|
||||
| 'authImpact'
|
||||
| 'skipRefresh'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBabyObjectMatchGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
buildPuzzleGenerationAnchorEntries,
|
||||
@@ -227,6 +228,37 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('baby object match generation exposes two item names', () => {
|
||||
const state = createMiniGameDraftGenerationState('baby-object-match');
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 9_000,
|
||||
);
|
||||
const entries = buildBabyObjectMatchGenerationAnchorEntries({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'baby-object-draft',
|
||||
'baby-object-images',
|
||||
'baby-object-ready',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('baby-object-images');
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'baby-object-item-1',
|
||||
label: '物品 1',
|
||||
value: '苹果',
|
||||
},
|
||||
{
|
||||
id: 'baby-object-item-2',
|
||||
label: '物品 2',
|
||||
value: '香蕉',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('puzzle generation anchors expose form payload as the display source', () => {
|
||||
const entries = buildPuzzleGenerationAnchorEntries({
|
||||
sessionId: 'puzzle-session-1',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||||
import type {
|
||||
BabyObjectMatchDraft,
|
||||
CreateBabyObjectMatchDraftRequest,
|
||||
} from '../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
@@ -18,7 +22,8 @@ export type MiniGameDraftGenerationKind =
|
||||
| 'puzzle'
|
||||
| 'big-fish'
|
||||
| 'square-hole'
|
||||
| 'match3d';
|
||||
| 'match3d'
|
||||
| 'baby-object-match';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
@@ -37,6 +42,9 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-generate-views'
|
||||
| 'match3d-ready'
|
||||
| 'baby-object-draft'
|
||||
| 'baby-object-images'
|
||||
| 'baby-object-ready'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-select-image'
|
||||
| 'ready'
|
||||
@@ -191,6 +199,27 @@ const MATCH3D_PHASE_ORDER: Partial<
|
||||
'match3d-generate-views': 5,
|
||||
};
|
||||
|
||||
const BABY_OBJECT_MATCH_STEPS = [
|
||||
{
|
||||
id: 'baby-object-draft',
|
||||
label: '整理识物草稿',
|
||||
detail: '写入两个物品名称与寓教于乐标签。',
|
||||
weight: 22,
|
||||
},
|
||||
{
|
||||
id: 'baby-object-images',
|
||||
label: '生成物品图',
|
||||
detail: '为两个物品准备绘本风格图片资产。',
|
||||
weight: 68,
|
||||
},
|
||||
{
|
||||
id: 'baby-object-ready',
|
||||
label: '准备结果页',
|
||||
detail: '校验草稿字段并进入结果页。',
|
||||
weight: 10,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
@@ -205,6 +234,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'match3d') {
|
||||
return MATCH3D_STEPS;
|
||||
}
|
||||
if (kind === 'baby-object-match') {
|
||||
return BABY_OBJECT_MATCH_STEPS;
|
||||
}
|
||||
return BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
@@ -260,7 +292,9 @@ export function createMiniGameDraftGenerationState(
|
||||
? 'square-hole-draft'
|
||||
: kind === 'match3d'
|
||||
? 'match3d-work-title'
|
||||
: 'compile',
|
||||
: kind === 'baby-object-match'
|
||||
? 'baby-object-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
@@ -313,6 +347,18 @@ function resolveMatch3DPhaseByElapsedMs(
|
||||
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
|
||||
}
|
||||
|
||||
function resolveBabyObjectMatchPhaseByElapsedMs(
|
||||
elapsedMs: number,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 52_000) {
|
||||
return 'baby-object-ready';
|
||||
}
|
||||
if (elapsedMs >= 8_000) {
|
||||
return 'baby-object-images';
|
||||
}
|
||||
return 'baby-object-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
@@ -360,27 +406,34 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
phase: puzzleTimeline.phase,
|
||||
}
|
||||
: state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'square-hole' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state.kind === 'match3d' &&
|
||||
: state.kind === 'square-hole' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
: state.kind === 'match3d' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
|
||||
}
|
||||
: state.kind === 'baby-object-match' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
@@ -401,13 +454,15 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 1
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? (puzzleTimeline?.activeStepProgressRatio ?? 0)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: 0;
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? 0.42
|
||||
: normalizedState.kind === 'match3d'
|
||||
? 0.5
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? 0.52
|
||||
: 0;
|
||||
const overallProgress =
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
@@ -436,7 +491,9 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||
: normalizedState.kind === 'match3d'
|
||||
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(cappedOverallProgress),
|
||||
@@ -448,13 +505,15 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
? 0
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 10 * 60_000 - elapsedMs)
|
||||
: null,
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
? Math.max(0, 12_000 - elapsedMs)
|
||||
: normalizedState.kind === 'match3d'
|
||||
? Math.max(0, 10 * 60_000 - elapsedMs)
|
||||
: normalizedState.kind === 'baby-object-match'
|
||||
? Math.max(0, 60_000 - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(
|
||||
steps,
|
||||
@@ -600,6 +659,22 @@ function resolveMatch3DGeneratedItemCount(
|
||||
return 21;
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchGenerationAnchorEntries(
|
||||
formPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
|
||||
draft: BabyObjectMatchDraft | null | undefined = null,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
const itemNames =
|
||||
formPayload?.itemAName?.trim() || formPayload?.itemBName?.trim()
|
||||
? [formPayload.itemAName.trim(), formPayload.itemBName.trim()]
|
||||
: (draft?.itemNames ?? []);
|
||||
|
||||
return itemNames.filter(Boolean).map((value, index) => ({
|
||||
id: `baby-object-item-${index + 1}`,
|
||||
label: `物品 ${index + 1}`,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSquareHoleGenerationAnchorEntries(
|
||||
session: SquareHoleSessionSnapshot | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
|
||||
@@ -45,6 +45,14 @@ export function buildVisualNovelPublicWorkCode(profileId: string) {
|
||||
return `VN-${suffix}`;
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `BO-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
@@ -103,3 +111,16 @@ export function isSameVisualNovelPublicWorkCode(
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSameBabyObjectMatchPublicWorkCode(
|
||||
keyword: string,
|
||||
profileId: string,
|
||||
) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildBabyObjectMatchPublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user