This commit is contained in:
2026-05-13 03:10:55 +08:00
154 changed files with 16812 additions and 708 deletions

View File

@@ -0,0 +1,5 @@
import { BarkBattleRuntimeShell } from './games/bark-battle/ui/BarkBattleRuntimeShell';
export default function BarkBattlePlaygroundApp() {
return <BarkBattleRuntimeShell />;
}

View File

@@ -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

View File

@@ -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 />);

View File

@@ -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>

View File

@@ -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', () => {

View File

@@ -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(

View File

@@ -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)}

View File

@@ -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();
});

View File

@@ -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;

View File

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

View 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;

View File

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

View 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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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

View File

@@ -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(

View File

@@ -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';
}

View File

@@ -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]);
});
});

View File

@@ -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']);
});

View File

@@ -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 [

View File

@@ -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'

View File

@@ -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');

View File

@@ -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();

View File

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

View File

@@ -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('寓教于乐');
});

View File

@@ -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;
}

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

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

View File

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

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

View 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: [],
});
}

View 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[];
};

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

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

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

View File

@@ -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');
});
});

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

View File

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

View File

@@ -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;
}
}

View File

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

View 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; }
}

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

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

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

View 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();
});
});

View File

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

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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'],

View File

@@ -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',

View File

@@ -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),
},
'绑定手机号失败',
);

View File

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

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

View File

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

View File

@@ -8,6 +8,11 @@ export {
startLocalMatch3DRun,
stopLocalMatch3DRun,
} from './match3dLocalRuntime';
export {
createLocalMatch3DRuntimeAdapter,
createServerMatch3DRuntimeAdapter,
type Match3DRuntimeAdapter,
} from './match3dRuntimeAdapter';
export {
clickMatch3DItem,
finishMatch3DTimeUp,

View File

@@ -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 {

View 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');
}
});

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

View File

@@ -25,7 +25,7 @@ const MATCH3D_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxDelayMs: 360,
retryUnsafeMethods: true,
};
type Match3DRuntimeRequestOptions = Pick<
export type Match3DRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'

View File

@@ -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',

View File

@@ -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[] {

View File

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