Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03

# Conflicts:
#	server-rs/crates/api-server/src/app.rs
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/puzzle.rs
#	server-rs/crates/spacetime-client/src/lib.rs
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
This commit is contained in:
2026-05-14 19:17:17 +08:00
495 changed files with 40663 additions and 5654 deletions

View File

@@ -1,4 +1,4 @@
import {
import {
type ReactNode,
useDeferredValue,
useEffect,
@@ -1199,7 +1199,7 @@ export function CustomWorldEntityCatalog({
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
</span>
) : null}
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">

View File

@@ -31,6 +31,8 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[];
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
revokingSessionIds?: string[];
initialSection?:
| 'appearance'
| 'account'
@@ -63,7 +65,10 @@ function renderAccountModal(overrides?: {
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
onRevokeSession={
overrides?.onRevokeSession ?? vi.fn().mockResolvedValue(undefined)
}
revokingSessionIds={overrides?.revokingSessionIds ?? []}
changePhoneCaptchaChallenge={null}
onSendChangePhoneCode={vi.fn().mockResolvedValue({
cooldownSeconds: 60,
@@ -75,6 +80,30 @@ function renderAccountModal(overrides?: {
);
}
function buildSession(
overrides: Partial<AuthSessionSummary> = {},
): AuthSessionSummary {
return {
sessionId: 'usess_1',
sessionIds: ['usess_1'],
sessionCount: 1,
clientType: 'web_browser',
clientRuntime: 'chrome',
clientPlatform: 'windows',
clientLabel: 'Windows / Chrome',
deviceDisplayName: 'Windows / Chrome',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0',
ipMasked: '203.0.*.*',
isCurrent: false,
createdAt: '2026-05-01T10:00:00.000Z',
lastSeenAt: '2026-05-01T10:30:00.000Z',
expiresAt: '2026-06-01T10:30:00.000Z',
...overrides,
};
}
test('settings header uses a generic title instead of the phone number', () => {
renderAccountModal();
@@ -238,8 +267,10 @@ test('account panel includes merged security devices and audit sections', async
},
],
sessions: [
{
buildSession({
sessionId: 'session-1',
sessionIds: ['session-1'],
sessionCount: 1,
clientType: 'mobile',
clientRuntime: 'ios',
clientPlatform: 'wechat',
@@ -253,7 +284,7 @@ test('account panel includes merged security devices and audit sections', async
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
},
}),
],
auditLogs: [
{
@@ -294,3 +325,77 @@ test('legacy nested section requests now open the merged account panel', () => {
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
});
test('current merged session group hides kick action and shows count', async () => {
const user = userEvent.setup();
renderAccountModal({
sessions: [
buildSession({
sessionId: 'usess_current',
sessionIds: ['usess_current', 'usess_rotated'],
sessionCount: 2,
isCurrent: true,
}),
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '踢下线' }),
).toBeNull();
});
test('remote merged session group can be revoked with loading state', async () => {
const user = userEvent.setup();
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
const remoteSession = buildSession({
sessionId: 'usess_remote',
sessionIds: ['usess_remote', 'usess_remote_rotated'],
sessionCount: 2,
});
renderAccountModal({
sessions: [remoteSession],
onRevokeSession,
revokingSessionIds: ['usess_remote'],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const revokeButton = within(accountDialog).getByRole('button', {
name: '处理中...',
}) as HTMLButtonElement;
expect(revokeButton.disabled).toBe(true);
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
expect(onRevokeSession).not.toHaveBeenCalled();
});
test('remote session revoke passes the grouped session payload', async () => {
const user = userEvent.setup();
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
const remoteSession = buildSession({
sessionId: 'usess_remote',
sessionIds: ['usess_remote', 'usess_remote_rotated'],
sessionCount: 2,
});
renderAccountModal({
sessions: [remoteSession],
onRevokeSession,
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
'button',
{ name: '踢下线' },
),
);
expect(onRevokeSession).toHaveBeenCalledWith(remoteSession);
});

View File

@@ -40,7 +40,8 @@ type AccountModalProps = {
onRefreshSessions: () => Promise<void>;
onLogoutAll: () => Promise<void>;
onRefreshAuditLogs: () => Promise<void>;
onRevokeSession: (sessionId: string) => Promise<void>;
onRevokeSession: (session: AuthSessionSummary) => Promise<void>;
revokingSessionIds: string[];
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
onSendChangePhoneCode: (
phone: string,
@@ -298,6 +299,7 @@ export function AccountModal({
onLogoutAll,
onRefreshAuditLogs,
onRevokeSession,
revokingSessionIds,
changePhoneCaptchaChallenge,
onSendChangePhoneCode,
onChangePhone,
@@ -759,41 +761,55 @@ export function AccountModal({
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
sessions.map((session) => {
const isRevoking = revokingSessionIds.includes(
session.sessionId,
);
return (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<div className="flex shrink-0 items-center gap-2">
{session.sessionCount > 1 ? (
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{session.sessionCount}
</span>
) : null}
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs"
disabled={isRevoking}
onClick={() => {
void onRevokeSession(session);
}}
>
{isRevoking ? '处理中...' : '踢下线'}
</button>
) : null}
</div>
);
})
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">

View File

@@ -5,7 +5,8 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
@@ -22,6 +23,10 @@ const authMocks = vi.hoisted(() => ({
logoutAuthUser: vi.fn(),
redeemRegistrationInviteCode: vi.fn(),
resetPassword: vi.fn(),
getAuthAuditLogs: vi.fn(),
getAuthRiskBlocks: vi.fn(),
getAuthSessions: vi.fn(),
revokeAuthSessions: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
@@ -41,11 +46,11 @@ vi.mock('../../services/authService', () => ({
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthAuditLogs: authMocks.getAuthAuditLogs,
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: vi.fn(),
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: vi.fn(),
getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null),
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
@@ -53,7 +58,7 @@ vi.mock('../../services/authService', () => ({
logoutAuthUser: authMocks.logoutAuthUser,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
revokeAuthSessions: authMocks.revokeAuthSessions,
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
startWechatLogin: authMocks.startWechatLogin,
@@ -72,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({
}),
}));
vi.mock('./AccountModal', () => ({
AccountModal: () => null,
}));
vi.mock('./AccountModal', async () => {
const actual =
await vi.importActual<typeof import('./AccountModal')>('./AccountModal');
return actual;
});
vi.mock('./BindPhoneScreen', () => ({
BindPhoneScreen: () => <div></div>,
@@ -95,6 +103,7 @@ const mockUser: AuthUser = {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
@@ -114,6 +123,10 @@ beforeEach(() => {
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
authMocks.logoutAuthUser.mockResolvedValue(undefined);
authMocks.getAuthAuditLogs.mockResolvedValue([]);
authMocks.getAuthRiskBlocks.mockResolvedValue([]);
authMocks.getAuthSessions.mockResolvedValue([]);
authMocks.revokeAuthSessions.mockResolvedValue(undefined);
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
center: {
inviteCode: 'SY12345678',
@@ -141,6 +154,15 @@ beforeEach(() => {
authMocks.startWechatLogin.mockResolvedValue(undefined);
});
async function acceptLegalConsent(
user: ReturnType<typeof userEvent.setup>,
dialog: HTMLElement,
) {
await user.click(
within(dialog).getByRole('switch', { name: '同意法律协议' }),
);
}
function ProtectedActionButton({
onAuthenticated,
}: {
@@ -194,6 +216,21 @@ function LogoutStateProbe() {
);
}
function AccountPanelProbe() {
const authUi = useAuthUi();
return (
<button
type="button"
onClick={() => {
authUi?.openAccountModal();
}}
>
</button>
);
}
test('auth gate keeps platform content visible when phone login is available', async () => {
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -346,6 +383,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
@@ -360,6 +398,70 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('login modal requires first-time legal consent before sms login', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
const loginButton = within(dialog).getByRole('button', { name: '登录' });
const legalSwitch = within(dialog).getByRole('switch', {
name: '同意法律协议',
});
expect((loginButton as HTMLButtonElement).disabled).toBe(true);
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
await user.click(
within(dialog).getByRole('button', { name: '《用户协议》' }),
);
expect(
await screen.findByRole('dialog', { name: '用户协议' }),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '我知道了' }));
expect(legalSwitch.getAttribute('aria-checked')).toBe('false');
await user.click(legalSwitch);
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
expect(window.localStorage.getItem(LEGAL_CONSENT_STORAGE_KEY)).toBe('true');
expect((loginButton as HTMLButtonElement).disabled).toBe(false);
});
test('login modal defaults legal consent to checked after stored confirmation', async () => {
const user = userEvent.setup();
window.localStorage.setItem(LEGAL_CONSENT_STORAGE_KEY, 'true');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
const legalSwitch = within(dialog).getByRole('switch', {
name: '同意法律协议',
});
expect(legalSwitch.getAttribute('aria-checked')).toBe('true');
});
test('phone login result is not overwritten by an older guest hydrate', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
@@ -387,6 +489,7 @@ test('phone login result is not overwritten by an older guest hydrate', async ()
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
@@ -425,6 +528,7 @@ test('auth gate hides register entry and opens invite modal for new sms account'
expect(within(dialog).queryByLabelText('邀请码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
@@ -475,6 +579,7 @@ test('registration invite modal can skip when invite code is empty', async () =>
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
const inviteDialog = await screen.findByRole('dialog', {
@@ -700,9 +805,108 @@ test('auth gate separates sms and password login by tabs', async () => {
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
});
});
test('auth gate revokes merged session group and refreshes sessions', async () => {
const user = userEvent.setup();
const initialSessions: AuthSessionSummary[] = [
{
sessionId: 'usess_remote',
sessionIds: ['usess_remote', 'usess_remote_rotated'],
sessionCount: 2,
clientType: 'web_browser',
clientRuntime: 'chrome',
clientPlatform: 'windows',
clientLabel: 'Windows / Chrome',
deviceDisplayName: 'Windows / Chrome',
miniProgramAppId: null,
miniProgramEnv: null,
userAgent: 'Mozilla/5.0',
ipMasked: '203.0.*.*',
isCurrent: false,
createdAt: '2026-05-01T10:00:00.000Z',
lastSeenAt: '2026-05-01T10:30:00.000Z',
expiresAt: '2026-06-01T10:30:00.000Z',
},
];
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
authMocks.getAuthSessions
.mockResolvedValueOnce(initialSessions)
.mockResolvedValueOnce([]);
render(
<AuthGate>
<AccountPanelProbe />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '打开账号面板' }));
const accountDialog = await screen.findByRole('dialog', {
name: '账号信息',
});
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
await waitFor(() => {
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
'usess_remote',
'usess_remote_rotated',
]);
expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2);
});
});
test('auth gate clears account state after password change', async () => {
const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
});
authMocks.getAuthSessions.mockResolvedValue([]);
authMocks.changePassword.mockResolvedValue(mockUser);
render(
<AuthGate>
<div>
<LogoutStateProbe />
<AccountPanelProbe />
</div>
</AuthGate>,
);
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '打开账号面板' }));
const accountDialog = await screen.findByRole('dialog', {
name: '账号信息',
});
await user.click(
within(accountDialog).getByRole('button', { name: '修改密码' }),
);
const passwordDialog = await screen.findByRole('dialog', {
name: '修改登录密码',
});
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
await user.click(
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),
);
await waitFor(() => {
expect(authMocks.changePassword).toHaveBeenCalledWith(
'oldpass1',
'newpass1',
);
expect(screen.getByText('当前用户:未登录')).toBeTruthy();
});
expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull();
});

View File

@@ -38,7 +38,7 @@ import {
logoutAuthUser,
redeemRegistrationInviteCode,
resetPassword,
revokeAuthSession,
revokeAuthSessions,
sendPhoneLoginCode,
setStoredLastLoginPhone,
startWechatLogin,
@@ -121,6 +121,7 @@ export function AuthGate({ children }: AuthGateProps) {
useState<PlatformSettingsSection | null>(null);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [revokingSessionIds, setRevokingSessionIds] = useState<string[]>([]);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
@@ -167,6 +168,7 @@ export function AuthGate({ children }: AuthGateProps) {
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
@@ -691,6 +693,7 @@ export function AuthGate({ children }: AuthGateProps) {
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
revokingSessionIds={revokingSessionIds}
isHydratingSettings={settings.isHydratingSettings}
isPersistingSettings={settings.isPersistingSettings}
settingsError={settings.settingsError}
@@ -752,14 +755,17 @@ export function AuthGate({ children }: AuthGateProps) {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
onRevokeSession={async (session) => {
const sessionIds =
session.sessionIds.length > 0
? session.sessionIds
: [session.sessionId];
setRevokingSessionIds((current) =>
Array.from(new Set([...current, session.sessionId])),
);
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter(
(session) => session.sessionId !== sessionId,
),
);
await revokeAuthSessions(sessionIds);
setSessions(await getAuthSessions());
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
@@ -767,6 +773,10 @@ export function AuthGate({ children }: AuthGateProps) {
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
} finally {
setRevokingSessionIds((current) =>
current.filter((id) => id !== session.sessionId),
);
}
}}
onLogoutAll={logoutAllSessions}
@@ -795,11 +805,8 @@ export function AuthGate({ children }: AuthGateProps) {
setUser(nextUser);
}}
onChangePassword={async (currentPassword, newPassword) => {
const nextUser = await changePassword(
currentPassword,
newPassword,
);
setUser(nextUser);
await changePassword(currentPassword, newPassword);
clearLocalAuthenticatedState();
}}
/>
) : null}

View File

@@ -62,7 +62,7 @@ export function BindPhoneScreen({
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">

View File

@@ -1,5 +1,5 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Check, X } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
@@ -7,6 +7,13 @@ import type {
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
type LegalDocumentId,
persistLegalConsent,
readStoredLegalConsent,
} from '../common/legalDocuments';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
@@ -70,6 +77,9 @@ export function LoginScreen({
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const [legalConsentChecked, setLegalConsentChecked] = useState(false);
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
@@ -92,6 +102,8 @@ export function LoginScreen({
setCooldownSeconds(0);
setResetCooldownSeconds(0);
setHint('');
setLegalConsentChecked(readStoredLegalConsent());
setActiveLegalDocumentId(null);
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password');
}, [isOpen, phoneLoginEnabled]);
@@ -143,89 +155,117 @@ export function LoginScreen({
}
const submitDisabled = loggingIn || sendingCode;
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const toggleLegalConsent = () => {
setLegalConsentChecked((current) => {
const nextChecked = !current;
if (nextChecked) {
persistLegalConsent();
}
return nextChecked;
});
};
const legalConsentRow = (
<LegalConsentRow
checked={legalConsentChecked}
onToggle={toggleLegalConsent}
onOpenDocument={setActiveLegalDocumentId}
/>
);
return (
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
>
<>
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-login-dialog-title"
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
onClick={(event) => event.stopPropagation()}
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[120] flex items-end justify-center px-3 py-4 text-[var(--platform-text-strong)] backdrop-blur-sm sm:items-center sm:p-4`}
onClick={onClose}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="auth-login-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
{isResetPanelOpen ? '重置密码' : '账号入口'}
<div
role="dialog"
aria-modal="true"
aria-labelledby="auth-login-dialog-title"
className="platform-auth-card w-full max-w-md overflow-hidden rounded-[2rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div
id="auth-login-dialog-title"
className="text-lg font-semibold text-[var(--platform-text-strong)]"
>
{isResetPanelOpen ? '重置密码' : '账号入口'}
</div>
<button
type="button"
onClick={onClose}
className="platform-icon-button p-2"
aria-label="关闭登录弹窗"
>
<X className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={onClose}
className="platform-icon-button p-2"
aria-label="关闭登录弹窗"
>
<X className="h-4 w-4" />
</button>
</div>
{isResetPanelOpen ? (
<PasswordResetPanel
phone={resetPhone}
code={resetCode}
password={resetPasswordValue}
sendingCode={sendingCode}
loggingIn={loggingIn}
cooldownSeconds={resetCooldownSeconds}
error={error}
onPhoneChange={setResetPhone}
onCodeChange={setResetCode}
onPasswordChange={setResetPasswordValue}
onBack={() => setIsResetPanelOpen(false)}
onSendCode={async () => {
const result = await onSendCode(resetPhone, 'reset_password');
setResetCooldownSeconds(result.cooldownSeconds);
}}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
{isResetPanelOpen ? (
<PasswordResetPanel
phone={resetPhone}
code={resetCode}
password={resetPasswordValue}
sendingCode={sendingCode}
loggingIn={loggingIn}
cooldownSeconds={resetCooldownSeconds}
error={error}
onPhoneChange={setResetPhone}
onCodeChange={setResetCode}
onPasswordChange={setResetPasswordValue}
onBack={() => setIsResetPanelOpen(false)}
onSendCode={async () => {
const result = await onSendCode(resetPhone, 'reset_password');
setResetCooldownSeconds(result.cooldownSeconds);
}}
onSubmit={() =>
onResetPassword(resetPhone, resetCode, resetPasswordValue)
}
/>
) : (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled ? (
<div
className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-2' : 'grid-cols-1'
}`}
role="tablist"
aria-label="登录方式"
>
</LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
) : null}
</div>
) : null}
{passwordLoginEnabled ? (
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
) : null}
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
) {
return;
}
void onPasswordSubmit(phone, password);
}}
>
@@ -253,12 +293,16 @@ export function LoginScreen({
</label>
{error ? <ErrorBanner message={error} /> : null}
{legalConsentRow}
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={
submitDisabled || !phone.trim() || !password.trim()
submitDisabled ||
!phone.trim() ||
!password.trim() ||
!legalConsentChecked
}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
@@ -296,6 +340,8 @@ export function LoginScreen({
hint={hint}
submitLabel="登录"
enabled={phoneLoginEnabled}
legalConsentChecked={legalConsentChecked}
legalConsentNode={legalConsentRow}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
@@ -323,13 +369,89 @@ export function LoginScreen({
</div>
) : null}
</div>
)}
</div>
)}
</div>
</div>
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
platformTheme={platformTheme}
onClose={() => setActiveLegalDocumentId(null)}
/>
</>
);
}
function LegalConsentRow({
checked,
onToggle,
onOpenDocument,
}: {
checked: boolean;
onToggle: () => void;
onOpenDocument: (documentId: LegalDocumentId) => void;
}) {
const openDocument = (documentId: LegalDocumentId) => {
onOpenDocument(documentId);
};
return (
<div className="flex items-start gap-2.5 text-xs leading-5 text-[var(--platform-text-base)]">
<button
type="button"
role="switch"
aria-checked={checked}
aria-label="同意法律协议"
onClick={onToggle}
className={`mt-0.5 flex h-5 w-9 shrink-0 items-center rounded-full border p-0.5 transition ${
checked
? 'justify-end border-[var(--platform-button-primary-border)] [background:var(--platform-profile-action-fill)]'
: 'justify-start border-[var(--platform-subpanel-border)] [background:var(--platform-button-secondary-fill)]'
}`}
>
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--platform-button-primary-text)] text-[var(--platform-cool-text)] shadow-sm">
{checked ? <Check className="h-3 w-3" /> : null}
</span>
</button>
<div>
<LegalLink
label="《用户协议》"
onClick={() => openDocument('user-agreement')}
/>
<LegalLink
label="《隐私政策》"
onClick={() => openDocument('privacy-policy')}
/>
<LegalLink
label="《免责声明》"
onClick={() => openDocument('disclaimer')}
/>
</div>
</div>
);
}
function LegalLink({
label,
onClick,
}: {
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
className="mx-0.5 align-baseline font-semibold text-[var(--platform-cool-text)] underline-offset-2 hover:underline"
onClick={onClick}
>
{label}
</button>
);
}
function LoginTabButton({
active,
children,
@@ -371,6 +493,8 @@ function PhoneCodeForm({
hint,
submitLabel,
enabled,
legalConsentChecked,
legalConsentNode,
showPhoneField,
onPhoneChange,
onCodeChange,
@@ -389,6 +513,8 @@ function PhoneCodeForm({
hint: string;
submitLabel: string;
enabled: boolean;
legalConsentChecked: boolean;
legalConsentNode: ReactNode;
showPhoneField: boolean;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
@@ -400,11 +526,17 @@ function PhoneCodeForm({
return null;
}
const submitBlocked =
loggingIn || !phone.trim() || !code.trim() || !legalConsentChecked;
return (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (submitBlocked) {
return;
}
void onSubmit();
}}
>
@@ -455,10 +587,11 @@ function PhoneCodeForm({
{hint ? <SuccessBanner message={hint} /> : null}
{error ? <ErrorBanner message={error} /> : null}
{legalConsentNode}
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
disabled={submitBlocked}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '处理中' : submitLabel}

View File

@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { ChildMotionWarmupDemo } from './ChildMotionWarmupDemo';
@@ -13,11 +14,41 @@ const mocapMock = vi.hoisted(() => ({
status: 'connected' as 'idle' | 'connecting' | 'connected' | 'error',
command: null as null | {
actions: string[];
hands?: Array<{ x: number; y: number; state: string; side: string }>;
primaryHand?: { x: number; y: number; state: string; side: string } | null;
leftHand?: { x: number; y: number; state: string; side: string } | null;
rightHand?: { x: number; y: number; state: string; side: string } | null;
hands?: Array<{
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
}>;
primaryHand?: {
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
} | null;
leftHand?: {
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
} | null;
rightHand?: {
x: number;
y: number;
state: string;
side: string;
wrist?: { x: number; y: number } | null;
} | null;
bodyCenter?: { x: number; y: number } | null;
bodyJoints?: {
leftShoulder?: { x: number; y: number } | null;
rightShoulder?: { x: number; y: number } | null;
leftElbow?: { x: number; y: number } | null;
rightElbow?: { x: number; y: number } | null;
};
},
receivedAtMs: 1,
}));
@@ -37,6 +68,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();
@@ -54,15 +97,170 @@ afterEach(() => {
vi.restoreAllMocks();
});
function setMocapBodyCenter(x: number) {
mocapMock.command = {
actions: [],
bodyCenter: { x, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
mocapMock.receivedAtMs += 1;
}
async function advanceWarmupTime(ms: number) {
await act(async () => {
vi.advanceTimersByTime(ms);
});
}
async function revealCurrentStepCue() {
await advanceWarmupTime(1100);
}
async function completeCurrentPositionStepByHold() {
await advanceWarmupTime(2200);
await advanceWarmupTime(900);
}
async function completeCurrentNarrationStep() {
await revealCurrentStepCue();
await advanceWarmupTime(1000);
await advanceWarmupTime(900);
}
async function sendMocapLeftHandTrack(
rerender: (ui: ReactElement) => void,
points: number[],
options: { raised?: boolean } = {},
) {
for (const x of points) {
const y = options.raised ? 0.34 : 0.72;
const wrist = { x, y };
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
bodyJoints: {
leftShoulder: { x: 0.4, y: 0.42 },
leftElbow: { x: 0.36, y: 0.5 },
},
hands: [{ x, y, state: 'unknown', side: 'left', wrist }],
primaryHand: { x, y, state: 'unknown', side: 'left', wrist },
leftHand: { x, y, state: 'unknown', side: 'left', wrist },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
}
function setMocapCameraHandTrackPoint({
cameraSide,
x,
y,
}: {
cameraSide: 'left' | 'right';
x: number;
y: number;
}) {
const wrist = { x, y };
const hand = { x, y, state: 'unknown', side: cameraSide, wrist };
const command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
bodyJoints: {
leftShoulder: { x: 0.62, y: 0.48 },
leftElbow: { x: 0.7, y: 0.5 },
rightShoulder: { x: 0.38, y: 0.48 },
rightElbow: { x: 0.3, y: 0.5 },
},
hands: [hand],
primaryHand: hand,
leftHand: null as null | typeof hand,
rightHand: null as null | typeof hand,
};
if (cameraSide === 'left') {
command.leftHand = hand;
} else {
command.rightHand = hand;
}
mocapMock.command = command;
mocapMock.receivedAtMs += 1;
}
async function sendMocapCameraHandTrack(
rerender: (ui: ReactElement) => void,
cameraSide: 'left' | 'right',
points: Array<{ x: number; y: number }>,
) {
for (const point of points) {
setMocapCameraHandTrackPoint({ cameraSide, ...point });
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
}
async function sendPlayerLeftArmSwingTrack(
rerender: (ui: ReactElement) => void,
) {
await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.2, y: 0.5 },
{ x: 0.16, y: 0.42 },
{ x: 0.13, y: 0.34 },
{ x: 0.15, y: 0.43 },
{ x: 0.19, y: 0.51 },
]);
}
async function sendPlayerRightArmSwingTrack(
rerender: (ui: ReactElement) => void,
) {
await sendMocapCameraHandTrack(rerender, 'left', [
{ x: 0.8, y: 0.5 },
{ x: 0.84, y: 0.42 },
{ x: 0.87, y: 0.34 },
{ x: 0.85, y: 0.43 },
{ x: 0.81, y: 0.51 },
]);
}
async function completeGreetingByWaveTrack(
rerender: (ui: ReactElement) => void,
) {
await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], {
raised: true,
});
}
test('renders the warmup stage and starts with the center ring step', () => {
render(<ChildMotionWarmupDemo />);
expect(screen.getByTestId('child-motion-demo')).toBeTruthy();
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
expect(screen.getByLabelText('绿色圆环')).toBeTruthy();
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByText('请横屏体验')).toBeTruthy();
});
test('shows narration first before revealing the step cue', async () => {
vi.useFakeTimers();
render(<ChildMotionWarmupDemo />);
expect(screen.getByText('来到圆圈这里')).toBeTruthy();
expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro');
await advanceWarmupTime(1000);
expect(screen.getByLabelText('绿色圆环')).toBeTruthy();
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active');
});
test('re-entering within the same runtime session opens the start button', () => {
markChildMotionWarmupCompletedInRuntime();
@@ -71,6 +269,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 />);
@@ -89,16 +299,35 @@ test('developer keyboard input moves the avatar and triggers jump state', () =>
expect(avatar.className).toContain('child-motion-avatar--jumping');
});
test('mocap body center dampens small jitter before moving the avatar', async () => {
setMocapBodyCenter(0.5);
const { rerender } = render(<ChildMotionWarmupDemo />);
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 50%',
);
setMocapBodyCenter(0.508);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 50%',
);
setMocapBodyCenter(0.34);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
const style = screen.getByTestId('child-motion-avatar').getAttribute('style');
expect(style).toContain('left: 46.5%');
expect(style).not.toContain('left: 34%');
});
test('mocap body center keeps the warmup flow on the motion data source', async () => {
vi.useFakeTimers();
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
setMocapBodyCenter(0.5);
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
expect(screen.queryByText('摄像头暂不可用,已切换到本地演示')).toBeNull();
@@ -107,63 +336,39 @@ test('mocap body center keeps the warmup flow on the motion data source', async
'left: 50%',
);
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
mocapMock.command = {
actions: ['open_palm'],
bodyCenter: { x: 0.5, y: 0.6 },
hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeGreetingByWaveTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
await act(async () => {
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await completeCurrentNarrationStep();
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '向左一步' })).toBeTruthy();
});
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.34, y: 0.6 },
hands: [],
primaryHand: null,
leftHand: null,
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
for (const targetX of [0.34, 0.34, 0.34, 0.34, 0.34]) {
setMocapBodyCenter(targetX);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
await vi.waitFor(() => {
expect(screen.getByTestId('child-motion-avatar').getAttribute('style')).toContain(
'left: 34%',
'left: 37',
);
});
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '回到中间来' })).toBeTruthy();
@@ -175,18 +380,17 @@ test('mocap body center keeps the warmup flow on the motion data source', async
vi.useRealTimers();
});
test('mocap open palm completes the greeting wave step', async () => {
test('mocap greeting requires a real horizontal wave track', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
await revealCurrentStepCue();
mocapMock.command = {
actions: ['open_palm'],
hands: [{ x: 0.46, y: 0.34, state: 'open_palm', side: 'left' }],
@@ -198,7 +402,35 @@ test('mocap open palm completes the greeting wave step', async () => {
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
await sendMocapLeftHandTrack(rerender, [0.42, 0.51, 0.58, 0.49, 0.43], {
raised: false,
});
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
for (const x of [0.42, 0.51, 0.58, 0.49, 0.43]) {
const wrist = { x, y: 0.34 };
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
hands: [{ x, y: 0.34, state: 'unknown', side: 'left', wrist }],
primaryHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist },
leftHand: { x, y: 0.34, state: 'unknown', side: 'left', wrist },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
}
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '打个招呼' })).toBeTruthy();
await completeGreetingByWaveTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
@@ -208,117 +440,89 @@ test('mocap open palm completes the greeting wave step', async () => {
vi.useRealTimers();
});
test('mocap hand tracks complete left and right wave steps only after movement is visible', async () => {
test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
const advancePositionStep = async (key: string, code: string) => {
await revealCurrentStepCue();
await act(async () => {
fireEvent.keyDown(window, { key, code });
});
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await completeCurrentPositionStepByHold();
await act(async () => {
fireEvent.keyUp(window, { key, code });
});
};
await act(async () => {
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
mocapMock.command = {
actions: ['open_palm'],
hands: [{ x: 0.48, y: 0.34, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
leftHand: { x: 0.48, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
await completeGreetingByWaveTrack(rerender);
await act(async () => {
vi.advanceTimersByTime(1000);
await vi.runOnlyPendingTimersAsync();
});
await advanceWarmupTime(900);
await completeCurrentNarrationStep();
await advancePositionStep('a', 'KeyA');
await act(async () => {
vi.advanceTimersByTime(120);
await vi.runOnlyPendingTimersAsync();
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await advancePositionStep('d', 'KeyD');
await act(async () => {
vi.advanceTimersByTime(120);
await vi.runOnlyPendingTimersAsync();
vi.advanceTimersByTime(2100);
await vi.runOnlyPendingTimersAsync();
});
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' },
primaryHand: { x: 0.3, y: 0.38, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' },
primaryHand: { x: 0.39, y: 0.36, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
mocapMock.command = {
actions: [],
leftHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' },
primaryHand: { x: 0.31, y: 0.34, state: 'unknown', side: 'left' },
rightHand: null,
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
await sendMocapCameraHandTrack(rerender, 'left', [
{ x: 0.78, y: 0.5 },
{ x: 0.86, y: 0.5 },
{ x: 0.79, y: 0.5 },
{ x: 0.87, y: 0.5 },
{ x: 0.8, y: 0.5 },
]);
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.32, y: 0.74 },
{ x: 0.24, y: 0.74 },
{ x: 0.31, y: 0.74 },
{ x: 0.23, y: 0.74 },
{ x: 0.3, y: 0.74 },
]);
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '挥动左手' })).toBeTruthy();
await sendPlayerLeftArmSwingTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy();
});
mocapMock.command = {
actions: ['right_hand_wave'],
leftHand: null,
primaryHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' },
rightHand: { x: 0.64, y: 0.35, state: 'unknown', side: 'right' },
};
mocapMock.receivedAtMs += 1;
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
await revealCurrentStepCue();
await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.2, y: 0.5 },
{ x: 0.16, y: 0.42 },
{ x: 0.13, y: 0.34 },
{ x: 0.15, y: 0.43 },
{ x: 0.19, y: 0.51 },
]);
await advanceWarmupTime(900);
expect(screen.getByRole('heading', { name: '挥动右手' })).toBeTruthy();
await sendPlayerRightArmSwingTrack(rerender);
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy();
});
await advanceWarmupTime(720);
await act(async () => {
vi.advanceTimersByTime(720);
await vi.runOnlyPendingTimersAsync();
unmount();
});
vi.useRealTimers();

View File

@@ -1,15 +1,20 @@
import type {
CSSProperties,
PointerEvent as ReactPointerEvent,
} from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent } 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,
MocapPointInput,
} from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput';
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
import {
applyChildMotionWarmupCompletion,
CHILD_MOTION_CENTER_X,
@@ -31,10 +36,68 @@ import {
type DragHand = 'left' | 'right';
type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump';
type WarmupStepPhase = 'intro' | 'active' | 'complete';
type WarmupMocapGestureIntent =
| 'greeting'
| 'left-hand'
| 'right-hand'
| 'jump';
type WarmupBodyHandSide = 'left' | 'right';
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: '香蕉',
},
],
visualPackage: null,
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;
const WARMUP_ARM_SWING_MIN_POINTS = 5;
const WARMUP_ARM_SWING_MIN_VERTICAL_RANGE = 0.08;
const WARMUP_ARM_SWING_MIN_ANGLE_RANGE_DEG = 28;
const WARMUP_ARM_SWING_MIN_REACH = 0.12;
const WARMUP_ARM_SWING_MIN_OUTWARD_X = 0.1;
const WARMUP_ARM_SWING_DIRECTION_EPSILON = 0.012;
const WARMUP_GREETING_WAVE_MIN_POINTS = 5;
const WARMUP_GREETING_WAVE_MIN_X_RANGE = 0.075;
const WARMUP_GREETING_WAVE_MIN_DIRECTION_CHANGES = 1;
const WARMUP_GREETING_WAVE_DIRECTION_EPSILON = 0.008;
const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04;
const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08;
const WARMUP_STEP_INTRO_DELAY_MS = 1000;
const WARMUP_STEP_COMPLETE_PAUSE_MS = 820;
const AVATAR_MOCAP_DEAD_ZONE = 0.012;
const AVATAR_MOCAP_SMOOTHING = 0.28;
const AVATAR_MOCAP_MAX_STEP = 0.035;
function clampMotionUnit(value: number) {
return Math.max(0, Math.min(1, value));
@@ -61,16 +124,54 @@ function formatPercent(value: number | null) {
return `${Math.round(value * 100)}%`;
}
function formatAvatarLeftPercent(value: number) {
return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`;
}
function resolveMocapHandWithBodySide(
command: MocapInputCommand,
side: WarmupBodyHandSide,
) {
// 本地 mocap 的 handedness 目前是摄像头视角:画面右侧手对应用户身体左手。
return side === 'left' ? command.rightHand : command.leftHand;
}
function resolveMocapJointWithBodySide(
command: MocapInputCommand,
side: WarmupBodyHandSide,
joint: 'shoulder' | 'elbow',
) {
const joints = command.bodyJoints;
if (side === 'left') {
return joint === 'shoulder' ? joints?.rightShoulder : joints?.rightElbow;
}
return joint === 'shoulder' ? joints?.leftShoulder : joints?.leftElbow;
}
function mocapHandToChildMotionPoint(
hand: MocapHandInput | null | undefined,
command?: MocapInputCommand,
bodySide?: WarmupBodyHandSide,
): ChildMotionPoint | null {
if (!hand) {
return null;
}
const armMetrics =
command && bodySide
? resolveWarmupArmMetrics(hand, command, bodySide)
: null;
return {
x: clampMotionUnit(hand.x),
y: clampMotionUnit(hand.y),
isRaised: command
? isWarmupGreetingHandRaised(hand, command, bodySide)
: undefined,
isArmExtended: armMetrics?.isExtended,
armAngleDeg: armMetrics?.angleDeg,
armReach: armMetrics?.reach,
};
}
@@ -124,20 +225,180 @@ function hasWarmupMocapAction(
return command.actions.some((action) => expectedActions.includes(action));
}
function hasWarmupMocapWavePath(points: ChildMotionPoint[]) {
if (points.length < WARMUP_MOCAP_WAVE_MIN_POINTS) {
function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) {
let previousDirection = 0;
let directionChanges = 0;
for (let index = 1; index < points.length; index += 1) {
const delta = points[index]!.y - points[index - 1]!.y;
if (Math.abs(delta) < WARMUP_ARM_SWING_DIRECTION_EPSILON) {
continue;
}
const direction = Math.sign(delta);
if (previousDirection !== 0 && direction !== previousDirection) {
directionChanges += 1;
}
previousDirection = direction;
}
return directionChanges;
}
function hasWarmupArmSwingPath(points: ChildMotionPoint[]) {
const extendedPoints = points.filter((point) => point.isArmExtended);
if (extendedPoints.length < WARMUP_ARM_SWING_MIN_POINTS) {
return false;
}
const xValues = points.map((point) => point.x);
const xValues = extendedPoints.map((point) => point.x);
const yValues = extendedPoints.map((point) => point.y);
const angleValues = extendedPoints
.map((point) => point.armAngleDeg)
.filter((angle): angle is number => typeof angle === 'number');
const xRange = Math.max(...xValues) - Math.min(...xValues);
const yRange = Math.max(...yValues) - Math.min(...yValues);
const angleRange =
angleValues.length > 0
? Math.max(...angleValues) - Math.min(...angleValues)
: 0;
return (
Math.max(...xValues) - Math.min(...xValues) >=
WARMUP_MOCAP_WAVE_MIN_X_RANGE
xRange >= WARMUP_MOCAP_WAVE_MIN_X_RANGE &&
yRange >= WARMUP_ARM_SWING_MIN_VERTICAL_RANGE &&
angleRange >= WARMUP_ARM_SWING_MIN_ANGLE_RANGE_DEG &&
countWarmupVerticalDirectionChanges(extendedPoints) >= 1
);
}
function countWarmupHorizontalDirectionChanges(points: ChildMotionPoint[]) {
let previousDirection = 0;
let directionChanges = 0;
for (let index = 1; index < points.length; index += 1) {
const delta = points[index]!.x - points[index - 1]!.x;
if (Math.abs(delta) < WARMUP_GREETING_WAVE_DIRECTION_EPSILON) {
continue;
}
const direction = Math.sign(delta);
if (previousDirection !== 0 && direction !== previousDirection) {
directionChanges += 1;
}
previousDirection = direction;
}
return directionChanges;
}
function hasWarmupGreetingWavePath(points: ChildMotionPoint[]) {
const raisedPoints = points.filter((point) => point.isRaised);
if (raisedPoints.length < WARMUP_GREETING_WAVE_MIN_POINTS) {
return false;
}
const xValues = raisedPoints.map((point) => point.x);
const xRange = Math.max(...xValues) - Math.min(...xValues);
return (
xRange >= WARMUP_GREETING_WAVE_MIN_X_RANGE &&
countWarmupHorizontalDirectionChanges(raisedPoints) >=
WARMUP_GREETING_WAVE_MIN_DIRECTION_CHANGES
);
}
function isWarmupGreetingHandRaised(
hand: MocapHandInput,
command: MocapInputCommand,
bodySide?: WarmupBodyHandSide,
) {
const wrist = hand.wrist ?? { x: hand.x, y: hand.y };
const elbow = bodySide
? resolveMocapJointWithBodySide(command, bodySide, 'elbow')
: hand.side === 'left'
? command.bodyJoints?.leftElbow
: hand.side === 'right'
? command.bodyJoints?.rightElbow
: null;
if (elbow) {
return wrist.y <= elbow.y + WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN;
}
const shoulder = bodySide
? resolveMocapJointWithBodySide(command, bodySide, 'shoulder')
: hand.side === 'left'
? command.bodyJoints?.leftShoulder
: hand.side === 'right'
? command.bodyJoints?.rightShoulder
: null;
if (shoulder) {
return wrist.y <= shoulder.y + WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN;
}
return false;
}
function getWarmupPointDistance(left: MocapPointInput, right: MocapPointInput) {
return Math.hypot(left.x - right.x, left.y - right.y);
}
function resolveWarmupArmMetrics(
hand: MocapHandInput,
command: MocapInputCommand,
bodySide: WarmupBodyHandSide,
) {
const wrist = hand.wrist ?? { x: hand.x, y: hand.y };
const shoulder = resolveMocapJointWithBodySide(command, bodySide, 'shoulder');
if (!shoulder) {
return null;
}
const elbow = resolveMocapJointWithBodySide(command, bodySide, 'elbow');
const reach = getWarmupPointDistance(shoulder, wrist);
const outwardX =
bodySide === 'left' ? shoulder.x - wrist.x : wrist.x - shoulder.x;
const upperArmReach = elbow ? getWarmupPointDistance(shoulder, elbow) : null;
const angleDeg =
(Math.atan2(shoulder.y - wrist.y, Math.abs(wrist.x - shoulder.x)) * 180) /
Math.PI;
const isNotDrooping = elbow
? wrist.y <= elbow.y + WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN
: wrist.y <= shoulder.y + WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN;
const isExtended =
outwardX >= WARMUP_ARM_SWING_MIN_OUTWARD_X &&
reach >= WARMUP_ARM_SWING_MIN_REACH &&
(!upperArmReach || reach >= upperArmReach * 1.2) &&
isNotDrooping;
return {
angleDeg,
reach,
isExtended,
};
}
function resolveAvatarXFromMocap(command: MocapInputCommand) {
return command.bodyCenter?.x ?? null;
const bodyCenterX = command.bodyCenter?.x;
if (typeof bodyCenterX !== 'number' || !Number.isFinite(bodyCenterX)) {
return null;
}
return clampMotionUnit(bodyCenterX);
}
function resolveDampedAvatarX(current: number, target: number) {
const clampedCurrent = clampMotionUnit(current);
const clampedTarget = clampMotionUnit(target);
const delta = clampedTarget - clampedCurrent;
if (Math.abs(delta) <= AVATAR_MOCAP_DEAD_ZONE) {
return clampedCurrent;
}
const smoothedDelta = delta * AVATAR_MOCAP_SMOOTHING;
const limitedDelta =
Math.sign(smoothedDelta) *
Math.min(Math.abs(smoothedDelta), AVATAR_MOCAP_MAX_STEP);
return clampMotionUnit(clampedCurrent + limitedDelta);
}
function resolveWarmupMocapGestureIntent(
@@ -151,22 +412,9 @@ function resolveWarmupMocapGestureIntent(
): WarmupMocapGestureIntent | null {
if (stepId === 'wave_greeting') {
if (
hasWarmupMocapAction(command, [
'wave',
'wave_greeting',
'hand_wave',
'hello',
'greeting',
'open_palm',
'handwave',
'wavehand',
'招手',
'挥手',
]) ||
command.hands?.some((hand) => hand.state === 'open_palm') ||
hasWarmupMocapWavePath(paths.leftHandPath) ||
hasWarmupMocapWavePath(paths.rightHandPath) ||
hasWarmupMocapWavePath(paths.primaryHandPath)
hasWarmupGreetingWavePath(paths.leftHandPath) ||
hasWarmupGreetingWavePath(paths.rightHandPath) ||
hasWarmupGreetingWavePath(paths.primaryHandPath)
) {
return 'greeting';
}
@@ -174,43 +422,27 @@ function resolveWarmupMocapGestureIntent(
if (
stepId === 'wave_left_hand' &&
(hasWarmupMocapAction(command, [
'left_wave',
'wave_left',
'left_hand_wave',
'wave_left_hand',
'left_handwave',
'lefthand_wave',
'lefthandwave',
'左手挥手',
'挥动左手',
]) ||
hasWarmupMocapWavePath(paths.leftHandPath))
hasWarmupArmSwingPath(paths.leftHandPath)
) {
return 'left-hand';
}
if (
stepId === 'wave_right_hand' &&
(hasWarmupMocapAction(command, [
'right_wave',
'wave_right',
'right_hand_wave',
'wave_right_hand',
'right_handwave',
'righthand_wave',
'righthandwave',
'右手挥手',
'挥动右手',
]) ||
hasWarmupMocapWavePath(paths.rightHandPath))
hasWarmupArmSwingPath(paths.rightHandPath)
) {
return 'right-hand';
}
if (
stepId === 'jump_once' &&
hasWarmupMocapAction(command, ['jump', 'jump_once', 'hop', '跳跃', '原地跳'])
hasWarmupMocapAction(command, [
'jump',
'jump_once',
'hop',
'跳跃',
'原地跳',
])
) {
return 'jump';
}
@@ -246,7 +478,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
'jump_once',
'warmup_finish',
'level_select',
'play_placeholder',
];
return Math.max(0, order.indexOf(stepId));
}
@@ -263,16 +494,18 @@ function ChildMotionAvatar({
className={`child-motion-avatar ${isJumping ? 'child-motion-avatar--jumping' : ''}`}
data-testid="child-motion-avatar"
style={{
left: `${avatarX * 100}%`,
left: formatAvatarLeftPercent(avatarX),
}}
aria-label="用户角色剪影"
>
<span className="child-motion-avatar__head" />
<span className="child-motion-avatar__body" />
<span className="child-motion-avatar__arm child-motion-avatar__arm--left" />
<span className="child-motion-avatar__arm child-motion-avatar__arm--right" />
<span className="child-motion-avatar__leg child-motion-avatar__leg--left" />
<span className="child-motion-avatar__leg child-motion-avatar__leg--right" />
<span className="child-motion-avatar__sprite" aria-hidden="true">
<span className="child-motion-avatar__head" />
<span className="child-motion-avatar__body" />
<span className="child-motion-avatar__arm child-motion-avatar__arm--left" />
<span className="child-motion-avatar__arm child-motion-avatar__arm--right" />
<span className="child-motion-avatar__leg child-motion-avatar__leg--left" />
<span className="child-motion-avatar__leg child-motion-avatar__leg--right" />
</span>
</div>
);
}
@@ -288,10 +521,12 @@ function ChildMotionRing({
<div
className={`child-motion-ring ${progress > 0 ? 'child-motion-ring--active' : ''}`}
data-testid="child-motion-ring"
style={{
left: `${targetX * 100}%`,
'--child-motion-ring-progress': `${Math.round(progress * 360)}deg`,
} as CSSProperties}
style={
{
left: `${targetX * 100}%`,
'--child-motion-ring-progress': `${Math.round(progress * 360)}deg`,
} as CSSProperties
}
aria-label="绿色圆环"
>
<span className="child-motion-ring__core" />
@@ -317,12 +552,16 @@ function ChildMotionGestureGuide({
return (
<div className="child-motion-gesture-guide" aria-hidden="true">
{isGreeting ? (
<span className="child-motion-gesture-guide__wave"></span>
<span className="child-motion-gesture-guide__wave-cat">
<span className="child-motion-gesture-guide__wave-cat-body" />
<span className="child-motion-gesture-guide__wave-cat-arm child-motion-gesture-guide__wave-cat-arm--left" />
<span className="child-motion-gesture-guide__wave-cat-arm child-motion-gesture-guide__wave-cat-arm--right" />
</span>
) : null}
{isLeft || isRight ? (
<>
<span
className={`child-motion-gesture-guide__hand child-motion-gesture-guide__hand--${isLeft ? 'left' : 'right'}`}
className={`child-motion-gesture-guide__arm child-motion-gesture-guide__arm--${isLeft ? 'left' : 'right'}`}
/>
{activePath.map((point, index) => (
<span
@@ -337,7 +576,9 @@ function ChildMotionGestureGuide({
))}
</>
) : null}
{isJump ? <span className="child-motion-gesture-guide__jump"></span> : null}
{isJump ? (
<span className="child-motion-gesture-guide__jump"></span>
) : null}
</div>
);
}
@@ -377,6 +618,10 @@ export function ChildMotionWarmupDemo() {
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
);
const [stepPhase, setStepPhase] = useState<WarmupStepPhase>(() =>
hasCompletedChildMotionWarmupInRuntime() ? 'active' : 'intro',
);
const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false);
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
const [calibration, setCalibration] = useState(
createEmptyChildMotionCalibration,
@@ -387,18 +632,21 @@ export function ChildMotionWarmupDemo() {
const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]);
const [activeHand, setActiveHand] = useState<DragHand | null>(null);
const [isJumping, setIsJumping] = useState(false);
const [justCompletedText, setJustCompletedText] = useState<string | null>(null);
const [cameraAccessState, setCameraAccessState] =
useState<CameraAccessState>(() =>
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
const [justCompletedText, setJustCompletedText] = useState<string | null>(
null,
);
const [cameraAccessState, setCameraAccessState] = useState<CameraAccessState>(
() =>
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
? 'blocked'
: 'idle',
);
);
const holdCompletionRef = useRef(false);
const cameraVideoRef = useRef<HTMLVideoElement | null>(null);
const cameraStreamRef = useRef<MediaStream | null>(null);
const handledMocapPacketKeyRef = useRef<string | null>(null);
const completionTimeoutRef = useRef<number | null>(null);
const feedbackTimeoutRef = useRef<number | null>(null);
const step = getChildMotionWarmupStep(stepId);
const mocapInput = useMocapInput({
@@ -411,6 +659,10 @@ export function ChildMotionWarmupDemo() {
const stepIndex = getStepIndex(stepId);
const progressPercent = Math.round((stepIndex / 12) * 100);
const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs);
const isStepActive = stepPhase === 'active';
const shouldShowStepCues = stepPhase !== 'intro';
const displayHoldProgress =
stepPhase === 'complete' && step.kind === 'position' ? 1 : holdProgress;
const targetX = step.target ? getChildMotionTargetX(step.target) : null;
const motionSourceState = getMotionSourceState(
mocapInput.status,
@@ -420,6 +672,10 @@ export function ChildMotionWarmupDemo() {
const completeStep = useCallback(
(completion: Parameters<typeof applyChildMotionWarmupCompletion>[2]) => {
if (stepPhase !== 'active') {
return;
}
setCalibration((current) =>
applyChildMotionWarmupCompletion(stepId, current, completion),
);
@@ -429,15 +685,31 @@ export function ChildMotionWarmupDemo() {
markChildMotionWarmupCompletedInRuntime();
}
setJustCompletedText(
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒',
);
window.setTimeout(() => setJustCompletedText(null), 720);
setStepId(nextStep);
const completionText =
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒';
setJustCompletedText(completionText);
setStepPhase('complete');
setHoldStartedAt(null);
holdCompletionRef.current = false;
if (feedbackTimeoutRef.current !== null) {
window.clearTimeout(feedbackTimeoutRef.current);
}
feedbackTimeoutRef.current = window.setTimeout(() => {
feedbackTimeoutRef.current = null;
setJustCompletedText(null);
}, WARMUP_STEP_COMPLETE_PAUSE_MS);
if (completionTimeoutRef.current !== null) {
window.clearTimeout(completionTimeoutRef.current);
}
completionTimeoutRef.current = window.setTimeout(() => {
completionTimeoutRef.current = null;
setStepId(nextStep);
setStepPhase(nextStep === 'level_select' ? 'active' : 'intro');
}, WARMUP_STEP_COMPLETE_PAUSE_MS);
},
[stepId],
[stepId, stepPhase],
);
useEffect(() => {
@@ -445,6 +717,18 @@ export function ChildMotionWarmupDemo() {
return () => window.clearInterval(timer);
}, []);
useEffect(
() => () => {
if (completionTimeoutRef.current !== null) {
window.clearTimeout(completionTimeoutRef.current);
}
if (feedbackTimeoutRef.current !== null) {
window.clearTimeout(feedbackTimeoutRef.current);
}
},
[],
);
useEffect(() => {
const videoElement = cameraVideoRef.current;
if (
@@ -519,10 +803,24 @@ export function ChildMotionWarmupDemo() {
setHoldStartedAt(null);
setLeftHandPath([]);
setRightHandPath([]);
}, [stepId]);
handledMocapPacketKeyRef.current = null;
if (step.kind === 'levelSelect') {
setStepPhase('active');
return;
}
setStepPhase('intro');
const timeout = window.setTimeout(
() =>
setStepPhase((current) => (current === 'intro' ? 'active' : current)),
WARMUP_STEP_INTRO_DELAY_MS,
);
return () => window.clearTimeout(timeout);
}, [step.kind, stepId]);
useEffect(() => {
if (step.kind !== 'position') {
if (step.kind !== 'position' || !isStepActive) {
return;
}
@@ -533,11 +831,12 @@ export function ChildMotionWarmupDemo() {
}
setHoldStartedAt((current) => current ?? Date.now());
}, [avatarX, step]);
}, [avatarX, isStepActive, step]);
useEffect(() => {
if (
step.kind !== 'position' ||
!isStepActive ||
holdStartedAt === null ||
holdCompletionRef.current ||
nowMs - holdStartedAt < CHILD_MOTION_HOLD_DURATION_MS
@@ -547,10 +846,13 @@ export function ChildMotionWarmupDemo() {
holdCompletionRef.current = true;
completeStep({ type: 'position', avatarX });
}, [avatarX, completeStep, holdStartedAt, nowMs, step.kind]);
}, [avatarX, completeStep, holdStartedAt, isStepActive, nowMs, step.kind]);
useEffect(() => {
if (step.kind !== 'narration' && step.kind !== 'finish') {
if (
!isStepActive ||
(step.kind !== 'narration' && step.kind !== 'finish')
) {
return;
}
@@ -561,10 +863,10 @@ export function ChildMotionWarmupDemo() {
: CHILD_MOTION_NARRATION_DURATION_MS,
);
return () => window.clearTimeout(timeout);
}, [completeStep, step.kind]);
}, [completeStep, isStepActive, step.kind]);
useEffect(() => {
if (step.kind !== 'gesture' || !mocapInput.latestCommand) {
if (step.kind !== 'gesture' || !isStepActive || !mocapInput.latestCommand) {
return;
}
@@ -577,25 +879,32 @@ export function ChildMotionWarmupDemo() {
return;
}
const primaryPoint = mocapHandToChildMotionPoint(command.primaryHand);
const leftBodyHand = resolveMocapHandWithBodySide(command, 'left');
const rightBodyHand = resolveMocapHandWithBodySide(command, 'right');
const primaryBodySide =
command.primaryHand === leftBodyHand
? 'left'
: command.primaryHand === rightBodyHand
? 'right'
: undefined;
const primaryPoint = mocapHandToChildMotionPoint(
command.primaryHand,
command,
primaryBodySide,
);
const primaryHandSide = command.primaryHand?.side ?? 'unknown';
const fallbackPrimaryToLeft =
Boolean(primaryPoint) &&
!command.leftHand &&
(primaryHandSide === 'left' ||
primaryHandSide === 'unknown' ||
stepId === 'wave_left_hand' ||
stepId === 'wave_greeting');
!leftBodyHand &&
(primaryBodySide === 'left' ||
(primaryHandSide === 'unknown' && stepId === 'wave_greeting'));
const fallbackPrimaryToRight =
Boolean(primaryPoint) &&
!command.rightHand &&
(primaryHandSide === 'right' ||
stepId === 'wave_right_hand');
Boolean(primaryPoint) && !rightBodyHand && primaryBodySide === 'right';
const leftPoint =
mocapHandToChildMotionPoint(command.leftHand) ??
mocapHandToChildMotionPoint(leftBodyHand, command, 'left') ??
(fallbackPrimaryToLeft ? primaryPoint : null);
const rightPoint =
mocapHandToChildMotionPoint(command.rightHand) ??
mocapHandToChildMotionPoint(rightBodyHand, command, 'right') ??
(fallbackPrimaryToRight ? primaryPoint : null);
const nextLeftHandPath = leftPoint
? appendWarmupMocapPoint(leftHandPath, leftPoint)
@@ -604,7 +913,7 @@ export function ChildMotionWarmupDemo() {
? appendWarmupMocapPoint(rightHandPath, rightPoint)
: rightHandPath;
const nextPrimaryHandPath = primaryPoint
? command.primaryHand?.side === 'right'
? primaryBodySide === 'right'
? nextRightHandPath
: nextLeftHandPath
: [];
@@ -633,14 +942,14 @@ export function ChildMotionWarmupDemo() {
}
if (intent === 'right-hand') {
const path = [...nextRightHandPath, rightPoint ?? primaryPoint].filter(
const path = [...nextRightHandPath, rightPoint].filter(
(point): point is ChildMotionPoint => Boolean(point),
);
completeStep({ type: 'right-hand', path: path.slice(-16) });
return;
}
const path = [...nextLeftHandPath, leftPoint ?? primaryPoint].filter(
const path = [...nextLeftHandPath, leftPoint].filter(
(point): point is ChildMotionPoint => Boolean(point),
);
completeStep({ type: 'left-hand', path: path.slice(-16) });
@@ -651,12 +960,13 @@ export function ChildMotionWarmupDemo() {
mocapInput.rawPacketPreview?.receivedAtMs,
mocapInput.rawPacketPreview?.text,
rightHandPath,
isStepActive,
step.kind,
stepId,
]);
useEffect(() => {
if (!mocapInput.latestCommand) {
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
return;
}
@@ -665,11 +975,12 @@ export function ChildMotionWarmupDemo() {
return;
}
setAvatarX(nextAvatarX);
setAvatarX((current) => resolveDampedAvatarX(current, nextAvatarX));
}, [
mocapInput.latestCommand,
mocapInput.rawPacketPreview?.receivedAtMs,
mocapInput.rawPacketPreview?.text,
stepPhase,
]);
useEffect(() => {
@@ -678,6 +989,10 @@ export function ChildMotionWarmupDemo() {
return;
}
if (stepPhase === 'complete') {
return;
}
const key = event.key.toLowerCase();
if (key === 'a') {
setAvatarX(0.34);
@@ -693,7 +1008,7 @@ export function ChildMotionWarmupDemo() {
event.preventDefault();
setIsJumping(true);
window.setTimeout(() => setIsJumping(false), 360);
if (stepId === 'jump_once') {
if (stepId === 'jump_once' && isStepActive) {
completeStep({ type: 'jump', jumpSpace: 0.14 });
}
}
@@ -701,12 +1016,17 @@ export function ChildMotionWarmupDemo() {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [completeStep, stepId]);
}, [completeStep, isStepActive, stepId, stepPhase]);
useEffect(() => {
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if (key === 'a' || key === 'd' || event.code === 'KeyA' || event.code === 'KeyD') {
if (
key === 'a' ||
key === 'd' ||
event.code === 'KeyA' ||
event.code === 'KeyD'
) {
setAvatarX(CHILD_MOTION_CENTER_X);
}
};
@@ -716,6 +1036,10 @@ export function ChildMotionWarmupDemo() {
}, []);
const handleStagePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (!isStepActive) {
return;
}
if (event.button !== 0 && event.button !== 2) {
return;
}
@@ -763,6 +1087,10 @@ export function ChildMotionWarmupDemo() {
: [...rightHandPath, point].slice(-16);
setActiveHand(null);
if (!isStepActive) {
return;
}
if (stepId === 'wave_greeting') {
completeStep({ type: 'left-hand', path: completedPath });
return;
@@ -778,15 +1106,26 @@ export function ChildMotionWarmupDemo() {
}
};
const handleStartPlaceholderLevel = () => {
setStepId('play_placeholder');
const handleStartBabyObjectLevel = () => {
setIsBabyObjectRuntimeOpen(true);
};
const handleReturnToStart = () => {
setStepId('level_select');
};
const lineText = useMemo(
() => step.spokenLines.join(''),
[step.spokenLines],
);
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">
@@ -795,8 +1134,9 @@ export function ChildMotionWarmupDemo() {
</div>
<section
className="child-motion-stage"
className={`child-motion-stage child-motion-stage--${stepPhase}`}
data-testid="child-motion-stage"
data-step-phase={stepPhase}
onPointerDown={handleStagePointerDown}
onPointerMove={handleStagePointerMove}
onPointerUp={handleStagePointerUp}
@@ -820,10 +1160,10 @@ export function ChildMotionWarmupDemo() {
</div>
) : null}
<div className="child-motion-floor" aria-hidden="true" />
{targetX !== null && step.kind === 'position' ? (
<ChildMotionRing targetX={targetX} progress={holdProgress} />
{shouldShowStepCues && targetX !== null && step.kind === 'position' ? (
<ChildMotionRing targetX={targetX} progress={displayHoldProgress} />
) : null}
{step.kind === 'gesture' ? (
{shouldShowStepCues && step.kind === 'gesture' ? (
<ChildMotionGestureGuide
stepId={stepId}
leftHandPath={leftHandPath}
@@ -832,7 +1172,9 @@ export function ChildMotionWarmupDemo() {
) : null}
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
{justCompletedText ? (
<div className="child-motion-floating-reward">{justCompletedText}</div>
<div className="child-motion-floating-reward">
{justCompletedText}
</div>
) : null}
<div className="child-motion-hud child-motion-hud--top">
@@ -846,21 +1188,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', () => {
@@ -63,14 +60,25 @@ describe('childMotionWarmupModel', () => {
{
type: 'left-hand',
path: [
{ x: 0.3, y: 0.4 },
{ x: 0.34, y: 0.32 },
{ x: 0.3, y: 0.4, armAngleDeg: 12, armReach: 0.2 },
{ x: 0.34, y: 0.32, armAngleDeg: 44, armReach: 0.28 },
],
},
);
const withRightHand = applyChildMotionWarmupCompletion(
'wave_right_hand',
withLeftHand,
{
type: 'right-hand',
path: [
{ x: 0.7, y: 0.42, armAngleDeg: 10, armReach: 0.22 },
{ x: 0.82, y: 0.3, armAngleDeg: 46, armReach: 0.31 },
],
},
);
const completed = applyChildMotionWarmupCompletion(
'jump_once',
withLeftHand,
withRightHand,
{
type: 'jump',
jumpSpace: 0.14,
@@ -80,6 +88,16 @@ describe('childMotionWarmupModel', () => {
expect(completed.leftBoundary).toBeCloseTo(0.16);
expect(completed.rightBoundary).toBeCloseTo(0.16);
expect(completed.leftHandPath).toHaveLength(2);
expect(completed.leftHandSpace).toEqual({
minX: 0.3,
maxX: 0.34,
minY: 0.32,
maxY: 0.4,
minAngleDeg: 12,
maxAngleDeg: 44,
maxReach: 0.28,
});
expect(completed.rightHandSpace?.maxReach).toBe(0.31);
expect(completed.jumpSpace).toBe(0.14);
});
});

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;
@@ -34,6 +32,20 @@ export type ChildMotionWarmupStep = {
export type ChildMotionPoint = {
x: number;
y: number;
isRaised?: boolean;
isArmExtended?: boolean;
armAngleDeg?: number;
armReach?: number;
};
export type ChildMotionHandSpace = {
minX: number;
maxX: number;
minY: number;
maxY: number;
minAngleDeg: number | null;
maxAngleDeg: number | null;
maxReach: number | null;
};
export type ChildMotionWarmupCalibration = {
@@ -41,6 +53,8 @@ export type ChildMotionWarmupCalibration = {
rightBoundary: number | null;
leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[];
leftHandSpace: ChildMotionHandSpace | null;
rightHandSpace: ChildMotionHandSpace | null;
jumpSpace: number | null;
};
@@ -151,12 +165,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
title: '准备开始',
spokenLines: ['现在开始我们的游戏吧'],
},
{
id: 'play_placeholder',
kind: 'placeholder',
title: '下一关',
spokenLines: ['游戏关卡正在准备中'],
},
];
const STEP_BY_ID = new Map(
@@ -214,10 +222,39 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
rightBoundary: null,
leftHandPath: [],
rightHandPath: [],
leftHandSpace: null,
rightHandSpace: null,
jumpSpace: null,
};
}
function resolveChildMotionHandSpace(
path: ChildMotionPoint[],
): ChildMotionHandSpace | null {
if (path.length === 0) {
return null;
}
const xValues = path.map((point) => point.x);
const yValues = path.map((point) => point.y);
const angleValues = path
.map((point) => point.armAngleDeg)
.filter((angle): angle is number => typeof angle === 'number');
const reachValues = path
.map((point) => point.armReach)
.filter((reach): reach is number => typeof reach === 'number');
return {
minX: Math.min(...xValues),
maxX: Math.max(...xValues),
minY: Math.min(...yValues),
maxY: Math.max(...yValues),
minAngleDeg: angleValues.length > 0 ? Math.min(...angleValues) : null,
maxAngleDeg: angleValues.length > 0 ? Math.max(...angleValues) : null,
maxReach: reachValues.length > 0 ? Math.max(...reachValues) : null,
};
}
export function applyChildMotionWarmupCompletion(
stepId: ChildMotionWarmupStepId,
calibration: ChildMotionWarmupCalibration,
@@ -241,6 +278,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
leftHandPath: completion.path,
leftHandSpace: resolveChildMotionHandSpace(completion.path),
};
}
@@ -248,6 +286,7 @@ export function applyChildMotionWarmupCompletion(
return {
...calibration,
rightHandPath: completion.path,
rightHandSpace: resolveChildMotionHandSpace(completion.path),
};
}

View File

@@ -0,0 +1,124 @@
import { FileText } from 'lucide-react';
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
import type {
LegalDocument,
LegalDocumentBlock,
} from './legalDocuments';
import { UnifiedModal } from './UnifiedModal';
type LegalDocumentModalProps = {
document: LegalDocument | null;
open: boolean;
platformTheme?: PlatformTheme;
zIndexClassName?: string;
onClose: () => void;
};
function LegalRichText({ text }: { text: string }) {
const parts = text.split(/(\*\*[^*]+\*\*)/gu);
return (
<>
{parts.map((part, index) => {
const boldMatch = /^\*\*([^*]+)\*\*$/u.exec(part);
if (boldMatch) {
return (
<strong
key={`${index}:${part}`}
className="font-black text-[var(--platform-text-strong)]"
>
{boldMatch[1]}
</strong>
);
}
return part;
})}
</>
);
}
function LegalDocumentBodyBlock({
block,
}: {
block: LegalDocumentBlock;
}) {
if (block.type === 'heading') {
const className =
block.level === 2
? 'mt-5 text-base font-black leading-7 text-[var(--platform-text-strong)] first:mt-0'
: 'mt-4 text-sm font-black leading-6 text-[var(--platform-text-strong)] first:mt-0';
return <div className={className}>{block.text}</div>;
}
if (block.type === 'list') {
return (
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm leading-7 text-[var(--platform-text-base)]">
{block.items.map((item, index) => (
<li key={`${index}:${item}`}>
<LegalRichText text={item} />
</li>
))}
</ul>
);
}
return (
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-[var(--platform-text-base)]">
<LegalRichText text={block.text} />
</p>
);
}
export function LegalDocumentModal({
document,
open,
platformTheme,
zIndexClassName,
onClose,
}: LegalDocumentModalProps) {
return (
<UnifiedModal
open={open && Boolean(document)}
title={document?.title ?? '法律信息'}
onClose={onClose}
size="md"
closeLabel="关闭法律信息"
zIndexClassName={zIndexClassName ?? 'z-[150]'}
overlayClassName={`platform-theme ${
platformTheme ? `platform-theme--${platformTheme}` : ''
}`}
panelClassName="platform-remap-surface rounded-t-[1.4rem] sm:rounded-[1.4rem]"
headerClassName="items-center"
bodyClassName="px-4 py-0 sm:px-5"
footerClassName="justify-stretch sm:justify-end"
footer={
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary min-h-0 w-full rounded-[0.9rem] px-4 py-2.5 text-sm sm:w-auto"
>
</button>
}
>
<div className="max-h-[min(64vh,34rem)] overflow-y-auto py-4 pr-1">
<div className="mb-4 flex items-center gap-2 text-[var(--platform-cool-text)]">
<FileText className="h-4 w-4" />
<span className="text-xs font-black tracking-[0.2em]">
LEGAL
</span>
</div>
{document?.blocks.map((block, index) => (
<LegalDocumentBodyBlock
// 中文注释:法律文本没有稳定段落 id用序号只限定在静态文档渲染列表内。
key={`${block.type}:${index}`}
block={block}
/>
))}
</div>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,444 @@
import {
type CSSProperties,
type PointerEvent,
useMemo,
useRef,
useState,
} from 'react';
export type SquareImageCropRect = {
x: number;
y: number;
size: number;
};
export type SquareImageCropModalLabels = {
title: string;
close: string;
editor: string;
previewAlt: string;
cancel: string;
submit: string;
saving: string;
};
type SquareCropDragHandle =
| 'move'
| 'north'
| 'northEast'
| 'east'
| 'southEast'
| 'south'
| 'southWest'
| 'west'
| 'northWest';
type SquareCropDragSnapshot = {
pointerId: number;
handle: SquareCropDragHandle;
clientX: number;
clientY: number;
cropRect: SquareImageCropRect;
previewWidth: number;
previewHeight: number;
};
const SQUARE_CROP_RESIZE_HANDLES: Array<{
handle: Exclude<SquareCropDragHandle, 'move'>;
label: string;
className: string;
}> = [
{
handle: 'northWest',
label: '拖拽左上角裁剪边界',
className:
'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
},
{
handle: 'north',
label: '拖拽上边裁剪边界',
className:
'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
},
{
handle: 'northEast',
label: '拖拽右上角裁剪边界',
className:
'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
},
{
handle: 'east',
label: '拖拽右边裁剪边界',
className:
'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
},
{
handle: 'southEast',
label: '拖拽右下角裁剪边界',
className:
'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
},
{
handle: 'south',
label: '拖拽下边裁剪边界',
className:
'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
},
{
handle: 'southWest',
label: '拖拽左下角裁剪边界',
className:
'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
},
{
handle: 'west',
label: '拖拽左边裁剪边界',
className:
'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
},
];
function clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getSquareCropSizeBounds(imageSize: { width: number; height: number }) {
const maxSize = Math.max(1, Math.min(imageSize.width, imageSize.height));
const minSize = Math.min(maxSize, Math.max(48, maxSize * 0.18));
return { minSize, maxSize };
}
export function buildCenteredSquareImageCropRect(imageSize: {
width: number;
height: number;
}): SquareImageCropRect {
const size = Math.max(1, Math.min(imageSize.width, imageSize.height));
return {
x: Math.max(0, (imageSize.width - size) / 2),
y: Math.max(0, (imageSize.height - size) / 2),
size,
};
}
export function clampSquareImageCropRect(
imageSize: { width: number; height: number },
crop: SquareImageCropRect,
): SquareImageCropRect {
const { minSize, maxSize } = getSquareCropSizeBounds(imageSize);
const size = clampNumber(crop.size, minSize, maxSize);
return {
x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)),
y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)),
size,
};
}
function buildSquareCropPreviewStyle(
crop: SquareImageCropRect,
imageSize: { width: number; height: number },
) {
return {
left: `${(crop.x / imageSize.width) * 100}%`,
top: `${(crop.y / imageSize.height) * 100}%`,
width: `${(crop.size / imageSize.width) * 100}%`,
height: `${(crop.size / imageSize.height) * 100}%`,
} satisfies CSSProperties;
}
function resizeSquareCropRectFromHandle(
snapshot: SquareCropDragSnapshot,
deltaX: number,
deltaY: number,
imageSize: { width: number; height: number },
) {
const start = snapshot.cropRect;
const startRight = start.x + start.size;
const startBottom = start.y + start.size;
const startCenterX = start.x + start.size / 2;
const startCenterY = start.y + start.size / 2;
const { minSize, maxSize } = getSquareCropSizeBounds(imageSize);
const chooseSize = (sizeFromX: number, sizeFromY: number) => {
const xDistance = Math.abs(sizeFromX - start.size);
const yDistance = Math.abs(sizeFromY - start.size);
return xDistance >= yDistance ? sizeFromX : sizeFromY;
};
const clampSize = (size: number, maxByAnchor = maxSize) =>
clampNumber(
size,
minSize,
Math.max(minSize, Math.min(maxSize, maxByAnchor)),
);
if (snapshot.handle === 'move') {
return clampSquareImageCropRect(imageSize, {
...start,
x: start.x + deltaX,
y: start.y + deltaY,
});
}
// 中文注释:边缘手柄保持正方形比例,锚点逻辑与拼图图片上传裁剪一致。
if (snapshot.handle === 'east' || snapshot.handle === 'west') {
const isEast = snapshot.handle === 'east';
const anchorX = isEast ? start.x : startRight;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByCenterY =
2 * Math.min(startCenterY, imageSize.height - startCenterY);
const size = clampSize(
start.size + (isEast ? deltaX : -deltaX),
Math.min(maxByAnchorX, maxByCenterY),
);
return clampSquareImageCropRect(imageSize, {
x: isEast ? anchorX : anchorX - size,
y: startCenterY - size / 2,
size,
});
}
if (snapshot.handle === 'north' || snapshot.handle === 'south') {
const isSouth = snapshot.handle === 'south';
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
const maxByCenterX =
2 * Math.min(startCenterX, imageSize.width - startCenterX);
const size = clampSize(
start.size + (isSouth ? deltaY : -deltaY),
Math.min(maxByAnchorY, maxByCenterX),
);
return clampSquareImageCropRect(imageSize, {
x: startCenterX - size / 2,
y: isSouth ? anchorY : anchorY - size,
size,
});
}
const isEast =
snapshot.handle === 'northEast' || snapshot.handle === 'southEast';
const isSouth =
snapshot.handle === 'southEast' || snapshot.handle === 'southWest';
const anchorX = isEast ? start.x : startRight;
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
const sizeFromX = start.size + (isEast ? deltaX : -deltaX);
const sizeFromY = start.size + (isSouth ? deltaY : -deltaY);
const size = clampSize(
chooseSize(sizeFromX, sizeFromY),
Math.min(maxByAnchorX, maxByAnchorY),
);
return clampSquareImageCropRect(imageSize, {
x: isEast ? anchorX : anchorX - size,
y: isSouth ? anchorY : anchorY - size,
size,
});
}
export function SquareImageCropModal({
source,
imageSize,
cropRect,
labels,
titleId = 'square-image-crop-title',
error = null,
isSaving = false,
onCropRectChange,
onClose,
onSubmit,
}: {
source: string;
imageSize: { width: number; height: number };
cropRect: SquareImageCropRect;
labels: SquareImageCropModalLabels;
titleId?: string;
error?: string | null;
isSaving?: boolean;
onCropRectChange: (nextCrop: SquareImageCropRect) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
const dragSnapshotRef = useRef<SquareCropDragSnapshot | null>(null);
const [activeDragHandle, setActiveDragHandle] =
useState<SquareCropDragHandle | null>(null);
const normalizedCropRect = useMemo(
() => clampSquareImageCropRect(imageSize, cropRect),
[cropRect, imageSize],
);
const previewStyle = useMemo(
() => buildSquareCropPreviewStyle(normalizedCropRect, imageSize),
[normalizedCropRect, imageSize],
);
const editorPreviewStyle = useMemo(
() =>
({
aspectRatio: `${imageSize.width} / ${imageSize.height}`,
width: `min(100%, calc(min(52vh, 22rem) * ${
imageSize.width / Math.max(1, imageSize.height)
}))`,
}) satisfies CSSProperties,
[imageSize],
);
const beginCropDrag = (
handle: SquareCropDragHandle,
event: PointerEvent<HTMLElement>,
) => {
if (isSaving) {
return;
}
const preview = previewRef.current;
if (!preview) {
return;
}
const rect = preview.getBoundingClientRect();
dragSnapshotRef.current = {
pointerId: event.pointerId,
handle,
clientX: event.clientX,
clientY: event.clientY,
cropRect: normalizedCropRect,
previewWidth: rect.width,
previewHeight: rect.height,
};
setActiveDragHandle(handle);
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
};
const updateCropDrag = (event: PointerEvent<HTMLElement>) => {
const snapshot = dragSnapshotRef.current;
if (!snapshot || snapshot.pointerId !== event.pointerId) {
return;
}
const deltaX =
((event.clientX - snapshot.clientX) * imageSize.width) /
Math.max(1, snapshot.previewWidth);
const deltaY =
((event.clientY - snapshot.clientY) * imageSize.height) /
Math.max(1, snapshot.previewHeight);
onCropRectChange(
resizeSquareCropRectFromHandle(snapshot, deltaX, deltaY, imageSize),
);
};
const stopCropDrag = (event: PointerEvent<HTMLElement>) => {
if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
return;
}
dragSnapshotRef.current = null;
setActiveDragHandle(null);
event.currentTarget.releasePointerCapture(event.pointerId);
};
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id={titleId} className="text-base font-black">
{labels.title}
</div>
<button
type="button"
aria-label={labels.close}
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<div
ref={previewRef}
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
style={editorPreviewStyle}
aria-label={labels.editor}
>
<img
src={source}
alt={labels.previewAlt}
draggable={false}
className="h-full w-full object-fill"
/>
<div
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
}`}
style={previewStyle}
onPointerDown={(event) => beginCropDrag('move', event)}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
/>
<div
className="pointer-events-none absolute border border-white/70"
style={previewStyle}
>
<div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
<div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
<div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
<div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
</div>
<div className="pointer-events-none absolute" style={previewStyle}>
{SQUARE_CROP_RESIZE_HANDLES.map((handleConfig) => (
<button
key={handleConfig.handle}
type="button"
aria-label={handleConfig.label}
disabled={isSaving}
className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 ${handleConfig.className}`}
onPointerDown={(event) =>
beginCropDrag(handleConfig.handle, event)
}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
>
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)]" />
</button>
))}
</div>
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
{labels.cancel}
</button>
<button
type="button"
onClick={onSubmit}
disabled={isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? labels.saving : labels.submit}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import disclaimerMarkdown from '../../../media/files/disclaimer.md?raw';
import privacyPolicyMarkdown from '../../../media/files/privacy_policy.md?raw';
import userAgreementMarkdown from '../../../media/files/user_agreement.md?raw';
export type LegalDocumentId =
| 'user-agreement'
| 'privacy-policy'
| 'disclaimer';
export type LegalDocumentBlock =
| {
type: 'heading';
level: 2 | 3;
text: string;
}
| {
type: 'paragraph';
text: string;
}
| {
type: 'list';
items: string[];
};
export type LegalDocument = {
id: LegalDocumentId;
title: string;
markdown: string;
blocks: LegalDocumentBlock[];
};
export const LEGAL_CONSENT_STORAGE_KEY =
'genarrative.auth.legal-consent.v1';
export const ICP_RECORD_NUMBER = '京ICP备2026025677号';
export const ICP_RECORD_URL = 'https://beian.miit.gov.cn/';
function normalizeMarkdownInlineText(value: string) {
return value.replace(/`([^`]+)`/gu, '$1').trim();
}
function pushParagraph(
blocks: LegalDocumentBlock[],
lines: string[],
) {
if (lines.length === 0) {
return;
}
const text = normalizeMarkdownInlineText(lines.join('\n'));
if (text) {
blocks.push({ type: 'paragraph', text });
}
lines.length = 0;
}
function pushList(blocks: LegalDocumentBlock[], items: string[]) {
if (items.length === 0) {
return;
}
blocks.push({
type: 'list',
items: items.map(normalizeMarkdownInlineText).filter(Boolean),
});
items.length = 0;
}
function parseLegalMarkdown(markdown: string): LegalDocumentBlock[] {
const blocks: LegalDocumentBlock[] = [];
const paragraphLines: string[] = [];
const listItems: string[] = [];
markdown.split(/\r?\n/u).forEach((rawLine) => {
const line = rawLine.trim();
if (!line) {
pushParagraph(blocks, paragraphLines);
pushList(blocks, listItems);
return;
}
const headingMatch = /^(#{2,3})\s+(.+)$/u.exec(line);
if (headingMatch) {
pushParagraph(blocks, paragraphLines);
pushList(blocks, listItems);
blocks.push({
type: 'heading',
level: headingMatch[1]?.length === 2 ? 2 : 3,
text: normalizeMarkdownInlineText(headingMatch[2] ?? ''),
});
return;
}
const listMatch = /^(?:[-*]|\d+[.)])\s+(.+)$/u.exec(line);
if (listMatch) {
pushParagraph(blocks, paragraphLines);
listItems.push(listMatch[1] ?? '');
return;
}
pushList(blocks, listItems);
paragraphLines.push(line);
});
pushParagraph(blocks, paragraphLines);
pushList(blocks, listItems);
return blocks;
}
const legalDocumentDefinitions = [
{
id: 'user-agreement',
title: '用户协议',
markdown: userAgreementMarkdown,
},
{
id: 'privacy-policy',
title: '隐私政策',
markdown: privacyPolicyMarkdown,
},
{
id: 'disclaimer',
title: '免责声明',
markdown: disclaimerMarkdown,
},
] satisfies Array<{
id: LegalDocumentId;
title: string;
markdown: string;
}>;
export const LEGAL_DOCUMENTS: LegalDocument[] = legalDocumentDefinitions.map(
(document) => ({
...document,
blocks: parseLegalMarkdown(document.markdown),
}),
);
export function getLegalDocument(id: LegalDocumentId) {
return LEGAL_DOCUMENTS.find((document) => document.id === id) ?? null;
}
export function readStoredLegalConsent() {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem(LEGAL_CONSENT_STORAGE_KEY) === 'true';
}
export function persistLegalConsent() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(LEGAL_CONSENT_STORAGE_KEY, 'true');
}

View File

@@ -382,7 +382,7 @@ export function CreativeAgentHome({
<CreativeAgentInputComposer
variant="floating"
isBusy={isBusy}
placeholder="问一问百梦"
placeholder="问一问陶泥儿"
onSubmit={(payload) => {
const content = buildCreativeHomeInputParts(payload);
if (content.length === 0) {

View File

@@ -40,7 +40,7 @@ test('shows cost range and opens an independent adjustment dialog', () => {
);
const confirmDialog = screen.getByRole('dialog', { name: '确认拼图模板' });
expect(within(confirmDialog).getByText('预计 2 到 12 点')).toBeTruthy();
expect(within(confirmDialog).getByText('预计 2 到 12 点')).toBeTruthy();
expect(within(confirmDialog).getByText('创意拼图')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: //u }));

View File

@@ -61,7 +61,7 @@ export function CreativeAgentTemplateConfirmPanel({
setDraftSelection(selection);
}, [selection]);
const pointsText = `${draftSelection.costRange.minPoints}${draftSelection.costRange.maxPoints} `;
const pointsText = `${draftSelection.costRange.minPoints}${draftSelection.costRange.maxPoints} `;
const panel = (
<div

View File

@@ -119,7 +119,7 @@ test('target ready session exposes the puzzle result entry action', () => {
expect(screen.getByText('拼图草稿已就绪')).toBeTruthy();
expect(screen.getByText('可以进入结果页继续编辑')).toBeTruthy();
expect(screen.getByText('预计 2-12 点')).toBeTruthy();
expect(screen.getByText('预计 2-12 点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开草稿' }));
@@ -168,7 +168,7 @@ test('waiting confirmation shows template catalog before template config dialog'
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
expect(screen.getByText('预计 4 到 16 点')).toBeTruthy();
expect(screen.getByText('预计 4 到 16 点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: //u }));

View File

@@ -85,7 +85,7 @@ function CreativeAgentTemplateCatalogPanel({
{template.summary}
</div>
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} `}
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} `}
</div>
</button>
))}

View File

@@ -323,7 +323,7 @@ function buildEventProcessItem(
return {
id: `${index}-cost-${event.data.costRange.minPoints}-${event.data.costRange.maxPoints}`,
meta: '消耗',
title: `预计 ${event.data.costRange.minPoints}-${event.data.costRange.maxPoints} `,
title: `预计 ${event.data.costRange.minPoints}-${event.data.costRange.maxPoints} `,
detail: event.data.costRange.reason,
detailLines: [],
tone: 'info',
@@ -482,7 +482,7 @@ function buildSessionFallbackItems(
id: `session-level-plan-${plan.templateId}`,
meta: '关卡',
title: `规划 ${plan.levels.length} 个关卡`,
detail: `${formatPuzzleLevelMode(plan.mode)} · ${plan.estimatedCostRange.minPoints}-${plan.estimatedCostRange.maxPoints} `,
detail: `${formatPuzzleLevelMode(plan.mode)} · ${plan.estimatedCostRange.minPoints}-${plan.estimatedCostRange.maxPoints} `,
detailLines: plan.levels.slice(0, 4).map((level) =>
[
level.levelName,

View File

@@ -5,9 +5,10 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
@@ -189,6 +190,40 @@ const hiddenSquareHoleItem: SquareHoleWorkSummary = {
sourceSessionId: 'square-hole-session-hidden',
};
const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-delete',
profileId: 'baby-object-profile-delete',
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: '香蕉',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
updatedAt: new Date('2026-05-11T10:00:00.000Z').toISOString(),
publishedAt: null,
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -345,9 +380,9 @@ test('creation hub shows puzzle point incentive and claims without opening card'
profileId: 'puzzle-profile-incentive',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '百梦灯塔',
levelName: '陶泥儿灯塔',
summary: '拼图作品会展示积分激励。',
themeTags: ['灯塔', '百梦'],
themeTags: ['灯塔', '陶泥儿'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-01T12:00:00.000Z').toISOString(),
@@ -375,8 +410,8 @@ test('creation hub shows puzzle point incentive and claims without opening card'
/>,
);
expect(screen.getByLabelText('积分激励总数 2.5 点')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 点')).toBeTruthy();
expect(screen.getByLabelText('积分激励总数 2.5 点')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取积分' }));
@@ -462,6 +497,34 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub shows delete action for baby object match drafts', async () => {
const user = userEvent.setup();
const onDeleteBabyObjectMatch = vi.fn();
const onOpenBabyObjectMatchDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBabyObjectMatchDetail={onOpenBabyObjectMatchDetail}
onDeleteBabyObjectMatch={onDeleteBabyObjectMatch}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(babyObjectMatchDraftItem);
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();

View File

@@ -257,3 +257,70 @@ test('creation hub published work spans full mobile row', () => {
expect(html).toContain('col-span-2 sm:col-span-1');
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
});
test('creation hub draft cards use cover background and hide updated time', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
puzzleItems={[
{
workId: 'puzzle:old-draft',
profileId: 'puzzle-profile-old',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '旧草稿',
workDescription: '先前修改的拼图草稿。',
levelName: '旧草稿',
summary: '先前修改的拼图草稿。',
themeTags: [],
coverImageSrc: '/covers/old-draft.webp',
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
{
workId: 'puzzle:new-draft',
profileId: 'puzzle-profile-new',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '新草稿',
workDescription: '最近修改的拼图草稿。',
levelName: '新草稿',
summary: '最近修改的拼图草稿。',
themeTags: [],
coverImageSrc: '/covers/new-draft.webp',
publicationStatus: 'draft',
updatedAt: '1778457601.234567Z',
publishedAt: null,
publishReady: false,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
/>,
);
const newerIndex = html.indexOf('新草稿');
const olderIndex = html.indexOf('旧草稿');
expect(newerIndex).toBeGreaterThanOrEqual(0);
expect(olderIndex).toBeGreaterThanOrEqual(0);
expect(newerIndex).toBeLessThan(olderIndex);
expect(html).toContain(
'class="absolute inset-0 h-full w-full object-cover" src="/covers/new-draft.webp"',
);
expect(html).toContain('src="/covers/new-draft.webp"');
expect(html).not.toContain('1778457601.234567Z');
expect(html).not.toContain('2026-05-07');
expect(html).not.toContain('更新于');
expect(html).not.toContain('最后修改');
});

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,9 @@ type CustomWorldCreationHubProps = {
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
@@ -167,6 +171,9 @@ export function CustomWorldCreationHub({
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
@@ -189,6 +196,7 @@ export function CustomWorldCreationHub({
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
puzzleItems,
babyObjectMatchItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
@@ -196,6 +204,7 @@ export function CustomWorldCreationHub({
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
@@ -209,6 +218,8 @@ export function CustomWorldCreationHub({
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
@@ -216,6 +227,7 @@ export function CustomWorldCreationHub({
[
bigFishItems,
isSquareHoleCreationVisible,
babyObjectMatchItems,
items,
match3dItems,
onDeleteBigFish,
@@ -223,11 +235,13 @@ export function CustomWorldCreationHub({
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
@@ -259,6 +273,38 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems],
);
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;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
@@ -339,8 +385,7 @@ export function CustomWorldCreationHub({
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => {
onOpenShelfItem?.(item);
item.actions.open();
handleOpenShelfItem(item);
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}

View File

@@ -255,7 +255,7 @@ export function CustomWorldWorkCard({
event.preventDefault();
onOpen();
}}
className={`platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
className={`creation-work-card platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
@@ -263,10 +263,9 @@ export function CustomWorldWorkCard({
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0 opacity-70 saturate-[1.08]"
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="creation-work-card__overlay absolute inset-0" />
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
@@ -288,7 +287,7 @@ export function CustomWorldWorkCard({
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
className="grid h-7 w-7 place-items-center rounded-full bg-black/22 text-white/78 transition hover:bg-red-500/22 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
@@ -326,7 +325,7 @@ export function CustomWorldWorkCard({
? '分享内容复制失败'
: '分享'
}
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap rounded-full bg-black/22 px-1.5 text-white/78 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
@@ -358,10 +357,10 @@ export function CustomWorldWorkCard({
</div>
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl xl:text-xl">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.52)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-white/84 [text-shadow:0_1px_8px_rgba(0,0,0,0.5)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
@@ -371,7 +370,7 @@ export function CustomWorldWorkCard({
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} `}
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} `}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
@@ -384,7 +383,7 @@ export function CustomWorldWorkCard({
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} `}
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} `}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">

View File

@@ -1,6 +1,10 @@
import { expect, test, vi } from 'vitest';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
const items = buildCreationWorkShelfItems({
@@ -81,3 +85,228 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork);
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
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: '香蕉',
},
],
visualPackage: null,
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',
},
],
canDeleteBabyObjectMatch: true,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
});
items[1]?.actions.open();
items[1]?.actions.delete?.();
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();
expect(items[1]?.canDelete).toBe(true);
expect(onOpenBabyObjectMatchDetail).toHaveBeenCalledWith(baseDraft);
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(baseDraft);
});
test('buildCreationWorkShelfItems sorts works by latest updatedAt across timestamp formats', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:older',
profileId: 'puzzle-profile-older',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '旧草稿',
summary: '较早修改。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
{
workId: 'puzzle:newer',
profileId: 'puzzle-profile-newer',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '新草稿',
summary: '较晚修改。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '1778457601.234567Z',
publishedAt: null,
publishReady: false,
},
],
});
expect(items.map((item) => item.id)).toEqual([
'puzzle:newer',
'puzzle:older',
]);
});
test('buildCreationWorkShelfItems falls back to available gameplay images as covers', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:level-cover',
profileId: 'puzzle-profile-level-cover',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '关卡封面拼图',
summary: '作品自身封面为空时使用关卡正式图。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '港口雨夜。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle-candidate.png',
assetId: 'asset-1',
prompt: '港口雨夜',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'ready',
},
],
},
],
match3dItems: [
{
workId: 'match3d:asset-cover',
profileId: 'match3d-profile-asset-cover',
ownerUserId: 'user-1',
gameName: '素材封面抓鹅',
themeText: '糖果厨房',
summary: '作品自身封面为空时使用素材图。',
tags: [],
coverImageSrc: null,
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageSrc: '/match3d-item.png',
status: 'image_ready',
},
],
},
],
squareHoleItems: [
{
workId: 'square-hole:background-cover',
profileId: 'square-hole-profile-background-cover',
ownerUserId: 'user-1',
gameName: '背景封面方洞',
themeText: '星空玩具箱',
twistRule: '旋转洞口',
summary: '作品自身封面为空时使用背景图。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '星空玩具箱',
backgroundImageSrc: '/square-hole-background.png',
shapeOptions: [],
holeOptions: [],
shapeCount: 3,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-06T00:00:00.000Z',
publishReady: false,
},
],
});
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
'/puzzle-candidate.png',
);
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'/match3d-item.png',
);
expect(items.find((item) => item.kind === 'square-hole')?.coverImageSrc).toBe(
'/square-hole-background.png',
);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,
);
expect(getCreationWorkShelfItemTime('2026-05-07T00:00:00.000Z')).toBe(
new Date('2026-05-07T00:00:00.000Z').getTime(),
);
});

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,6 +80,10 @@ export type CreationWorkShelfSource =
| {
kind: 'visual-novel';
item: VisualNovelWorkSummary;
}
| {
kind: 'baby-object-match';
item: BabyObjectMatchDraft;
};
export type CreationWorkShelfActions = {
@@ -116,12 +123,14 @@ export function buildCreationWorkShelfItems(params: {
match3dItems?: Match3DWorkSummary[];
squareHoleItems?: SquareHoleWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
visualNovelItems?: VisualNovelWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
canDeleteMatch3D?: boolean;
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void;
@@ -135,6 +144,8 @@ export function buildCreationWorkShelfItems(params: {
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
@@ -148,12 +159,14 @@ export function buildCreationWorkShelfItems(params: {
match3dItems = [],
squareHoleItems = [],
puzzleItems,
babyObjectMatchItems = [],
visualNovelItems = [],
canDeleteRpg = false,
canDeleteBigFish = false,
canDeleteMatch3D = false,
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteVisualNovel = false,
onOpenRpgDraft,
onEnterRpgPublished,
@@ -167,6 +180,8 @@ export function buildCreationWorkShelfItems(params: {
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
@@ -205,6 +220,12 @@ export function buildCreationWorkShelfItems(params: {
onClaimPointIncentive: onClaimPuzzlePointIncentive,
}),
),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
}),
),
...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
@@ -224,7 +245,8 @@ export function buildCreationWorkShelfItems(params: {
})
.sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
getCreationWorkShelfItemTime(right.updatedAt) -
getCreationWorkShelfItemTime(left.updatedAt),
);
}
@@ -347,6 +369,7 @@ function mapMatch3DWorkToShelfItem(
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildMatch3DPublicWorkCode(item.profileId) : null;
const coverImageSrc = resolveMatch3DWorkCoverImageSrc(item);
return {
id: item.workId,
@@ -355,7 +378,7 @@ function mapMatch3DWorkToShelfItem(
title: item.gameName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
@@ -391,6 +414,7 @@ function mapPuzzleWorkToShelfItem(
const status = item.publicationStatus;
const publicWorkCode =
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null;
const coverImageSrc = resolvePuzzleWorkCoverImageSrc(item);
return {
id: item.workId,
@@ -402,7 +426,7 @@ function mapPuzzleWorkToShelfItem(
item.summary.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
@@ -446,6 +470,56 @@ function mapPuzzleWorkToShelfItem(
};
}
function mapBabyObjectMatchDraftToShelfItem(
item: BabyObjectMatchDraft,
canDelete: boolean,
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,
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,
@@ -505,6 +579,7 @@ function mapSquareHoleWorkToShelfItem(
status === 'published'
? buildSquareHolePublicWorkCode(item.profileId)
: null;
const coverImageSrc = resolveSquareHoleWorkCoverImageSrc(item);
return {
id: item.workId,
@@ -513,7 +588,7 @@ function mapSquareHoleWorkToShelfItem(
title: item.gameName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
@@ -541,6 +616,89 @@ function mapSquareHoleWorkToShelfItem(
};
}
function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null;
}
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
return directCoverImageSrc;
}
for (const level of item.levels ?? []) {
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
)
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const levelCoverImageSrc =
selectedCandidateImageSrc ||
normalizeCoverImageSrc(level.coverImageSrc) ||
fallbackCandidateImageSrc;
if (levelCoverImageSrc) {
return levelCoverImageSrc;
}
}
return null;
}
function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
return directCoverImageSrc;
}
const backgroundImageSrc =
normalizeCoverImageSrc(item.backgroundImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const imageViewSrc = normalizeCoverImageSrc(
asset.imageViews?.find((view) => normalizeCoverImageSrc(view.imageSrc))
?.imageSrc,
);
const itemImageSrc = normalizeCoverImageSrc(asset.imageSrc);
if (imageViewSrc || itemImageSrc) {
return imageViewSrc || itemImageSrc;
}
}
return null;
}
function resolveSquareHoleWorkCoverImageSrc(item: SquareHoleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
return directCoverImageSrc;
}
const backgroundImageSrc = normalizeCoverImageSrc(item.backgroundImageSrc);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const option of [...item.shapeOptions, ...item.holeOptions]) {
const optionImageSrc = normalizeCoverImageSrc(option.imageSrc);
if (optionImageSrc) {
return optionImageSrc;
}
}
return null;
}
function buildWorkShelfActions<TItem>(
item: TItem,
@@ -667,7 +825,25 @@ function buildStatusBadge(
};
}
function getShelfItemTime(value: string) {
const timestamp = new Date(value).getTime();
export function getCreationWorkShelfItemTime(value: string) {
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
if (absoluteTimestamp >= 1_000_000_000_000_000) {
return rawTimestamp / 1000;
}
if (absoluteTimestamp >= 1_000_000_000_000) {
return rawTimestamp;
}
if (absoluteTimestamp >= 1_000_000_000) {
return rawTimestamp * 1000;
}
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}

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,209 @@
/* @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: '香蕉',
},
],
visualPackage: null,
themeTags: ['宝贝识物'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: null,
...overrides,
};
return draft;
}
function createGeneratedDraft() {
return createDraft({
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,a',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,b',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '香蕉',
},
],
visualPackage: {
themePrompt: '果园主题',
assets: [
{
assetId: 'baby-object-visual-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background',
},
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui',
},
{
assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'gift',
},
{
assetId: 'baby-object-visual-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket',
},
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke',
},
],
},
});
}
test('baby object result publishes with exact edutainment tag', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
render(
<BabyObjectMatchResultView
draft={createGeneratedDraft()}
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={createGeneratedDraft()}
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);
});
test('baby object result blocks placeholder assets and exposes regeneration', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
const onStartTestRun = vi.fn();
const onRegenerateAssets = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
onBack={() => {}}
onPublish={onPublish}
onStartTestRun={onStartTestRun}
onRegenerateAssets={onRegenerateAssets}
/>,
);
expect(
screen.getByText('当前作品仍是占位资源,请重新生成 image-2 资源后再试玩或发布。'),
).toBeTruthy();
expect(
(screen.getByRole('button', { name: '试玩' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '发布' }) as HTMLButtonElement)
.disabled,
).toBe(true);
await user.click(screen.getByRole('button', { name: '重新生成资源' }));
expect(onRegenerateAssets).toHaveBeenCalledTimes(1);
expect(onPublish).not.toHaveBeenCalled();
expect(onStartTestRun).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,219 @@
import {
ArrowLeft,
CheckCircle2,
Loader2,
Play,
RefreshCw,
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;
onRegenerateAssets?: (draft: BabyObjectMatchDraft) => void;
};
function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
return {
...draft,
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
updatedAt: new Date().toISOString(),
};
}
const REQUIRED_VISUAL_ASSET_KINDS = [
'background',
'ui-frame',
'gift-box',
'basket',
'smoke-puff',
] as const;
export function BabyObjectMatchResultView({
draft,
isBusy = false,
error = null,
onBack,
onSaveDraft,
onPublish,
onStartTestRun,
onRegenerateAssets,
}: BabyObjectMatchResultViewProps) {
const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]);
const hasGeneratedAssets =
normalizedDraft.itemAssets.every(
(asset) =>
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
) &&
Boolean(normalizedDraft.visualPackage) &&
REQUIRED_VISUAL_ASSET_KINDS.every((kind) =>
normalizedDraft.visualPackage!.assets.some(
(asset) =>
asset.assetKind === kind &&
asset.generationProvider === 'vector-engine-gpt-image-2' &&
asset.imageSrc.startsWith('data:image/png;base64,'),
),
);
const publishReady =
normalizedDraft.itemNames.every((itemName) => itemName.trim()) &&
normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) &&
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags) &&
hasGeneratedAssets;
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}
{!hasGeneratedAssets ? (
<div className="platform-banner mt-3 rounded-2xl text-sm leading-6">
image-2
</div>
) : null}
</div>
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-4">
<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 || !onRegenerateAssets}
onClick={() => onRegenerateAssets?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
<button
type="button"
disabled={isBusy || !hasGeneratedAssets || !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,246 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { BabyLoveDrawingRuntimeShell } from './BabyLoveDrawingRuntimeShell';
const saveBabyLoveDrawingMock = vi.fn();
const createBabyLoveDrawingMagicImageMock = vi.fn();
const mocapMock = vi.hoisted(() => ({
command: null as null | {
actions: string[];
leftHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'left';
} | null;
rightHand?: {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
side: 'right';
} | null;
},
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: () => ({
status: 'idle',
latestCommand: mocapMock.command,
rawPacketPreview: null,
error: null,
}),
}));
vi.mock('../../services/edutainment-baby-drawing', () => ({
createBabyLoveDrawingMagicImage: (...args: unknown[]) =>
createBabyLoveDrawingMagicImageMock(...args),
saveBabyLoveDrawing: (...args: unknown[]) => saveBabyLoveDrawingMock(...args),
}));
function installCanvasMock() {
const context = {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
drawImage: vi.fn(),
set fillStyle(_value: string) {},
set strokeStyle(_value: string) {},
set lineWidth(_value: number) {},
set lineCap(_value: CanvasLineCap) {},
set lineJoin(_value: CanvasLineJoin) {},
set globalCompositeOperation(_value: GlobalCompositeOperation) {},
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
'data:image/png;base64,original',
);
}
beforeEach(() => {
installCanvasMock();
mocapMock.command = null;
saveBabyLoveDrawingMock.mockReturnValue({
record: {
drawingId: 'baby-love-drawing-local-1',
templateId: 'baby-love-drawing',
templateName: '宝贝爱画',
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
strokeTrace: [],
saveMode: 'original-only',
themeTags: ['寓教于乐', '宝贝爱画'],
createdAt: '2026-05-13T08:00:00.000Z',
updatedAt: '2026-05-13T08:00:00.000Z',
},
});
createBabyLoveDrawingMagicImageMock.mockResolvedValue({
magicImageSrc: 'data:image/png;base64,magic',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '绘本风格',
});
});
afterEach(() => {
vi.restoreAllMocks();
});
test('renders drawing board, seven colors and tool buttons', () => {
render(<BabyLoveDrawingRuntimeShell />);
expect(screen.getByTestId('baby-love-drawing-runtime')).toBeTruthy();
expect(screen.getByLabelText('画板')).toBeTruthy();
expect(screen.getByLabelText('红')).toBeTruthy();
expect(screen.getByLabelText('紫')).toBeTruthy();
expect(screen.getAllByRole('button')).toHaveLength(11);
expect(screen.getByLabelText('画笔')).toBeTruthy();
expect(screen.getByLabelText('橡皮')).toBeTruthy();
});
test('finish then save stores original drawing in local demo service', () => {
render(<BabyLoveDrawingRuntimeShell />);
fireEvent.click(screen.getByRole('button', { name: '完成' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(saveBabyLoveDrawingMock).toHaveBeenCalledWith(
expect.objectContaining({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
}),
);
expect(screen.getByText('已保存')).toBeTruthy();
});
test('back button calls onBack callback', () => {
const onBack = vi.fn();
render(<BabyLoveDrawingRuntimeShell onBack={onBack} />);
fireEvent.click(screen.getByLabelText('返回'));
expect(onBack).toHaveBeenCalledTimes(1);
});
test('mocap camera-left hand drives the player right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.18, y: 0.82, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});
test('mocap camera-right hand renders the player left hand color indicator', () => {
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container } = render(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('left hand indicator stays visible through brief mocap hand loss', () => {
vi.useFakeTimers();
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.16, y: 0.42, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
vi.advanceTimersByTime(120);
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
const indicator = container.querySelector(
'.baby-love-drawing-runtime__left-hand-indicator',
) as HTMLElement;
expect(indicator).toBeTruthy();
expect(indicator.style.left).toBe('16%');
expect(indicator.style.top).toBe('42%');
});
test('player left hand never takes over the right hand brush cursor', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.68, y: 0.32, state: 'open_palm', side: 'left' },
rightHand: { x: 0.18, y: 0.78, state: 'open_palm', side: 'right' },
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
mocapMock.command = {
actions: [],
leftHand: null,
rightHand: { x: 0.7, y: 0.3, state: 'grab', side: 'right' },
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('68%');
expect(cursor.style.top).toBe('32%');
});
test('large camera-left jump is rejected to prevent left hand stealing brush', () => {
mocapMock.command = {
actions: [],
leftHand: { x: 0.72, y: 0.34, state: 'open_palm', side: 'left' },
rightHand: null,
};
const { container, rerender } = render(<BabyLoveDrawingRuntimeShell />);
const cursor = container.querySelector(
'.baby-love-drawing-runtime__cursor',
) as HTMLElement;
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
mocapMock.command = {
actions: [],
leftHand: { x: 0.16, y: 0.82, state: 'grab', side: 'left' },
rightHand: null,
};
rerender(<BabyLoveDrawingRuntimeShell />);
expect(cursor.style.left).toBe('72%');
expect(cursor.style.top).toBe('34%');
});

View File

@@ -0,0 +1,932 @@
import {
ArrowLeft,
Brush,
Check,
Eraser,
ImagePlus,
RotateCcw,
Save,
Sparkles,
} from 'lucide-react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
BabyLoveDrawingPoint,
BabyLoveDrawingRecord,
BabyLoveDrawingStroke,
BabyLoveDrawingTool,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import { BABY_LOVE_DRAWING_RAINBOW_COLORS } from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import {
createBabyLoveDrawingMagicImage,
saveBabyLoveDrawing,
} from '../../services/edutainment-baby-drawing';
import type { MocapHandInput } from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput';
import {
appendPointToStroke,
BABY_LOVE_DRAWING_BRUSH_SIZE,
BABY_LOVE_DRAWING_DEFAULT_COLOR,
BABY_LOVE_DRAWING_ERASER_SIZE,
type BabyLoveDrawingBounds,
type BabyLoveDrawingHandPoint,
type BabyLoveDrawingHoverTarget,
type BabyLoveDrawingPhase,
createBabyDrawingStroke,
hasHoverCompleted,
isPointInsideBounds,
resolveHoverProgress,
toCanvasPoint,
} from './babyLoveDrawingModel';
type BabyLoveDrawingRuntimeShellProps = {
onBack?: () => void;
};
type ActionButtonId = 'finish' | 'magic' | 'save' | 'restart' | 'back';
type RectMap = {
canvas: BabyLoveDrawingBounds | null;
colors: Record<string, BabyLoveDrawingBounds>;
tools: Record<BabyLoveDrawingTool, BabyLoveDrawingBounds | null>;
buttons: Record<ActionButtonId, BabyLoveDrawingBounds | null>;
};
type ActiveStrokeState = {
stroke: BabyLoveDrawingStroke;
lastPoint: BabyLoveDrawingPoint;
};
const BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS = 320;
const BABY_LOVE_DRAWING_HAND_SMOOTHING = 0.38;
const BABY_LOVE_DRAWING_RIGHT_HAND_MAX_FRAME_JUMP = 0.28;
const EMPTY_RECT_MAP: RectMap = {
canvas: null,
colors: {},
tools: {
brush: null,
eraser: null,
},
buttons: {
finish: null,
magic: null,
save: null,
restart: null,
back: null,
},
};
function pointFromPointer(
event: ReactPointerEvent<HTMLElement>,
element: HTMLElement,
): BabyLoveDrawingHandPoint {
const rect = element.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
return {
x: Math.max(0, Math.min(1, (event.clientX - rect.left) / width)),
y: Math.max(0, Math.min(1, (event.clientY - rect.top) / height)),
state: event.buttons ? 'grab' : 'open_palm',
};
}
function handToPoint(hand: MocapHandInput | null | undefined) {
if (!hand) {
return null;
}
return {
x: hand.x,
y: hand.y,
state: hand.state,
} satisfies BabyLoveDrawingHandPoint;
}
function commandToPlayerLeftHand(command: {
rightHand?: MocapHandInput | null;
}) {
// 本地 mocap handedness 当前按摄像头视角输出:画面右侧手对应用户身体左手。
return handToPoint(command.rightHand);
}
function commandToPlayerRightHand(command: {
leftHand?: MocapHandInput | null;
}) {
// 本地 mocap handedness 当前按摄像头视角输出:画面左侧手对应用户身体右手。
return handToPoint(command.leftHand);
}
function smoothHandPoint(
previous: BabyLoveDrawingHandPoint | null,
next: BabyLoveDrawingHandPoint,
): BabyLoveDrawingHandPoint {
if (!previous) {
return next;
}
return {
x:
previous.x +
(next.x - previous.x) * BABY_LOVE_DRAWING_HAND_SMOOTHING,
y:
previous.y +
(next.y - previous.y) * BABY_LOVE_DRAWING_HAND_SMOOTHING,
state: next.state,
};
}
function getHandPointDistance(
left: BabyLoveDrawingHandPoint,
right: BabyLoveDrawingHandPoint,
) {
return Math.hypot(left.x - right.x, left.y - right.y);
}
function canAcceptRightHandPoint(
previous: BabyLoveDrawingHandPoint | null,
next: BabyLoveDrawingHandPoint | null,
) {
if (!next || !previous) {
return Boolean(next);
}
return (
getHandPointDistance(previous, next) <=
BABY_LOVE_DRAWING_RIGHT_HAND_MAX_FRAME_JUMP
);
}
function sameHoverTarget(
left: BabyLoveDrawingHoverTarget,
right: BabyLoveDrawingHoverTarget,
) {
if (!left || !right) {
return left === right;
}
return left.kind === right.kind && left.id === right.id;
}
function findTargetInBounds<T extends string>(
point: BabyLoveDrawingHandPoint | null,
bounds: Record<T, BabyLoveDrawingBounds | null>,
): T | null {
if (!point) {
return null;
}
for (const [id, rect] of Object.entries(bounds) as Array<
[T, BabyLoveDrawingBounds | null]
>) {
if (rect && isPointInsideBounds(point, rect)) {
return id;
}
}
return null;
}
function drawStrokeSegment(
context: CanvasRenderingContext2D,
stroke: BabyLoveDrawingStroke,
from: BabyLoveDrawingPoint,
to: BabyLoveDrawingPoint,
width: number,
height: number,
) {
context.save();
context.lineCap = 'round';
context.lineJoin = 'round';
context.lineWidth =
stroke.tool === 'brush'
? BABY_LOVE_DRAWING_BRUSH_SIZE
: BABY_LOVE_DRAWING_ERASER_SIZE;
if (stroke.tool === 'eraser') {
context.globalCompositeOperation = 'destination-out';
context.strokeStyle = 'rgba(0,0,0,1)';
} else {
context.globalCompositeOperation = 'source-over';
context.strokeStyle = stroke.color;
}
context.beginPath();
context.moveTo(from.x * width, from.y * height);
context.lineTo(to.x * width, to.y * height);
context.stroke();
context.restore();
}
export function BabyLoveDrawingRuntimeShell({
onBack,
}: BabyLoveDrawingRuntimeShellProps) {
const shellRef = useRef<HTMLElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const rectMapRef = useRef<RectMap>(EMPTY_RECT_MAP);
const activeStrokeRef = useRef<ActiveStrokeState | null>(null);
const hoverTargetRef = useRef<BabyLoveDrawingHoverTarget>(null);
const hoverStartedAtRef = useRef<number | null>(null);
const hoverCompletedKeyRef = useRef<string | null>(null);
const previousToolGrabRef = useRef<string | null>(null);
const visibleLeftHandRef = useRef<BabyLoveDrawingHandPoint | null>(null);
const visibleRightHandRef = useRef<BabyLoveDrawingHandPoint | null>(null);
const leftHandSeenAtRef = useRef<number | null>(null);
const rightHandSeenAtRef = useRef<number | null>(null);
const [phase, setPhase] = useState<BabyLoveDrawingPhase>('drawing');
const [selectedColor, setSelectedColor] = useState<string>(
BABY_LOVE_DRAWING_DEFAULT_COLOR,
);
const [selectedTool, setSelectedTool] =
useState<BabyLoveDrawingTool>('brush');
const [strokes, setStrokes] = useState<BabyLoveDrawingStroke[]>([]);
const [rightHandPoint, setRightHandPoint] =
useState<BabyLoveDrawingHandPoint | null>(null);
const [leftHandPoint, setLeftHandPoint] =
useState<BabyLoveDrawingHandPoint | null>(null);
const [hoverTarget, setHoverTarget] =
useState<BabyLoveDrawingHoverTarget>(null);
const [hoverProgress, setHoverProgress] = useState(0);
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [magicImageSrc, setMagicImageSrc] = useState<string | null>(null);
const [savedRecord, setSavedRecord] = useState<BabyLoveDrawingRecord | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const { latestCommand } = useMocapInput({ enabled: true });
const canUseMagic = phase === 'finished' || phase === 'magicReady';
const canSave = phase === 'finished' || phase === 'magicReady';
const actionButtons = useMemo(
() => [
{
id: 'finish' as const,
label: '完成',
icon: Check,
visible: phase === 'drawing',
},
{
id: 'magic' as const,
label: phase === 'magicPending' ? '魔法中' : '使用绘画魔法',
icon: Sparkles,
visible: phase === 'finished' || phase === 'magicReady' || phase === 'magicPending',
},
{
id: 'save' as const,
label: '保存',
icon: Save,
visible: canSave,
},
{
id: 'restart' as const,
label: '再画一张',
icon: RotateCcw,
visible: phase === 'saved',
},
{
id: 'back' as const,
label: '返回',
icon: ArrowLeft,
visible: phase === 'saved',
},
],
[canSave, phase],
);
const updateRectMap = useCallback(() => {
const shell = shellRef.current;
if (!shell) {
return;
}
const shellRect = shell.getBoundingClientRect();
const toUnitBounds = (element: Element | null): BabyLoveDrawingBounds | null => {
if (!(element instanceof HTMLElement)) {
return null;
}
const rect = element.getBoundingClientRect();
return {
left: (rect.left - shellRect.left) / shellRect.width,
top: (rect.top - shellRect.top) / shellRect.height,
width: rect.width / shellRect.width,
height: rect.height / shellRect.height,
};
};
const colors: Record<string, BabyLoveDrawingBounds> = {};
BABY_LOVE_DRAWING_RAINBOW_COLORS.forEach((color) => {
const rect = toUnitBounds(
shell.querySelector(`[data-baby-drawing-color="${color.id}"]`),
);
if (rect) {
colors[color.id] = rect;
}
});
rectMapRef.current = {
canvas: toUnitBounds(shell.querySelector('[data-baby-drawing-canvas]')),
colors,
tools: {
brush: toUnitBounds(shell.querySelector('[data-baby-drawing-tool="brush"]')),
eraser: toUnitBounds(shell.querySelector('[data-baby-drawing-tool="eraser"]')),
},
buttons: {
finish: toUnitBounds(shell.querySelector('[data-baby-drawing-button="finish"]')),
magic: toUnitBounds(shell.querySelector('[data-baby-drawing-button="magic"]')),
save: toUnitBounds(shell.querySelector('[data-baby-drawing-button="save"]')),
restart: toUnitBounds(shell.querySelector('[data-baby-drawing-button="restart"]')),
back: toUnitBounds(shell.querySelector('[data-baby-drawing-button="back"]')),
},
};
}, []);
useEffect(() => {
updateRectMap();
window.addEventListener('resize', updateRectMap);
return () => window.removeEventListener('resize', updateRectMap);
}, [updateRectMap]);
useEffect(() => {
updateRectMap();
}, [actionButtons, phase, updateRectMap]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const resizeCanvas = () => {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const nextWidth = Math.max(1, Math.floor(rect.width * dpr));
const nextHeight = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width === nextWidth && canvas.height === nextHeight) {
return;
}
const previousImage = canvas.toDataURL('image/png');
canvas.width = nextWidth;
canvas.height = nextHeight;
const context = canvas.getContext('2d');
if (!context) {
return;
}
context.fillStyle = '#fffdf4';
context.fillRect(0, 0, canvas.width, canvas.height);
if (previousImage) {
const image = new Image();
image.onload = () => {
context.drawImage(image, 0, 0, canvas.width, canvas.height);
};
image.src = previousImage;
}
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => window.removeEventListener('resize', resizeCanvas);
}, []);
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) {
return;
}
context.globalCompositeOperation = 'source-over';
context.fillStyle = '#fffdf4';
context.fillRect(0, 0, canvas.width, canvas.height);
}, []);
useEffect(() => {
clearCanvas();
}, [clearCanvas]);
const captureOriginalImage = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) {
return null;
}
return canvas.toDataURL('image/png');
}, []);
const finishDrawing = useCallback(() => {
const imageSrc = captureOriginalImage();
if (!imageSrc) {
return;
}
activeStrokeRef.current = null;
setOriginalImageSrc(imageSrc);
setPhase('finished');
setError(null);
}, [captureOriginalImage]);
const restartDrawing = useCallback(() => {
activeStrokeRef.current = null;
hoverTargetRef.current = null;
hoverStartedAtRef.current = null;
hoverCompletedKeyRef.current = null;
setPhase('drawing');
setSelectedColor(BABY_LOVE_DRAWING_DEFAULT_COLOR);
setSelectedTool('brush');
setStrokes([]);
setOriginalImageSrc(null);
setMagicImageSrc(null);
setSavedRecord(null);
setError(null);
setHoverTarget(null);
setHoverProgress(0);
clearCanvas();
}, [clearCanvas]);
const saveCurrentDrawing = useCallback(() => {
const imageSrc = originalImageSrc ?? captureOriginalImage();
if (!imageSrc) {
return;
}
const response = saveBabyLoveDrawing({
originalImageSrc: imageSrc,
magicImageSrc,
strokeTrace: strokes,
});
setOriginalImageSrc(imageSrc);
setSavedRecord(response.record);
setPhase('saved');
setError(null);
}, [captureOriginalImage, magicImageSrc, originalImageSrc, strokes]);
const generateMagicImage = useCallback(async () => {
const imageSrc = originalImageSrc ?? captureOriginalImage();
if (!imageSrc || phase === 'magicPending') {
return;
}
setOriginalImageSrc(imageSrc);
setPhase('magicPending');
setError(null);
try {
const response = await createBabyLoveDrawingMagicImage({
originalImageSrc: imageSrc,
strokeTrace: strokes,
});
setMagicImageSrc(response.magicImageSrc);
setPhase('magicReady');
} catch (magicError) {
setError(
magicError instanceof Error
? magicError.message
: '生成宝贝爱画魔法图片失败。',
);
setPhase('finished');
}
}, [captureOriginalImage, originalImageSrc, phase, strokes]);
const triggerButton = useCallback(
(buttonId: string) => {
if (buttonId === 'finish' && phase === 'drawing') {
finishDrawing();
return;
}
if (buttonId === 'magic' && canUseMagic) {
void generateMagicImage();
return;
}
if (buttonId === 'save' && canSave) {
saveCurrentDrawing();
return;
}
if (buttonId === 'restart' && phase === 'saved') {
restartDrawing();
return;
}
if (buttonId === 'back' && phase === 'saved') {
onBack?.();
}
},
[
canSave,
canUseMagic,
finishDrawing,
generateMagicImage,
onBack,
phase,
restartDrawing,
saveCurrentDrawing,
],
);
const applyHoverTarget = useCallback(
(nextTarget: BabyLoveDrawingHoverTarget) => {
const currentTarget = hoverTargetRef.current;
const now = Date.now();
if (!sameHoverTarget(currentTarget, nextTarget)) {
hoverTargetRef.current = nextTarget;
hoverStartedAtRef.current = nextTarget ? now : null;
hoverCompletedKeyRef.current = null;
setHoverTarget(nextTarget);
setHoverProgress(0);
return;
}
const startedAt = hoverStartedAtRef.current;
const progress = resolveHoverProgress(nextTarget, startedAt, now);
setHoverProgress(progress);
if (!hasHoverCompleted(nextTarget, startedAt, now) || !nextTarget) {
return;
}
const completeKey = `${nextTarget.kind}:${nextTarget.id}`;
if (hoverCompletedKeyRef.current === completeKey) {
return;
}
hoverCompletedKeyRef.current = completeKey;
if (nextTarget.kind === 'color') {
const color = BABY_LOVE_DRAWING_RAINBOW_COLORS.find(
(item) => item.id === nextTarget.id,
);
if (color) {
setSelectedColor(color.value);
setSelectedTool('brush');
}
return;
}
triggerButton(nextTarget.id);
},
[triggerButton],
);
const updateToolFromRightHand = useCallback((point: BabyLoveDrawingHandPoint | null) => {
if (!point || point.state !== 'grab') {
previousToolGrabRef.current = null;
return;
}
const tool = findTargetInBounds(point, rectMapRef.current.tools);
if (!tool) {
previousToolGrabRef.current = null;
return;
}
if (previousToolGrabRef.current === tool) {
return;
}
previousToolGrabRef.current = tool;
setSelectedTool(tool);
}, []);
const drawWithRightHand = useCallback(
(point: BabyLoveDrawingHandPoint | null) => {
const canvasBounds = rectMapRef.current.canvas;
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (
phase !== 'drawing' ||
!point ||
point.state !== 'grab' ||
!canvasBounds ||
!canvas ||
!context ||
!isPointInsideBounds(point, canvasBounds)
) {
activeStrokeRef.current = null;
return;
}
const nextPoint = toCanvasPoint(point, canvasBounds);
const activeStroke = activeStrokeRef.current;
if (!activeStroke) {
const stroke = createBabyDrawingStroke(
selectedTool,
selectedColor,
nextPoint,
);
activeStrokeRef.current = {
stroke,
lastPoint: nextPoint,
};
setStrokes((current) => [...current, stroke]);
return;
}
const nextStroke = appendPointToStroke(activeStroke.stroke, nextPoint);
drawStrokeSegment(
context,
nextStroke,
activeStroke.lastPoint,
nextPoint,
canvas.width,
canvas.height,
);
activeStrokeRef.current = {
stroke: nextStroke,
lastPoint: nextPoint,
};
setStrokes((current) =>
current.map((stroke) =>
stroke.strokeId === nextStroke.strokeId ? nextStroke : stroke,
),
);
},
[phase, selectedColor, selectedTool],
);
const updateInteraction = useCallback(
(
nextLeftHand: BabyLoveDrawingHandPoint | null,
nextRightHand: BabyLoveDrawingHandPoint | null,
) => {
const now = Date.now();
const previousLeftHand = visibleLeftHandRef.current;
const previousRightHand = visibleRightHandRef.current;
const acceptedRightHand = canAcceptRightHandPoint(
previousRightHand,
nextRightHand,
)
? nextRightHand
: null;
const visibleLeftHand = nextLeftHand
? smoothHandPoint(previousLeftHand, nextLeftHand)
: previousLeftHand &&
leftHandSeenAtRef.current !== null &&
now - leftHandSeenAtRef.current <=
BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS
? previousLeftHand
: null;
const visibleRightHand = acceptedRightHand
? smoothHandPoint(previousRightHand, acceptedRightHand)
: previousRightHand &&
rightHandSeenAtRef.current !== null &&
now - rightHandSeenAtRef.current <=
BABY_LOVE_DRAWING_HAND_LOSS_GRACE_MS
? previousRightHand
: null;
const activeRightHand = acceptedRightHand ? visibleRightHand : null;
if (nextLeftHand) {
leftHandSeenAtRef.current = now;
}
if (acceptedRightHand) {
rightHandSeenAtRef.current = now;
}
visibleLeftHandRef.current = visibleLeftHand;
visibleRightHandRef.current = visibleRightHand;
setLeftHandPoint(visibleLeftHand);
setRightHandPoint(visibleRightHand);
updateToolFromRightHand(activeRightHand);
drawWithRightHand(activeRightHand);
const colorId = findTargetInBounds(
visibleLeftHand,
rectMapRef.current.colors,
);
const buttonId =
findTargetInBounds(visibleLeftHand, rectMapRef.current.buttons) ??
findTargetInBounds(visibleRightHand, rectMapRef.current.buttons);
const nextHoverTarget: BabyLoveDrawingHoverTarget = colorId
? { kind: 'color', id: colorId }
: buttonId
? { kind: 'button', id: buttonId }
: null;
applyHoverTarget(nextHoverTarget);
},
[applyHoverTarget, drawWithRightHand, updateToolFromRightHand],
);
useEffect(() => {
if (!latestCommand) {
return;
}
updateInteraction(
commandToPlayerLeftHand(latestCommand),
commandToPlayerRightHand(latestCommand),
);
}, [latestCommand, updateInteraction]);
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
const point = pointFromPointer(event, event.currentTarget);
if (event.button === 2) {
updateInteraction(leftHandPoint, { ...point, state: 'grab' as const });
return;
}
updateInteraction({ ...point, state: 'open_palm' as const }, null);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
const point = pointFromPointer(event, event.currentTarget);
const nextState: BabyLoveDrawingHandPoint['state'] = event.buttons
? 'grab'
: 'open_palm';
const nextPoint: BabyLoveDrawingHandPoint = {
...point,
state: nextState,
};
if (event.buttons === 2) {
updateInteraction(leftHandPoint, nextPoint);
return;
}
updateInteraction(nextPoint, null);
};
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
const point = pointFromPointer(event, event.currentTarget);
if (event.button === 2) {
updateInteraction(leftHandPoint, { ...point, state: 'open_palm' as const });
return;
}
updateInteraction({ ...point, state: 'open_palm' as const }, null);
};
return (
<main
ref={shellRef}
className="baby-love-drawing-runtime"
data-testid="baby-love-drawing-runtime"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
className="baby-love-drawing-runtime__back"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="baby-love-drawing-runtime__colors" aria-label="颜色">
{BABY_LOVE_DRAWING_RAINBOW_COLORS.map((color) => (
<button
key={color.id}
type="button"
data-baby-drawing-color={color.id}
className={`baby-love-drawing-runtime__color${
selectedColor === color.value
? ' baby-love-drawing-runtime__color--active'
: ''
}`}
style={{ '--baby-drawing-color': color.value } as CSSProperties}
aria-label={color.label}
title={color.label}
/>
))}
</div>
<section className="baby-love-drawing-runtime__board">
<canvas
ref={canvasRef}
data-baby-drawing-canvas
className="baby-love-drawing-runtime__canvas"
aria-label="画板"
/>
{magicImageSrc && phase !== 'drawing' ? (
<img
src={magicImageSrc}
alt="绘画魔法结果"
className="baby-love-drawing-runtime__magic-image"
/>
) : null}
{phase === 'magicPending' ? (
<div className="baby-love-drawing-runtime__magic-pending">
<Sparkles className="h-7 w-7" />
</div>
) : null}
</section>
<div className="baby-love-drawing-runtime__tools" aria-label="工具">
<button
type="button"
data-baby-drawing-tool="brush"
className={`baby-love-drawing-runtime__tool${
selectedTool === 'brush'
? ' baby-love-drawing-runtime__tool--active'
: ''
}`}
aria-label="画笔"
title="画笔"
>
<Brush className="h-7 w-7" />
</button>
<button
type="button"
data-baby-drawing-tool="eraser"
className={`baby-love-drawing-runtime__tool${
selectedTool === 'eraser'
? ' baby-love-drawing-runtime__tool--active'
: ''
}`}
aria-label="橡皮"
title="橡皮"
>
<Eraser className="h-7 w-7" />
</button>
</div>
<div className="baby-love-drawing-runtime__actions">
{actionButtons
.filter((button) => button.visible)
.map((button) => {
const Icon = button.icon;
const isHovering =
hoverTarget?.kind === 'button' && hoverTarget.id === button.id;
return (
<button
key={button.id}
type="button"
data-baby-drawing-button={button.id}
className="baby-love-drawing-runtime__action"
disabled={button.id === 'magic' && phase === 'magicPending'}
onClick={() => triggerButton(button.id)}
>
<Icon className="h-4 w-4" />
<span>{button.label}</span>
{isHovering ? (
<span
className="baby-love-drawing-runtime__action-progress"
style={
{
'--baby-drawing-hover-progress': `${hoverProgress * 100}%`,
} as CSSProperties
}
/>
) : null}
</button>
);
})}
</div>
{error ? (
<div className="baby-love-drawing-runtime__error">{error}</div>
) : null}
{savedRecord ? (
<div className="baby-love-drawing-runtime__saved" role="status">
<ImagePlus className="h-5 w-5" />
</div>
) : null}
{leftHandPoint ? (
<div
className="baby-love-drawing-runtime__left-hand-indicator"
aria-hidden="true"
style={
{
left: `${leftHandPoint.x * 100}%`,
top: `${leftHandPoint.y * 100}%`,
} as CSSProperties
}
>
<span />
</div>
) : null}
<div
className={`baby-love-drawing-runtime__cursor baby-love-drawing-runtime__cursor--${selectedTool}`}
style={
{
left: `${(rightHandPoint?.x ?? 0.5) * 100}%`,
top: `${(rightHandPoint?.y ?? 0.5) * 100}%`,
'--baby-drawing-color': selectedColor,
} as CSSProperties
}
>
{selectedTool === 'brush' ? (
<Brush className="h-5 w-5" />
) : (
<Eraser className="h-5 w-5" />
)}
</div>
</main>
);
}
export default BabyLoveDrawingRuntimeShell;

View File

@@ -0,0 +1,763 @@
/* @vitest-environment jsdom */
import { act, 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,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
'data-testid'?: string;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : 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: '香蕉',
},
],
visualPackage: null,
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 createVisualPackageDraft(): BabyObjectMatchDraft {
return {
...createDraft(),
visualPackage: {
themePrompt: '果园主题',
assets: [
{
assetId: 'baby-object-visual-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '背景',
},
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'UI',
},
{
assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '礼盒',
},
{
assetId: 'baby-object-visual-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '篮子',
},
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '烟雾',
},
],
},
};
}
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,
});
});
}
async function advanceRoundIntro() {
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(640);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
}
async function advanceFeedback() {
await act(async () => {
await vi.advanceTimersByTimeAsync(1200);
});
}
test('shows the first gift item after gift and item animations', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
/>,
);
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell draft={createVisualPackageDraft()} />,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
expect(stage.classList.contains('baby-object-runtime__stage--skinned')).toBe(
true,
);
expect(
screen
.getByTestId('baby-object-background-image')
.getAttribute('src'),
).toBe('data:image/png;base64,background');
expect(
stage.style.getPropertyValue('--baby-object-ui-frame-image'),
).toContain('ui');
expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain(
'smoke',
);
expect(screen.getByAltText('礼物盒')).toBeTruthy();
expect(
container.querySelector('.baby-object-runtime__basket-shell-image'),
).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
vi.useRealTimers();
});
test('removes the gift box after smoke releases the current item', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createVisualPackageDraft()}
random={createRandomSequence([0])}
/>,
);
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(640);
});
expect(screen.queryByLabelText('礼物盒')).toBeNull();
expect(screen.getByTestId('baby-object-smoke-effect')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(620);
});
expect(screen.queryByLabelText('礼物盒')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('keeps left and right baskets fixed while only the gift item is random', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0.99])}
/>,
);
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
).toBeTruthy();
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
vi.useRealTimers();
});
test('mocap camera-right hand movement sends the player left hand item into the left basket', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.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: 1,
},
})}
/>,
);
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: 2,
},
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'camera-right-horizontal-3',
receivedAtMs: 3,
},
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
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: 4,
},
})}
/>,
);
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', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.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: 1 },
})}
/>,
);
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: 2 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 3 },
})}
/>,
);
expect(screen.queryByText('再想一想吧')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.73, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 4 },
})}
/>,
);
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', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: ['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: 1 },
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('mocap unknown hand horizontal movement does not select a basket', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
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 + 1,
},
})}
/>,
);
}
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('left hand horizontal drag sends a correct item into the left basket', async () => {
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');
}
await advanceRoundIntro();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
expect(screen.getByLabelText('左侧篮子 苹果').className).toContain(
'baby-object-runtime__basket--correct',
);
await advanceFeedback();
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('ignores drag input until the item animation finishes', async () => {
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');
}
dragHand(stage, 0);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
await advanceRoundIntro();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers();
});
test('correct placement automatically shows the next gift item', async () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0.99])}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
await advanceFeedback();
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('香蕉'),
).toBeTruthy();
vi.useRealTimers();
});
test('wrong basket keeps the item active after feedback', async () => {
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');
}
await advanceRoundIntro();
dragHand(stage, 2);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
await advanceFeedback();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy();
vi.useRealTimers();
});
test('twenty correct placements completes the level', async () => {
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) {
await advanceRoundIntro();
dragHand(stage, 0);
await advanceFeedback();
}
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,718 @@
import {
ArrowLeft,
Gift,
PartyPopper,
RotateCcw,
SkipForward,
} from 'lucide-react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
BabyObjectMatchDraft,
BabyObjectMatchItemAsset,
BabyObjectMatchVisualAsset,
BabyObjectMatchVisualAssetKind,
} 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_GIFT_APPEAR_DURATION_MS = 620;
const BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS = 640;
const BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS = 620;
const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 1180;
const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05;
const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16;
type BabyObjectMatchRuntimeShellProps = {
draft: BabyObjectMatchDraft;
embedded?: boolean;
enableMocapInput?: boolean;
mocapInput?: UseMocapInputResult | null;
random?: BabyObjectMatchRandom;
onBack?: () => void;
onNextLevel?: () => void;
};
type BasketSide = 'left' | 'right';
type RuntimePhase =
| 'gift-entering'
| 'gift-opening'
| 'item-appearing'
| '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;
type BabyObjectMatchStageStyle = CSSProperties &
Partial<
Record<
| '--baby-object-ui-frame-image'
| '--baby-object-gift-box-image'
| '--baby-object-basket-image'
| '--baby-object-smoke-image',
string
>
>;
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 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 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);
}
function findVisualAsset(
draft: BabyObjectMatchDraft,
kind: BabyObjectMatchVisualAssetKind,
): BabyObjectMatchVisualAsset | null {
return (
draft.visualPackage?.assets.find((asset) => asset.assetKind === kind) ??
null
);
}
function buildCssImageValue(src: string) {
return `url("${src.replace(/"/gu, '\\"')}")`;
}
export function BabyObjectMatchRuntimeShell({
draft,
embedded = false,
enableMocapInput = true,
mocapInput = null,
random,
onBack,
onNextLevel,
}: BabyObjectMatchRuntimeShellProps) {
const randomRef = useRef<BabyObjectMatchRandom>(
random ?? (() => Math.random()),
);
const introTimerRef = useRef<number | null>(null);
const feedbackTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const handledMocapPacketKeyRef = useRef<string | null>(null);
const latestMocapPacketKeyRef = useRef<string | null>(null);
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
left: [],
right: [],
});
const [phase, setPhase] = useState<RuntimePhase>('gift-entering');
const [successCount, setSuccessCount] = useState(0);
const [round, setRound] = useState<RuntimeRound | null>(() =>
buildRuntimeRound(draft, randomRef.current),
);
const [feedbackText, setFeedbackText] = useState<string | null>(null);
const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null);
const liveMocapInput = useMocapInput({
enabled: enableMocapInput && !mocapInput,
});
const resolvedMocapInput = mocapInput ?? liveMocapInput;
const backgroundAsset = findVisualAsset(draft, 'background');
const uiFrameAsset = findVisualAsset(draft, 'ui-frame');
const giftBoxAsset = findVisualAsset(draft, 'gift-box');
const basketAsset = findVisualAsset(draft, 'basket');
const smokeAsset = findVisualAsset(draft, 'smoke-puff');
const stageStyle: BabyObjectMatchStageStyle = {
...(uiFrameAsset
? {
'--baby-object-ui-frame-image': buildCssImageValue(
uiFrameAsset.imageSrc,
),
}
: {}),
...(giftBoxAsset
? {
'--baby-object-gift-box-image': buildCssImageValue(
giftBoxAsset.imageSrc,
),
}
: {}),
...(basketAsset
? {
'--baby-object-basket-image': buildCssImageValue(
basketAsset.imageSrc,
),
}
: {}),
...(smokeAsset
? {
'--baby-object-smoke-image': buildCssImageValue(
smokeAsset.imageSrc,
),
}
: {}),
};
const progressText = `${successCount}/${BABY_OBJECT_MATCH_SUCCESS_TARGET}`;
const isComplete = phase === 'complete';
const currentItem = round?.item ?? null;
const isJudgementOpen = phase === 'active';
const shouldShowCurrentItem =
currentItem &&
(phase === 'item-appearing' ||
phase === 'active' ||
phase === 'correct' ||
phase === 'wrong');
const shouldShowGift = phase === 'gift-entering' || phase === 'gift-opening';
const shouldShowSmoke =
phase === 'gift-opening' || phase === 'item-appearing';
useEffect(() => {
randomRef.current = random ?? (() => Math.random());
}, [random]);
useEffect(() => {
latestMocapPacketKeyRef.current = resolvedMocapInput.latestCommand
? buildMocapPacketKey(
resolvedMocapInput.latestCommand,
resolvedMocapInput.rawPacketPreview,
)
: null;
}, [resolvedMocapInput.latestCommand, resolvedMocapInput.rawPacketPreview]);
const clearFeedbackTimer = useCallback(() => {
if (feedbackTimerRef.current !== null) {
window.clearTimeout(feedbackTimerRef.current);
feedbackTimerRef.current = null;
}
}, []);
const clearIntroTimer = useCallback(() => {
if (introTimerRef.current !== null) {
window.clearTimeout(introTimerRef.current);
introTimerRef.current = null;
}
}, []);
const resetInputPaths = useCallback(() => {
dragStateRef.current = null;
handledMocapPacketKeyRef.current = null;
mocapHandPathsRef.current = { left: [], right: [] };
}, []);
useEffect(() => {
clearIntroTimer();
if (phase === 'gift-entering') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('gift-opening');
}, BABY_OBJECT_MATCH_GIFT_APPEAR_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'gift-opening') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('item-appearing');
}, BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'item-appearing') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
resetInputPaths();
handledMocapPacketKeyRef.current = latestMocapPacketKeyRef.current;
setPhase('active');
}, BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS);
return clearIntroTimer;
}
return clearIntroTimer;
}, [clearIntroTimer, phase, resetInputPaths]);
const resetRuntime = useCallback(() => {
clearIntroTimer();
clearFeedbackTimer();
resetInputPaths();
setSuccessCount(0);
setRound(buildRuntimeRound(draft, randomRef.current));
setFeedbackText(null);
setLastTargetSide(null);
setPhase('gift-entering');
}, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]);
const finishFeedback = useCallback(
(nextSuccessCount: number, wasCorrect: boolean) => {
clearIntroTimer();
clearFeedbackTimer();
feedbackTimerRef.current = window.setTimeout(() => {
feedbackTimerRef.current = null;
if (wasCorrect) {
if (nextSuccessCount >= BABY_OBJECT_MATCH_SUCCESS_TARGET) {
setFeedbackText('恭喜你!小朋友!');
setRound(null);
setPhase('complete');
return;
}
setRound(buildRuntimeRound(draft, randomRef.current));
setFeedbackText(null);
setLastTargetSide(null);
resetInputPaths();
setPhase('gift-entering');
return;
}
setFeedbackText(null);
setLastTargetSide(null);
resetInputPaths();
setPhase('active');
}, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS);
},
[clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths],
);
const sendItemToBasket = useCallback(
(side: BasketSide) => {
if (!isJudgementOpen || !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, isJudgementOpen, round, successCount],
);
useEffect(
() => () => {
clearIntroTimer();
clearFeedbackTimer();
},
[clearFeedbackTimer, clearIntroTimer],
);
useEffect(() => {
const command = resolvedMocapInput.latestCommand;
if (!command || isComplete) {
return;
}
const packetKey = buildMocapPacketKey(
command,
resolvedMocapInput.rawPacketPreview,
);
if (handledMocapPacketKeyRef.current === packetKey) {
return;
}
handledMocapPacketKeyRef.current = packetKey;
if (!isJudgementOpen) {
resetInputPaths();
return;
}
const nextPaths = resolveMocapHandPaths(command, mocapHandPathsRef.current);
mocapHandPathsRef.current = nextPaths;
const targetSide = resolveMocapHorizontalMoveSide(nextPaths);
if (targetSide) {
sendItemToBasket(targetSide);
resetInputPaths();
}
}, [
isComplete,
isJudgementOpen,
resetInputPaths,
resolvedMocapInput.latestCommand,
resolvedMocapInput.rawPacketPreview,
sendItemToBasket,
]);
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 (!isJudgementOpen) {
return;
}
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 (!isJudgementOpen) {
dragStateRef.current = null;
return;
}
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${
backgroundAsset ? ' baby-object-runtime__stage--skinned' : ''
}`}
style={stageStyle}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onContextMenu={(event) => event.preventDefault()}
>
{backgroundAsset ? (
<ResolvedAssetImage
src={backgroundAsset.imageSrc}
alt=""
className="baby-object-runtime__background-image"
data-testid="baby-object-background-image"
aria-hidden="true"
/>
) : null}
{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>
{shouldShowGift ? (
<div
className={`baby-object-runtime__gift${
giftBoxAsset ? ' baby-object-runtime__gift--skinned' : ''
}${
phase === 'gift-entering'
? ' baby-object-runtime__gift--entering'
: ''
}${
phase === 'gift-opening'
? ' baby-object-runtime__gift--opening baby-object-runtime__gift--open'
: ''
}`}
aria-label="礼物盒"
>
{giftBoxAsset ? (
<ResolvedAssetImage
src={giftBoxAsset.imageSrc}
alt="礼物盒"
className="baby-object-runtime__gift-image"
/>
) : (
<Gift className="baby-object-runtime__gift-icon" />
)}
</div>
) : null}
{shouldShowSmoke ? (
<div
className={`baby-object-runtime__smoke${
smokeAsset ? ' baby-object-runtime__smoke--skinned' : ''
}${
phase === 'item-appearing'
? ' baby-object-runtime__smoke--releasing'
: ''
}`}
data-testid="baby-object-smoke-effect"
aria-hidden="true"
/>
) : null}
<div
className={`baby-object-runtime__item${
shouldShowCurrentItem ? ' baby-object-runtime__item--visible' : ''
}${
phase === 'item-appearing'
? ' baby-object-runtime__item--appearing'
: ''
}${
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"
>
{shouldShowCurrentItem ? (
<>
<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}${
phase === 'correct' && lastTargetSide === side
? ' baby-object-runtime__basket--correct'
: ''
}`}
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${
basketAsset
? ' baby-object-runtime__basket-body--skinned'
: ''
}`}
>
{basketAsset ? (
<ResolvedAssetImage
src={basketAsset.imageSrc}
alt=""
className="baby-object-runtime__basket-shell-image"
/>
) : null}
</div>
</div>
);
})}
</div>
</section>
</main>
);
}
export default BabyObjectMatchRuntimeShell;

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from 'vitest';
import {
appendPointToStroke,
BABY_LOVE_DRAWING_BUTTON_HOVER_MS,
BABY_LOVE_DRAWING_COLOR_HOVER_MS,
createBabyDrawingStroke,
hasHoverCompleted,
isPointInsideBounds,
resolveHoverProgress,
toCanvasPoint,
} from './babyLoveDrawingModel';
describe('babyLoveDrawingModel', () => {
test('completes color hover after 1.5 seconds', () => {
const target = { kind: 'color' as const, id: 'red' };
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_COLOR_HOVER_MS - 1,
),
).toBe(false);
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_COLOR_HOVER_MS,
),
).toBe(true);
});
test('completes button hover after 2 seconds', () => {
const target = { kind: 'button' as const, id: 'finish' };
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_BUTTON_HOVER_MS - 1,
),
).toBe(false);
expect(
hasHoverCompleted(
target,
1000,
1000 + BABY_LOVE_DRAWING_BUTTON_HOVER_MS,
),
).toBe(true);
});
test('clamps hover progress and canvas point into unit bounds', () => {
const bounds = {
left: 0.25,
top: 0.2,
width: 0.5,
height: 0.4,
};
expect(resolveHoverProgress(null, null, 1000)).toBe(0);
expect(resolveHoverProgress({ kind: 'color', id: 'red' }, 0, 999999)).toBe(
1,
);
expect(isPointInsideBounds({ x: 0.4, y: 0.3 }, bounds)).toBe(true);
expect(isPointInsideBounds({ x: 0.1, y: 0.3 }, bounds)).toBe(false);
expect(toCanvasPoint({ x: 0.5, y: 0.4 }, bounds)).toMatchObject({
x: 0.5,
y: 0.5,
});
expect(toCanvasPoint({ x: 0.9, y: 0.9 }, bounds)).toMatchObject({
x: 1,
y: 1,
});
});
test('creates and extends stroke trace without mutating previous stroke', () => {
const stroke = createBabyDrawingStroke('brush', '#ef4444', {
x: 0.1,
y: 0.2,
t: 1,
});
const nextStroke = appendPointToStroke(stroke, {
x: 0.3,
y: 0.4,
t: 2,
});
expect(stroke.points).toHaveLength(1);
expect(nextStroke.points).toHaveLength(2);
expect(nextStroke).toMatchObject({
tool: 'brush',
color: '#ef4444',
});
});
});

View File

@@ -0,0 +1,135 @@
import type {
BabyLoveDrawingPoint,
BabyLoveDrawingStroke,
BabyLoveDrawingTool,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import { BABY_LOVE_DRAWING_RAINBOW_COLORS } from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
export const BABY_LOVE_DRAWING_COLOR_HOVER_MS = 1500;
export const BABY_LOVE_DRAWING_BUTTON_HOVER_MS = 2000;
export const BABY_LOVE_DRAWING_BRUSH_SIZE = 10;
export const BABY_LOVE_DRAWING_ERASER_SIZE = 30;
export type BabyLoveDrawingPhase =
| 'drawing'
| 'finished'
| 'magicPending'
| 'magicReady'
| 'saved';
export type BabyLoveDrawingHoverTarget =
| {
kind: 'color';
id: string;
}
| {
kind: 'button';
id: string;
}
| null;
export type BabyLoveDrawingHandPoint = {
x: number;
y: number;
state: 'open_palm' | 'grab' | 'unknown';
};
export type BabyLoveDrawingBounds = {
left: number;
top: number;
width: number;
height: number;
};
export const BABY_LOVE_DRAWING_DEFAULT_COLOR =
BABY_LOVE_DRAWING_RAINBOW_COLORS[0].value;
export function clampBabyDrawingUnit(value: number) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
}
export function normalizeBabyDrawingPoint(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
): BabyLoveDrawingPoint {
return {
x: clampBabyDrawingUnit(point.x),
y: clampBabyDrawingUnit(point.y),
t: Date.now(),
};
}
export function isPointInsideBounds(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
bounds: BabyLoveDrawingBounds,
) {
return (
point.x >= bounds.left &&
point.x <= bounds.left + bounds.width &&
point.y >= bounds.top &&
point.y <= bounds.top + bounds.height
);
}
export function toCanvasPoint(
point: Pick<BabyLoveDrawingHandPoint, 'x' | 'y'>,
bounds: BabyLoveDrawingBounds,
) {
return {
x: clampBabyDrawingUnit((point.x - bounds.left) / bounds.width),
y: clampBabyDrawingUnit((point.y - bounds.top) / bounds.height),
t: Date.now(),
};
}
export function appendPointToStroke(
stroke: BabyLoveDrawingStroke,
point: BabyLoveDrawingPoint,
): BabyLoveDrawingStroke {
return {
...stroke,
points: [...stroke.points, point],
};
}
export function createBabyDrawingStroke(
tool: BabyLoveDrawingTool,
color: string,
point: BabyLoveDrawingPoint,
): BabyLoveDrawingStroke {
return {
strokeId: `baby-drawing-stroke-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`,
tool,
color,
points: [point],
};
}
export function resolveHoverProgress(
target: BabyLoveDrawingHoverTarget,
startedAtMs: number | null,
nowMs: number,
) {
if (!target || startedAtMs === null) {
return 0;
}
const duration =
target.kind === 'color'
? BABY_LOVE_DRAWING_COLOR_HOVER_MS
: BABY_LOVE_DRAWING_BUTTON_HOVER_MS;
return clampBabyDrawingUnit((nowMs - startedAtMs) / duration);
}
export function hasHoverCompleted(
target: BabyLoveDrawingHoverTarget,
startedAtMs: number | null,
nowMs: number,
) {
return resolveHoverProgress(target, startedAtMs, nowMs) >= 1;
}

View File

@@ -36,10 +36,11 @@ const baseSession: Match3DAgentSessionSnapshot = {
referenceImageSrc: null,
clearCount: 8,
difficulty: 3,
assetStyleId: 'low-poly',
assetStyleLabel: '低多边形',
assetStyleId: 'cel-cartoon',
assetStyleLabel: '赛璐璐卡通',
assetStylePrompt:
'块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
'明亮赛璐璐卡通 2D 游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
generateClickSound: false,
},
draft: null,
messages: [
@@ -71,10 +72,11 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.getByText('3D素材风格')).toBeTruthy();
expect(screen.getByRole('button', { name: '黏土手作' })).toBeTruthy();
expect(screen.getByText('2D素材风格')).toBeTruthy();
expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy();
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.getByText('消耗10泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
expect(screen.queryByText('参考图')).toBeNull();
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
@@ -94,14 +96,16 @@ test('match3d workspace submits derived entry form payload instead of agent chat
referenceImageSrc: null,
clearCount: 16,
difficulty: 6,
assetStyleId: 'clay-toy',
assetStyleLabel: '黏土手作',
assetStylePrompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
assetStyleId: 'flat-icon',
assetStyleLabel: '扁平图标',
assetStylePrompt:
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
generateClickSound: false,
});
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace supports custom 3d asset style prompt', () => {
test('match3d workspace supports custom 2d asset style prompt', () => {
const onCreateFromForm = vi.fn();
render(
@@ -119,7 +123,7 @@ test('match3d workspace supports custom 3d asset style prompt', () => {
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('自定义3D素材风格描述'), {
fireEvent.change(screen.getByLabelText('自定义2D素材风格描述'), {
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
});
fireEvent.click(screen.getByRole('button', { name: '应用' }));
@@ -138,6 +142,63 @@ test('match3d workspace supports custom 3d asset style prompt', () => {
);
});
test('match3d workspace submits strict pixel-retro style prompt', () => {
const onCreateFromForm = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '复古水果铺' },
});
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStyleId: 'pixel-retro',
assetStyleLabel: '像素复古',
assetStylePrompt: expect.stringContaining('64x64'),
}),
);
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
assetStylePrompt: expect.stringContaining('禁止抗锯齿'),
}),
);
});
test('match3d workspace keeps click sound generation disabled from entry form', () => {
const onCreateFromForm = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '海岛甜品' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
themeText: '海岛甜品',
generateClickSound: false,
}),
);
});
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
const onExecuteAction = vi.fn();
@@ -157,14 +218,15 @@ test('match3d workspace falls back to compile action when restored from the lega
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', { name: '低多边形' }).getAttribute(
'aria-pressed',
),
screen
.getByRole('button', { name: '赛璐璐卡通' })
.getAttribute('aria-pressed'),
).toBe('true');
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'match3d_compile_draft',
generateClickSound: false,
});
});

View File

@@ -31,7 +31,7 @@ type Match3DFormState = {
const EMPTY_FORM_STATE: Match3DFormState = {
themeText: '',
difficultyOptionId: 'standard',
assetStyleId: 'clay-toy',
assetStyleId: 'flat-icon',
customAssetStylePrompt: '',
};
@@ -40,7 +40,7 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
{ id: 'easy', label: '轻松', clearCount: 8, difficulty: 2 },
{ id: 'standard', label: '标准', clearCount: 12, difficulty: 4 },
{ id: 'advanced', label: '进阶', clearCount: 16, difficulty: 6 },
{ id: 'hardcore', label: '硬核', clearCount: 20, difficulty: 8 },
{ id: 'hardcore', label: '硬核', clearCount: 21, difficulty: 8 },
] as const;
type Match3DDifficultyOptionId =
@@ -48,40 +48,46 @@ type Match3DDifficultyOptionId =
const MATCH3D_ASSET_STYLE_OPTIONS = [
{
id: 'clay-toy',
label: '黏土手作',
imageSrc: '/match3d-style-references/clay-toy.png',
prompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
id: 'flat-icon',
label: '扁平图标',
imageSrc: '/match3d-style-references/flat-icon.png',
prompt:
'干净扁平的2D游戏道具图标风格正面视角色块清楚边缘硬朗。',
},
{
id: 'low-poly',
label: '低多边形',
imageSrc: '/match3d-style-references/low-poly.png',
prompt: '块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
id: 'cel-cartoon',
label: '赛璐璐卡通',
imageSrc: '/match3d-style-references/cel-cartoon.png',
prompt:
'明亮赛璐璐卡通2D游戏道具风格清晰线稿硬边阴影饱和配色轮廓醒目。',
},
{
id: 'toy-plastic',
label: '玩具塑料',
imageSrc: '/match3d-style-references/toy-plastic.png',
prompt: '亮面、光滑、有柔和高光的玩具塑料 3D 素材风格。',
id: 'pixel-retro',
label: '像素',
imageSrc: '/match3d-style-references/pixel-retro.png',
prompt:
'像素2D游戏道具sprite风格',
},
{
id: 'wood-carved',
label: '木质雕刻',
imageSrc: '/match3d-style-references/wood-carved.png',
prompt: '保留木纹和手工雕刻感的温润木质 3D 素材风格。',
id: 'watercolor',
label: '手绘水彩',
imageSrc: '/match3d-style-references/watercolor.png',
prompt:
'手绘水彩2D道具素材风格',
},
{
id: 'voxel-block',
label: '体素积木',
imageSrc: '/match3d-style-references/voxel-block.png',
prompt: '由小方块构成、边缘清晰、带游戏感的体素积木 3D 素材风格。',
id: 'sticker-outline',
label: '贴纸描边',
imageSrc: '/match3d-style-references/sticker-outline.png',
prompt:
'贴纸描边2D游戏道具素材风格粗白边与深色外轮廓',
},
{
id: 'metal-mecha',
label: '金属机甲',
imageSrc: '/match3d-style-references/metal-mecha.png',
prompt: '带金属拉丝、柔和高光和轻科幻感的金属机甲 3D 素材风格。',
id: 'painterly-icon',
label: '厚涂图标',
imageSrc: '/match3d-style-references/painterly-icon.png',
prompt:
'厚涂2D游戏道具图标风格笔触细腻体积光影明确中心构图保持图标级清晰剪影。',
},
{
id: 'custom',
@@ -149,7 +155,7 @@ function resolveAssetStyleOptionId(
return matchedOption.id;
}
return assetStylePrompt?.trim() ? 'custom' : 'clay-toy';
return assetStylePrompt?.trim() ? 'custom' : 'flat-icon';
}
function resolveInitialFormState(
@@ -164,21 +170,13 @@ function resolveInitialFormState(
initialFormPayload?.seedText?.trim() ||
'';
const clearCount =
initialFormPayload?.clearCount ??
config?.clearCount ??
null;
initialFormPayload?.clearCount ?? config?.clearCount ?? null;
const difficulty =
initialFormPayload?.difficulty ??
config?.difficulty ??
null;
initialFormPayload?.difficulty ?? config?.difficulty ?? null;
const assetStyleId =
initialFormPayload?.assetStyleId ??
config?.assetStyleId ??
null;
initialFormPayload?.assetStyleId ?? config?.assetStyleId ?? null;
const assetStylePrompt =
initialFormPayload?.assetStylePrompt ??
config?.assetStylePrompt ??
'';
initialFormPayload?.assetStylePrompt ?? config?.assetStylePrompt ?? '';
return {
...EMPTY_FORM_STATE,
@@ -255,6 +253,7 @@ export function Match3DAgentWorkspace({
assetStyleId: formState.assetStyleId,
assetStyleLabel,
assetStylePrompt,
generateClickSound: false,
}),
[
assetStyleLabel,
@@ -290,7 +289,10 @@ export function Match3DAgentWorkspace({
}
if (session) {
onExecuteAction({ action: 'match3d_compile_draft' });
onExecuteAction({
action: 'match3d_compile_draft',
generateClickSound: false,
});
}
};
@@ -350,11 +352,11 @@ export function Match3DAgentWorkspace({
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
<div className="min-h-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/52 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
3D素材风格
2D素材风格
</div>
<div
className="flex snap-x gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
aria-label="3D素材风格"
aria-label="2D素材风格"
>
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
const selected = formState.assetStyleId === option.id;
@@ -424,8 +426,7 @@ export function Match3DAgentWorkspace({
</div>
<div className="grid grid-cols-4 gap-1.5 sm:gap-2 lg:grid-cols-2">
{MATCH3D_DIFFICULTY_OPTIONS.map((option) => {
const selected =
formState.difficultyOptionId === option.id;
const selected = formState.difficultyOptionId === option.id;
return (
<button
key={option.id}
@@ -479,7 +480,7 @@ export function Match3DAgentWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
10
</span>
</span>
</button>
@@ -511,10 +512,12 @@ export function Match3DAgentWorkspace({
</div>
<textarea
value={draftCustomStylePrompt}
onChange={(event) => setDraftCustomStylePrompt(event.target.value)}
onChange={(event) =>
setDraftCustomStylePrompt(event.target.value)
}
rows={4}
className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="自定义3D素材风格描述"
aria-label="自定义2D素材风格描述"
/>
<div className="mt-5 grid grid-cols-2 gap-3">
<button

View File

@@ -1,7 +1,8 @@
import { Box, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { readAssetBytes } from '../../services/assetReadUrlService';
import { isDebugMode } from '../../config/debugMode';
import { readMatch3DGeneratedModelBytes } from '../../services/match3dGeneratedModelCache';
type ThreeModule = typeof import('three');
type GltfPayload = import('three/examples/jsm/loaders/GLTFLoader.js').GLTF;
@@ -55,6 +56,22 @@ function centerAndScaleModel(three: ThreeModule, model: import('three').Object3D
model.position.sub(center);
}
function shouldLogMatch3DModelPreviewDiagnostics() {
return isDebugMode() && import.meta.env.MODE !== 'test';
}
function warnMatch3DModelPreviewLoadFailure(source: string, error: unknown) {
if (!shouldLogMatch3DModelPreviewDiagnostics()) {
return;
}
const message =
error instanceof Error ? error.message : String(error || 'unknown error');
console.warn('[match3d] model preview load failed', {
source,
message,
});
}
export function Match3DModelPreview({
modelSrc,
className = '',
@@ -87,8 +104,6 @@ export function Match3DModelPreview({
}
let cancelled = false;
let objectUrl: string | null = null;
const teardown = () => {
const runtime = runtimeRef.current;
if (runtime?.animationId != null) {
@@ -98,10 +113,6 @@ export function Match3DModelPreview({
runtime?.renderer.dispose();
runtime?.renderer.domElement.remove();
runtimeRef.current = null;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
canvasHost.replaceChildren();
};
@@ -113,25 +124,19 @@ export function Match3DModelPreview({
setStatus('loading');
try {
const [three, loaderModule, response] = await Promise.all([
const [three, loaderModule, bytes] = await Promise.all([
import('three'),
import('three/examples/jsm/loaders/GLTFLoader.js'),
readAssetBytes(source, { expireSeconds: 600 }),
readMatch3DGeneratedModelBytes(source, { expireSeconds: 600 }),
]);
if (cancelled || !containerRef.current) {
return;
}
const bytes = await response.arrayBuffer();
if (bytes.byteLength === 0) {
throw new Error('empty model');
}
const blob = new Blob([bytes], {
type: 'model/gltf-binary',
});
objectUrl = URL.createObjectURL(blob);
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
@@ -167,16 +172,7 @@ export function Match3DModelPreview({
scene.add(modelRoot);
const loader = new loaderModule.GLTFLoader();
const gltf = await new Promise<GltfPayload>(
(resolve, reject) => {
loader.load(
objectUrl as string,
(loaded: GltfPayload) => resolve(loaded),
undefined,
(error) => reject(error),
);
},
);
const gltf = (await loader.parseAsync(bytes, '')) as GltfPayload;
if (cancelled) {
const cancelledModel = gltf.scene ?? gltf.scenes[0];
if (cancelledModel) {
@@ -277,8 +273,9 @@ export function Match3DModelPreview({
};
setStatus('ready');
} catch {
} catch (caughtError) {
if (!cancelled) {
warnMatch3DModelPreviewLoadFailure(source, caughtError);
setStatus('fallback');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,10 @@ import type {
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import { isDebugMode } from '../../config/debugMode';
import { readAssetBytes } from '../../services/assetReadUrlService';
import {
readMatch3DGeneratedModelBytes,
resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import {
isItemState,
resolveRenderableItemFrame,
@@ -111,6 +114,8 @@ type PhysicsRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, PhysicsEntry>;
failedGeneratedModelTypeIds: Set<string>;
generatedModelByType: Map<string, Match3DGeneratedItemAsset>;
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
pendingSpawns: Map<string, PendingPhysicsSpawn>;
raycaster: import('three').Raycaster;
@@ -170,7 +175,7 @@ export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape>
function normalizeMatch3DGeneratedModelSource(
asset: Match3DGeneratedItemAsset,
) {
return asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
return resolveMatch3DGeneratedModelAssetSource(asset);
}
function compareMatch3DGeneratedTypeId(left: string, right: string) {
@@ -213,6 +218,7 @@ export function buildMatch3DGeneratedAssetTypeMap(
...resolved.asset,
modelSrc: resolved.source,
});
debugMatch3DGeneratedModelMapped(itemTypeId, resolved.source);
});
return assetMap;
@@ -237,12 +243,16 @@ function resolveGeneratedModelSourceForItemType(
return asset ? normalizeMatch3DGeneratedModelSource(asset) : '';
}
function shouldLogMatch3DGeneratedModelDiagnostics() {
return isDebugMode() && import.meta.env.MODE !== 'test';
}
function warnMatch3DGeneratedModelLoadFailure(
itemTypeId: string,
source: string,
error: unknown,
) {
if (!isDebugMode()) {
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
return;
}
const message =
@@ -254,6 +264,32 @@ function warnMatch3DGeneratedModelLoadFailure(
});
}
function debugMatch3DGeneratedModelLoaded(
itemTypeId: string,
source: string,
) {
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
return;
}
console.debug('[match3d] generated model loaded', {
itemTypeId,
source,
});
}
function debugMatch3DGeneratedModelMapped(
itemTypeId: string,
source: string,
) {
if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
return;
}
console.debug('[match3d] generated model mapped', {
itemTypeId,
source,
});
}
async function loadMatch3DGeneratedModelTemplate(
templateMap: Match3DGeneratedModelTemplateMap,
three: ThreeModule,
@@ -265,11 +301,10 @@ async function loadMatch3DGeneratedModelTemplate(
if (cached?.source === source) {
return cached.scene;
}
const response = await readAssetBytes(source, {
const bytes = await readMatch3DGeneratedModelBytes(source, {
expireSeconds: 300,
signal,
});
const bytes = await response.arrayBuffer();
if (bytes.byteLength === 0) {
throw new Error('抓大鹅 3D 模型内容为空');
}
@@ -297,6 +332,7 @@ async function loadMatch3DGeneratedModelTemplate(
scene,
source,
});
debugMatch3DGeneratedModelLoaded(itemTypeId, source);
return scene;
}
@@ -327,7 +363,6 @@ function createGeneratedModelMesh(
}
const position = toWorldPosition(item);
const model = cloneThreeObjectWithMaterials(template);
markObjectForItem(model, item.itemInstanceId);
const bounds = new three.Box3().setFromObject(model);
const size = bounds.getSize(new three.Vector3());
const dimension = Math.max(size.x, size.y, size.z, 0.001);
@@ -341,10 +376,13 @@ function createGeneratedModelMesh(
model.position.sub(center);
const bottomY = scaledBounds.min.y - center.y;
model.position.y -= bottomY;
const pivot = new three.Group();
pivot.add(model);
markObjectForItem(pivot, item.itemInstanceId);
return {
lockReadableTop: false,
mesh: model,
mesh: pivot,
radius: position.radius,
shape: 'brick' as Match3DGeometryShape,
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
@@ -1157,6 +1195,22 @@ function createItemMesh(
);
}
function shouldWaitForGeneratedModelTemplate(
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
templateMap: Match3DGeneratedModelTemplateMap,
failedTypeIds: ReadonlySet<string>,
itemTypeId: string,
) {
const source = resolveGeneratedModelSourceForItemType(
generatedModelByType,
itemTypeId,
);
// 中文注释:坏 GLB 或过期链接不能让整局空等模板;失败类型应立即走默认几何降级。
return Boolean(
source && !templateMap.has(itemTypeId) && !failedTypeIds.has(itemTypeId),
);
}
export function buildMatch3DPhysicsEntrySignature(
runId: string,
item: Match3DItemSnapshot,
@@ -1192,6 +1246,16 @@ function createPhysicsEntryFromPendingSpawn(
now: number,
templateMap?: Match3DGeneratedModelTemplateMap | null,
) {
if (
shouldWaitForGeneratedModelTemplate(
runtime.generatedModelByType,
runtime.generatedModelTemplates,
runtime.failedGeneratedModelTypeIds,
pendingSpawn.item.itemTypeId,
)
) {
return;
}
const visual = createItemMesh(runtime.three, pendingSpawn.item, templateMap);
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
@@ -1273,7 +1337,17 @@ function createPhysicsEntryFromPendingSpawn(
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
const readySpawns = [...runtime.pendingSpawns.entries()]
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
.filter(([, pendingSpawn]) => {
if (now < pendingSpawn.spawnAtMs) {
return false;
}
return !shouldWaitForGeneratedModelTemplate(
runtime.generatedModelByType,
runtime.generatedModelTemplates,
runtime.failedGeneratedModelTypeIds,
pendingSpawn.item.itemTypeId,
);
})
.sort((left, right) => {
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
return left[1].spawnAtMs - right[1].spawnAtMs;
@@ -1317,6 +1391,7 @@ type TrayPreviewRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, ThreeObject3D>;
failedGeneratedModelTypeIds: Set<string>;
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
renderer: ThreeRenderer;
scene: ThreeScene;
@@ -1620,6 +1695,8 @@ export function Match3DTrayPreviewBoard({
animationId: null,
camera,
entries: runtimeRef.current?.entries ?? new Map(),
failedGeneratedModelTypeIds:
runtimeRef.current?.failedGeneratedModelTypeIds ?? new Set(),
generatedModelTemplates:
runtimeRef.current?.generatedModelTemplates ?? new Map(),
renderer,
@@ -1645,6 +1722,7 @@ export function Match3DTrayPreviewBoard({
animationId: window.requestAnimationFrame(animate),
camera,
entries: new Map(),
failedGeneratedModelTypeIds: new Set(),
generatedModelTemplates: new Map(),
renderer,
scene,
@@ -1687,6 +1765,7 @@ export function Match3DTrayPreviewBoard({
staleItemTypeIds.delete(itemTypeId);
const hadFreshTemplate =
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
void loadMatch3DGeneratedModelTemplate(
runtime.generatedModelTemplates,
runtime.three,
@@ -1721,6 +1800,8 @@ export function Match3DTrayPreviewBoard({
caughtError,
);
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
setTrayModelRevision((current) => current + 1);
});
});
staleItemTypeIds.forEach((itemTypeId) => {
@@ -1729,6 +1810,7 @@ export function Match3DTrayPreviewBoard({
disposeThreeObject(template.scene);
}
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
});
return () => {
@@ -1785,7 +1867,9 @@ export function Match3DTrayPreviewBoard({
const preview = createItemMesh(
runtime.three,
item,
runtime.generatedModelTemplates,
runtime.failedGeneratedModelTypeIds.has(item.itemTypeId)
? null
: runtime.generatedModelTemplates,
);
const model = preview.mesh;
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
@@ -2050,6 +2134,8 @@ export function Match3DPhysicsBoard({
animationId: null,
camera,
entries: new Map(),
failedGeneratedModelTypeIds: new Set(),
generatedModelByType,
generatedModelTemplates: new Map(),
pendingSpawns: new Map(),
raycaster: new three.Raycaster(),
@@ -2157,6 +2243,7 @@ export function Match3DPhysicsBoard({
if (!runtime) {
return undefined;
}
runtime.generatedModelByType = generatedModelByType;
const abortController = new AbortController();
const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
generatedModelByType.forEach((asset, itemTypeId) => {
@@ -2167,6 +2254,7 @@ export function Match3DPhysicsBoard({
}
const hadFreshTemplate =
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
void loadMatch3DGeneratedModelTemplate(
runtime.generatedModelTemplates,
runtime.three,
@@ -2201,6 +2289,8 @@ export function Match3DPhysicsBoard({
caughtError,
);
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.add(itemTypeId);
setGeneratedModelRevision((current) => current + 1);
});
});
staleItemTypeIds.forEach((itemTypeId) => {
@@ -2209,6 +2299,7 @@ export function Match3DPhysicsBoard({
disposeThreeObject(template.scene);
}
runtime.generatedModelTemplates.delete(itemTypeId);
runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
});
return () => {

View File

@@ -13,6 +13,7 @@ import type {
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
confirmLocalMatch3DClick,
resolveLocalMatch3DItemTypeCount,
startLocalMatch3DRun,
} from '../../services/match3d-runtime';
import {
@@ -77,9 +78,13 @@ afterEach(() => {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__;
vi.restoreAllMocks();
});
function renderRuntime(run: Match3DRunSnapshot) {
function renderRuntime(
run: Match3DRunSnapshot,
generatedItemAssets: Match3DGeneratedItemAsset[] = [],
) {
let currentRun = run;
let authorityRun = run;
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
@@ -92,6 +97,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
rerender(
<Match3DRuntimeShell
run={currentRun}
generatedItemAssets={generatedItemAssets}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
@@ -102,6 +108,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
const { rerender } = render(
<Match3DRuntimeShell
run={currentRun}
generatedItemAssets={generatedItemAssets}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={onOptimisticRunChange}
@@ -122,7 +129,7 @@ test('展示圆形空间和 7 格备选栏', () => {
});
test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => {
const run = startLocalMatch3DRun(25);
const run = startLocalMatch3DRun(21);
const firstItemByType = [...new Map(
run.items.map((item) => [item.itemTypeId, item]),
).values()];
@@ -159,12 +166,7 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
});
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
(
globalThis as typeof globalThis & {
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
}
).__MATCH3D_KEEP_3D_TEST_RENDER__ = true;
test('运行态按物品实例稳定使用生成 2D 视角素材', () => {
const run = startLocalMatch3DRun(1);
const selectedItem = run.items[0]!;
const nextRun: Match3DRunSnapshot = {
@@ -187,13 +189,31 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
itemTypeId: selectedItem.itemTypeId,
visualKey: selectedItem.visualKey,
}
: slot,
: slot,
),
};
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${viewIndex}`,
viewIndex,
imageSrc: `/match3d/strawberry-view-${viewIndex}.png`,
imageObjectKey: null,
})),
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
];
renderRuntime(nextRun);
renderRuntime(nextRun, generatedItemAssets);
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
const trayImage = screen.getByTestId('match3d-tray-image') as HTMLImageElement;
expect(trayImage.src).toContain('/match3d/strawberry-view-');
});
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
@@ -283,19 +303,266 @@ test('生成模型按运行态类型编号映射,并兼容仅 modelObjectKey
);
});
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
test('运行态会先换签 generated 图片素材再渲染局内物品', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
viewId: `view-${viewIndex}`,
viewIndex,
imageSrc: null,
imageObjectKey:
`generated-match3d-assets/session/profile/items/match3d-item-1/views/view-${viewIndex}.png`,
})),
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
];
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/match3d-view.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
);
render(
<Match3DRuntimeShell
run={run}
generatedItemAssets={generatedItemAssets}
onBack={vi.fn()}
onRestart={vi.fn()}
onOptimisticRunChange={vi.fn()}
onClickItem={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getAllByTestId('match3d-token-image').length).toBeGreaterThan(0);
});
expect(screen.getAllByTestId('match3d-token-image')[0]!.getAttribute('src')).toBe(
'https://oss.example.com/match3d-view.png',
);
});
test('运行态按 generated itemId 编号映射到后端 match3d-type 类型', async () => {
const baseRun = startLocalMatch3DRun(3);
const baseTypeIds = [...new Set(baseRun.items.map((item) => item.itemTypeId))];
const run: Match3DRunSnapshot = {
...baseRun,
items: baseRun.items.map((item) =>
item.itemTypeId === baseTypeIds[0]
? {...item, itemTypeId: 'match3d-type-01'}
: item.itemTypeId === baseTypeIds[1]
? {...item, itemTypeId: 'match3d-type-02'}
: item,
),
};
const typeOneItem = run.items.find(
(item) => item.itemTypeId === 'match3d-type-01',
);
expect(typeOneItem).toBeTruthy();
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '樱桃',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/cherry-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: null,
imageObjectKey: null,
imageViews: [
{
viewId: 'view-01',
viewIndex: 1,
imageSrc: '/match3d/apple-view-01.png',
imageObjectKey: null,
},
],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
];
renderRuntime(run, generatedItemAssets);
const token = screen.getByTestId(
`match3d-item-${typeOneItem!.itemInstanceId}`,
);
await waitFor(() => {
expect(token.querySelector('img')?.getAttribute('src')).toContain(
'/match3d/cherry-view-01.png',
);
});
});
test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: null,
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundAsset: {
prompt: '果园纯背景',
imageSrc: null,
imageObjectKey: null,
containerPrompt: '果园浅盘容器',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/task/container.png',
status: 'image_ready',
error: null,
},
},
];
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/match3d-container.png',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
),
);
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('https://oss.example.com/match3d-container.png');
});
});
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
const run = startLocalMatch3DRun(3);
const playSpy = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
const url = String(input);
const signedUrl = url.includes('legacyPublicPath')
? 'https://oss.example.com/match3d-music.mp3'
: 'https://oss.example.com/match3d-view.png';
return Promise.resolve(
new Response(
JSON.stringify({
read: {
signedUrl,
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
);
});
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: '/match3d/apple.png',
imageObjectKey: null,
imageViews: [],
status: 'image_ready',
modelSrc: null,
modelObjectKey: null,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
];
renderRuntime(run, generatedItemAssets);
await waitFor(() => {
expect(screen.getByLabelText('抓大鹅背景音乐').getAttribute('src')).toBe(
'https://oss.example.com/match3d-music.mp3',
);
});
await waitFor(() => expect(playSpy).toHaveBeenCalled());
});
test('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
const smallRun = startLocalMatch3DRun(12);
const largeRun = startLocalMatch3DRun(100);
const hardRun = startLocalMatch3DRun(20);
const countTypes = (run: Match3DRunSnapshot) =>
new Set(run.items.map((item) => item.itemTypeId)).size;
expect(countTypes(smallRun)).toBe(12);
expect(countTypes(largeRun)).toBe(25);
expect(largeRun.items).toHaveLength(300);
expect(resolveLocalMatch3DItemTypeCount(8)).toBe(3);
expect(resolveLocalMatch3DItemTypeCount(12)).toBe(9);
expect(resolveLocalMatch3DItemTypeCount(16)).toBe(15);
expect(resolveLocalMatch3DItemTypeCount(20)).toBe(21);
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(21);
expect(countTypes(smallRun)).toBe(9);
expect(countTypes(hardRun)).toBe(21);
expect(hardRun.clearCount).toBe(21);
expect(hardRun.items).toHaveLength(63);
});
test('25 次以内生成不重复积木视觉签名', () => {
const run = startLocalMatch3DRun(25);
test('硬核档位生成不重复积木视觉签名', () => {
const run = startLocalMatch3DRun(21);
const firstItemByType = new Map(
run.items.map((item) => [item.itemTypeId, item]),
);
@@ -311,14 +578,14 @@ test('25 次以内生成不重复积木视觉签名', () => {
),
);
expect(firstItemByType.size).toBe(25);
expect(visualKeys.size).toBe(25);
expect(signatures.size).toBe(25);
expect(firstItemByType.size).toBe(21);
expect(visualKeys.size).toBe(21);
expect(signatures.size).toBe(21);
});
test('积木池覆盖参考图里的特殊件', () => {
const shapes = new Set(
startLocalMatch3DRun(25).items.map((item) =>
startLocalMatch3DRun(21).items.map((item) =>
resolveGeometryAsset(item.visualKey).shape,
),
);
@@ -342,8 +609,8 @@ test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', as
}
});
test('15 次消除时每种视觉模型只对应一次消除目标', () => {
const run = startLocalMatch3DRun(15);
test('进阶档位保持 15 种视觉模型并按三消组复用', () => {
const run = startLocalMatch3DRun(16);
const countByVisualKey = new Map<string, number>();
const typeByVisualKey = new Map<string, Set<string>>();
@@ -357,23 +624,26 @@ test('15 次消除时每种视觉模型只对应一次消除目标', () => {
}
expect(countByVisualKey.size).toBe(15);
expect([...countByVisualKey.values()]).toEqual(Array(15).fill(3));
expect([...countByVisualKey.values()].sort((left, right) => left - right)).toEqual([
...Array(14).fill(3),
6,
]);
expect(
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
).toBe(true);
});
test('25 次以内的随机抽取不会刷新重复物品', () => {
for (const clearCount of [1, 12, 15, 24, 25]) {
test('随机抽取不会刷新重复物品', () => {
for (const clearCount of [1, 8, 12, 16, 21]) {
const run = startLocalMatch3DRun(clearCount);
const visualKeys = new Set(run.items.map((item) => item.visualKey));
expect(visualKeys.size).toBe(clearCount);
expect(visualKeys.size).toBe(resolveLocalMatch3DItemTypeCount(clearCount));
}
});
test('25 类型局面按五档体积比例生成尺寸', () => {
const run = startLocalMatch3DRun(25);
test('硬核档位按五档体积比例生成尺寸', () => {
const run = startLocalMatch3DRun(21);
const radiusByVisualKey = new Map<string, number>();
for (const item of run.items) {
radiusByVisualKey.set(item.visualKey, item.radius);
@@ -400,15 +670,15 @@ test('25 类型局面按五档体积比例生成尺寸', () => {
tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1);
}
expect(tierCounts.get('XL')).toBe(5);
expect(tierCounts.get('L')).toBe(8);
expect(tierCounts.get('M')).toBe(7);
expect(tierCounts.get('XS')).toBe(4);
expect(tierCounts.get('XL')).toBe(4);
expect(tierCounts.get('L')).toBe(7);
expect(tierCounts.get('M')).toBe(6);
expect(tierCounts.get('XS')).toBe(3);
expect(tierCounts.get('S')).toBe(1);
});
test('同一视觉模型在复用时保持唯一尺寸', () => {
const run = startLocalMatch3DRun(30);
const run = startLocalMatch3DRun(21);
const radiiByVisualKey = new Map<string, Set<number>>();
for (const item of run.items) {
@@ -417,13 +687,13 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
radiiByVisualKey.set(item.visualKey, radii);
}
expect(radiiByVisualKey.size).toBe(25);
expect(radiiByVisualKey.size).toBe(21);
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
});
test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => {
const three = await import('three');
const run = startLocalMatch3DRun(25);
const run = startLocalMatch3DRun(21);
const firstItemByType = [...new Map(
run.items.map((item) => [item.itemTypeId, item]),
).values()];

View File

@@ -24,9 +24,21 @@ import type {
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import {
Match3DPhysicsBoard,
Match3DTrayPreviewBoard,
} from './Match3DPhysicsBoard';
isGeneratedLegacyPath,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import {
getMatch3DGeneratedImageViewSources,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
playRuntimeCountdownSound,
playRuntimeLevelClearSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useAuthUi } from '../auth/AuthUiContext';
import {
isItemState,
isRunState,
@@ -36,11 +48,17 @@ import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import {
MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS,
MATCH3D_RUNTIME_GLASS_TIMER_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_CLASS,
MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS,
} from './match3dRuntimeUiStyles';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
backgroundImageSrc?: string | null;
isBusy?: boolean;
error?: string | null;
embedded?: boolean;
@@ -85,7 +103,6 @@ function resolveTrayPreviewItem(
};
}
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
function formatTimer(value: number) {
@@ -133,6 +150,122 @@ function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) {
.sort((left, right) => right.layer - left.layer)[0];
}
function compareMatch3DGeneratedTypeId(left: string, right: string) {
const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) {
return leftIndex - rightIndex;
}
return left.localeCompare(right);
}
function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
return [...new Set(run.items.map((item) => item.itemTypeId.trim()))]
.filter(Boolean)
.sort(compareMatch3DGeneratedTypeId);
}
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
const parsed = Number.parseInt(value?.match(/(\d+)$/u)?.[1] ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
}
function buildMatch3DImageSourcesByType(
run: Match3DRunSnapshot | null,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
if (!run) {
return new Map<string, string[]>();
}
const typeIds = resolveMatch3DGeneratedTypeIds(run);
const readyAssets = generatedItemAssets.flatMap((asset, fallbackIndex) => {
const sources = getMatch3DGeneratedImageViewSources(asset);
return sources.length > 0
? [
{
fallbackIndex,
itemIndex: resolveMatch3DGeneratedItemIndex(asset.itemId),
sources,
},
]
: [];
});
return new Map(
typeIds.flatMap((typeId, index) => {
const directIndex = resolveMatch3DGeneratedItemIndex(typeId);
const asset =
readyAssets.find(
(entry) => directIndex !== null && entry.itemIndex === directIndex,
) ??
readyAssets.find((entry) => entry.fallbackIndex === index);
return asset ? [[typeId, asset.sources] as const] : [];
}),
);
}
function buildMatch3DImageSourceSignature(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
return [...imageSourcesByType.entries()]
.map(([typeId, sources]) => `${typeId}:${sources.join(',')}`)
.join('|');
}
function resolveMatch3DImageReadUrlCacheKey(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
return [
...new Set(
[...imageSourcesByType.values()]
.flatMap((sources) => sources)
.map((source) => source.trim())
.filter(Boolean),
),
]
.sort()
.join('|');
}
function buildResolvedMatch3DImageSourcesByType(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
resolvedImageSources: ReadonlyMap<string, string>,
) {
return new Map(
[...imageSourcesByType.entries()].map(([typeId, sources]) => [
typeId,
sources
.map((source) => {
const resolvedSource = resolvedImageSources.get(source);
if (resolvedSource) {
return resolvedSource;
}
return isGeneratedLegacyPath(source) ? '' : source;
})
.filter(Boolean),
]),
);
}
function hashMatch3DString(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
function resolveMatch3DImageForItem(
item: Match3DItemSnapshot,
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
const sources = imageSourcesByType.get(item.itemTypeId);
if (!sources || sources.length <= 0) {
return '';
}
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? '';
}
function buildOptimisticRun(
run: Match3DRunSnapshot,
item: Match3DItemSnapshot,
@@ -167,10 +300,12 @@ function buildOptimisticRun(
function Match3DToken({
item,
imageSrc,
disabled,
onClick,
}: {
item: Match3DItemSnapshot;
imageSrc?: string;
disabled: boolean;
onClick: (item: Match3DItemSnapshot) => void;
}) {
@@ -208,17 +343,28 @@ function Match3DToken({
}
onClick={() => onClick(item)}
>
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
{imageSrc ? (
<img
src={imageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-token-image"
className="relative z-10 h-full w-full object-contain drop-shadow-[0_10px_14px_rgba(15,23,42,0.34)]"
draggable={false}
/>
) : (
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
)}
</button>
);
}
function Match3DTrayToken({
slot,
use3DPreview,
imageSrc,
}: {
slot: Match3DTraySlot;
use3DPreview: boolean;
imageSrc?: string;
}) {
if (!slot.visualKey) {
return (
@@ -226,15 +372,23 @@ function Match3DTrayToken({
);
}
const visualSeed = resolveVisualSeed(slot.visualKey);
const fallback = <Match3DVisualIcon visualKey={slot.visualKey} />;
return (
<span
className="flex h-full w-full items-center justify-center p-1"
aria-label={visualSeed.label}
>
<span className={use3DPreview ? 'opacity-0' : 'opacity-100'}>
{fallback}
</span>
{imageSrc ? (
<img
src={imageSrc}
alt=""
aria-hidden="true"
data-testid="match3d-tray-image"
className="h-full w-full object-contain drop-shadow-[0_5px_8px_rgba(15,23,42,0.26)]"
draggable={false}
/>
) : (
<Match3DVisualIcon visualKey={slot.visualKey} />
)}
</span>
);
}
@@ -305,6 +459,7 @@ function Match3DSettlement({
export function Match3DRuntimeShell({
run,
generatedItemAssets = [],
backgroundImageSrc = null,
isBusy = false,
error = null,
embedded = false,
@@ -317,22 +472,20 @@ export function Match3DRuntimeShell({
const authUi = useAuthUi();
const stageRef = useRef<HTMLDivElement | null>(null);
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
const clickAudioRefs = useRef<Record<string, HTMLAudioElement>>({});
const clearSoundKeyRef = useRef<string | null>(null);
const countdownSoundKeyRef = useRef<string | null>(null);
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
const [feedbackEvent, setFeedbackEvent] =
useState<Match3DFeedbackEvent | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [force2DRender, setForce2DRender] = useState(() => {
if (typeof window === 'undefined') {
return true;
}
const params = new URLSearchParams(window.location.search);
return (
params.get('match3dRender') === '2d' ||
params.get('match3d3d') === 'off' ||
!MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT
);
});
const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] =
useState('');
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const runtimeGeneratedItemAssets = useMemo(
() => normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
[generatedItemAssets],
);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
@@ -362,23 +515,102 @@ export function Match3DRuntimeShell({
return () => window.clearTimeout(timer);
}, [feedbackEvent]);
const progressText = useMemo(() => {
useEffect(() => {
if (!run) {
return '0/0';
clearSoundKeyRef.current = null;
return;
}
if (!isRunState(run.status, 'won')) {
return;
}
return `${run.clearedItemCount}/${run.totalItemCount}`;
}, [run]);
const shouldUse3DRender = !force2DRender;
const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME;
const soundKey = `${run.runId}:${run.snapshotVersion}:won`;
if (clearSoundKeyRef.current === soundKey) {
return;
}
clearSoundKeyRef.current = soundKey;
playRuntimeLevelClearSound(musicVolume);
}, [musicVolume, run, run?.runId, run?.snapshotVersion, run?.status]);
useEffect(() => {
if (!run || !isRunState(run.status, 'running')) {
countdownSoundKeyRef.current = null;
return;
}
const secondBucket =
timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs
? resolveRuntimeCountdownSecondBucket(timeLeftMs)
: null;
if (secondBucket === null) {
countdownSoundKeyRef.current = null;
return;
}
const soundKey = `${run.runId}:${run.startedAtMs}:${secondBucket}`;
if (countdownSoundKeyRef.current === soundKey) {
return;
}
countdownSoundKeyRef.current = soundKey;
playRuntimeCountdownSound(musicVolume);
}, [
levelAudioConfig.countdownWarningThresholdMs,
musicVolume,
run,
run?.runId,
run?.startedAtMs,
run?.status,
timeLeftMs,
]);
const backgroundAssetSrc =
backgroundImageSrc?.trim() ||
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
'',
)
.find(Boolean) ||
'';
const containerAssetSrc =
runtimeGeneratedItemAssets
.map(
(asset) =>
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
'',
)
.find(Boolean) || '';
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
);
const imageSourceSignature = useMemo(
() => buildMatch3DImageSourceSignature(imageSourcesByType),
[imageSourcesByType],
);
const [resolvedImageSources, setResolvedImageSources] = useState<
Map<string, string>
>(() => new Map());
const resolvedImageSourcesByType = useMemo(
() =>
buildResolvedMatch3DImageSourcesByType(
imageSourcesByType,
resolvedImageSources,
),
[imageSourcesByType, resolvedImageSources],
);
const backgroundMusicSrc =
generatedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
runtimeGeneratedItemAssets.find((asset) => asset.backgroundMusic?.audioSrc)
?.backgroundMusic?.audioSrc ?? null;
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState('');
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
}
const readyAssets = generatedItemAssets.filter(
const readyAssets = runtimeGeneratedItemAssets.filter(
(asset) => asset.clickSound?.audioSrc,
);
const sortedTypes = [
@@ -390,11 +622,11 @@ export function Match3DRuntimeShell({
return src ? [[typeId, src] as const] : [];
}),
);
}, [generatedItemAssets, run]);
}, [runtimeGeneratedItemAssets, run]);
useEffect(() => {
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !backgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (!audio || !resolvedBackgroundMusicSrc || !run || !isRunState(run.status, 'running')) {
if (audio) {
audio.pause();
}
@@ -402,31 +634,161 @@ export function Match3DRuntimeShell({
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [backgroundMusicSrc, musicVolume, run]);
}, [musicVolume, resolvedBackgroundMusicSrc, run]);
useEffect(() => {
Object.values(clickAudioRefs.current).forEach((audio) => {
audio.volume = Math.max(0, Math.min(1, musicVolume));
});
}, [musicVolume]);
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
useEffect(() => {
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
setResolvedBackgroundMusicSrc('');
return undefined;
}
if (!isGeneratedLegacyPath(source)) {
setResolvedBackgroundMusicSrc(source);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedBackgroundMusicSrc('');
void resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedBackgroundMusicSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedBackgroundMusicSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [backgroundMusicSrc]);
const playClickSound = useCallback(
(item: Match3DItemSnapshot) => {
const src = clickSoundByTypeId.get(item.itemTypeId);
if (!src) {
return;
}
const current = clickAudioRefs.current[src] ?? new Audio(src);
clickAudioRefs.current[src] = current;
current.currentTime = 0;
current.volume = Math.max(0, Math.min(1, musicVolume));
void current.play().catch(() => {});
playRuntimeClickSound(src, musicVolume);
},
[clickSoundByTypeId, musicVolume],
);
const handleTrayPreviewFallback = useCallback(() => {
setForce2DRender(true);
}, []);
useEffect(() => {
if (!backgroundAssetSrc) {
setResolvedBackgroundImageSrc('');
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void resolveAssetReadUrl(backgroundAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedBackgroundImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedBackgroundImageSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [backgroundAssetSrc]);
useEffect(() => {
if (!containerAssetSrc) {
setResolvedContainerImageSrc('');
return undefined;
}
let cancelled = false;
const controller = new AbortController();
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
})
.then((resolvedSrc) => {
if (!cancelled) {
setResolvedContainerImageSrc(resolvedSrc);
}
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc('');
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [containerAssetSrc]);
useEffect(() => {
const rawSources = [
...new Set(
[...imageSourcesByType.values()]
.flatMap((sources) => sources)
.map((source) => source.trim())
.filter(Boolean),
),
];
if (rawSources.length <= 0) {
setResolvedImageSources(new Map());
return undefined;
}
let cancelled = false;
const controller = new AbortController();
const nextSources = new Map<string, string>();
setResolvedImageSources(() => new Map());
void Promise.all(
rawSources.map(async (source) => {
if (!isGeneratedLegacyPath(source)) {
nextSources.set(source, source);
return;
}
const resolvedSource = await resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
});
nextSources.set(source, resolvedSource || source);
}),
)
.then(() => {
if (!cancelled) {
setResolvedImageSources(nextSources);
}
})
.catch(() => {
if (!cancelled) {
setResolvedImageSources(new Map());
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [imageSourceSignature, imageSourcesByType]);
const trayPreviewItems = useMemo(() => {
if (!run) {
return [];
@@ -441,6 +803,7 @@ export function Match3DRuntimeShell({
const optimisticRun = buildOptimisticRun(run, item);
const clientEventId = buildClientEventId(item.itemInstanceId);
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
tryPlayBackgroundMusic();
playClickSound(item);
setPendingClick({
clientEventId,
@@ -507,12 +870,21 @@ export function Match3DRuntimeShell({
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
{backgroundMusicSrc ? (
{resolvedBackgroundImageSrc ? (
<img
src={resolvedBackgroundImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : null}
{resolvedBackgroundMusicSrc ? (
<audio
ref={backgroundAudioRef}
src={backgroundMusicSrc}
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
aria-label="抓大鹅背景音乐"
/>
) : null}
<div
@@ -526,19 +898,19 @@ export function Match3DRuntimeShell({
<header className="flex items-center justify-between gap-2">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onBack}
aria-label="返回"
>
<ArrowLeft size={20} />
</button>
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
<div className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>
<Clock3 size={16} />
<span>{formatTimer(timeLeftMs)}</span>
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
onClick={onRestart}
aria-label="重新开始"
>
@@ -546,51 +918,39 @@ export function Match3DRuntimeShell({
</button>
</header>
<section className="mt-3 grid w-full min-w-0 grid-cols-3 gap-2 overflow-hidden text-center text-[0.72rem] font-black">
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
{progressText}
</div>
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
{run.clearCount}
</div>
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
v{run.snapshotVersion}
</div>
</section>
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div
ref={stageRef}
className={`relative aspect-square max-w-full rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)] ${
shouldUse3DRender ? 'overflow-visible' : 'overflow-hidden'
}`}
className="relative aspect-square max-w-full overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
style={{
width: 'min(92vw, 58dvh, 100%)',
}}
onPointerDown={handleBoardPointerDown}
data-testid="match3d-board"
>
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
{shouldUse3DRender ? (
<Match3DPhysicsBoard
run={run}
disabled={Boolean(pendingClick)}
generatedItemAssets={generatedItemAssets}
onClickItem={(item) => {
void handleItemClick(item);
}}
onFallback={() => setForce2DRender(true)}
{resolvedContainerImageSrc ? (
<img
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className="pointer-events-none absolute inset-[-4%] z-0 h-[108%] w-[108%] object-contain"
data-testid="match3d-container-image"
/>
) : (
run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
item={item}
disabled={Boolean(pendingClick)}
onClick={handleItemClick}
/>
))
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
)}
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
item={item}
imageSrc={resolveMatch3DImageForItem(
item,
resolvedImageSourcesByType,
)}
disabled={Boolean(pendingClick)}
onClick={handleItemClick}
/>
))}
{feedbackEvent?.kind === 'cleared' ? (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
@@ -601,29 +961,35 @@ export function Match3DRuntimeShell({
</div>
</section>
<section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<section className={MATCH3D_RUNTIME_GLASS_TRAY_CLASS}>
<div
className="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
>
{shouldUse3DRender ? (
<Match3DTrayPreviewBoard
onFallback={handleTrayPreviewFallback}
referenceItems={run.items}
slotItems={trayPreviewItems}
generatedItemAssets={generatedItemAssets}
/>
) : null}
{run.traySlots.map((slot) => {
const trayItem =
trayPreviewItems[slot.slotIndex] ??
(slot.itemInstanceId
? run.items.find(
(item) => item.itemInstanceId === slot.itemInstanceId,
)
: null);
return (
<div
key={slot.slotIndex}
className="relative z-0 h-14 min-w-0 rounded-xl bg-white/10 p-1 sm:h-16"
className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS}
data-testid="match3d-tray-slot"
>
<Match3DTrayToken
slot={slot}
use3DPreview={shouldUse3DRender}
imageSrc={
trayItem
? resolveMatch3DImageForItem(
trayItem,
resolvedImageSourcesByType,
)
: ''
}
/>
</div>
);

View File

@@ -0,0 +1,15 @@
// 中文注释:运行态 HUD 使用题材无关的半透明玻璃样式,避免和 AI 生成背景、容器素材绑定。
export const MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS =
'flex h-10 w-10 items-center justify-center rounded-full border border-white/65 bg-white/72 text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md transition hover:bg-white/86 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/82';
export const MATCH3D_RUNTIME_GLASS_TIMER_CLASS =
'flex min-w-[4.25rem] items-center justify-center gap-1.5 rounded-full border border-white/65 bg-white/72 px-3 py-2 text-sm font-black text-slate-800 shadow-[0_8px_22px_rgba(15,23,42,0.14)] backdrop-blur-md';
export const MATCH3D_RUNTIME_GLASS_SPINNER_CLASS =
'h-4 w-4 rounded-full border-2 border-slate-700/76 border-l-transparent';
export const MATCH3D_RUNTIME_GLASS_TRAY_CLASS =
'mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/56 bg-white/34 p-2 shadow-[0_14px_32px_rgba(15,23,42,0.16)] backdrop-blur-md';
export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';

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,11 +1,16 @@
import { expect, test } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import {
derivePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
afterEach(() => {
vi.unstubAllEnvs();
});
test('database entry config controls visibility open state and display order', () => {
const cards = derivePlatformCreationTypes([
{
@@ -100,12 +105,14 @@ 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(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
expect(
cards.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
@@ -113,3 +120,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: '/child-motion-demo/picture-book-grass-stage.png',
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: '/child-motion-demo/picture-book-grass-stage.png',
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;
@@ -31,6 +32,15 @@ export function isPlatformCreationTypeVisible(
return creationTypes.some((item) => item.id === id && !item.hidden);
}
export function isPlatformCreationTypeOpen(
creationTypes: readonly PlatformCreationTypeCard[],
id: PlatformCreationTypeId,
) {
return creationTypes.some(
(item) => item.id === id && !item.hidden && !item.locked,
);
}
/**
* 创作入口卡片只做展示派生;配置事实源来自后端 API / SpacetimeDB前端不再保留入口默认配置。
*/
@@ -46,7 +56,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

@@ -39,6 +39,11 @@ 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'
| 'baby-love-drawing-runtime'
| 'puzzle-agent-workspace'
| 'puzzle-generating'
| 'puzzle-onboarding'

View File

@@ -181,7 +181,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
});
expect(screen.getByText('消耗2点')).toBeTruthy();
expect(screen.getByText('消耗2点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
@@ -213,12 +213,14 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
);
expect(screen.getByText('拼图画面')).toBeTruthy();
expect(
screen.queryByText('若没有合适的图片可以通过填写画面描述生成画面'),
).toBeNull();
expect(
screen
.getByText('若没有合适的图片可以通过填写画面描述生成画面')
.getByText('上传图片/填写画面描述')
.closest('.puzzle-image-upload-card'),
).toBeTruthy();
expect(screen.getByText('点击上传拼图图片').closest('.puzzle-image-upload-card')).toBeTruthy();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.queryByLabelText('拼图创作模板')).toBeNull();
expect(
@@ -276,6 +278,9 @@ test('puzzle workspace selects a history image from the upload card', async () =
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
expect(historyButton.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(historyButton.className).toContain('top-3');
expect(historyButton.className).toContain('right-3');
expect(historyButton.className).not.toContain('bottom-3');
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).toBeTruthy();
fireEvent.click(historyButton);
@@ -321,7 +326,7 @@ test('puzzle upload card stays light in light theme', () => {
);
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
const uploadLabel = screen.getByText('点击上传拼图图片');
const uploadLabel = screen.getByText('上传图片/填写画面描述');
expect(uploadLabel).toBeTruthy();
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(uploadLabel.className).not.toContain('rounded-full');
@@ -493,7 +498,7 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect((aiRedrawSwitch as HTMLInputElement).checked).toBe(true);
fireEvent.click(aiRedrawSwitch);
expect(screen.queryByLabelText('画面AI重绘要求提示词')).toBeNull();
expect(screen.queryByText('消耗2点')).toBeNull();
expect(screen.queryByText('消耗2点')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
@@ -571,7 +576,13 @@ test('puzzle workspace shows AI redraw switch only after upload', async () => {
screen.getByRole('switch', { name: 'AI重绘' }).closest('.puzzle-image-upload-card'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '移除拼图图片' })).toBeTruthy();
expect(screen.queryByText('点击上传拼图图片')).toBeNull();
expect(screen.getByRole('button', { name: '移除拼图图片' }).className).toContain(
'left-3',
);
expect(screen.getByRole('button', { name: '选择历史图片' }).className).toContain(
'right-3',
);
expect(screen.queryByText('上传图片/填写画面描述')).toBeNull();
});
test('puzzle workspace confirms before removing uploaded image', async () => {
@@ -611,7 +622,7 @@ test('puzzle workspace confirms before removing uploaded image', async () => {
fireEvent.click(screen.getByRole('button', { name: '移除' }));
expect(screen.queryByAltText('拼图图片')).toBeNull();
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
expect(screen.getByText('点击上传拼图图片')).toBeTruthy();
expect(screen.getByText('上传图片/填写画面描述')).toBeTruthy();
});
test('puzzle workspace opens crop tool for non-square uploads', async () => {

View File

@@ -8,8 +8,6 @@ import {
} from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
type PointerEvent,
useEffect,
useMemo,
useRef,
@@ -27,6 +25,12 @@ import {
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
SquareImageCropModal,
type SquareImageCropRect,
} from '../common/SquareImageCropModal';
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
import {
normalizePuzzleImageModel,
@@ -69,81 +73,11 @@ type PuzzleImageCropState = {
source: string;
label: string;
imageSize: { width: number; height: number };
cropX: number;
cropY: number;
cropSize: number;
cropRect: SquareImageCropRect;
error: string | null;
isSaving: boolean;
};
type PuzzleCropDragHandle =
| 'move'
| 'north'
| 'northEast'
| 'east'
| 'southEast'
| 'south'
| 'southWest'
| 'west'
| 'northWest';
type PuzzleCropDragSnapshot = {
pointerId: number;
handle: PuzzleCropDragHandle;
clientX: number;
clientY: number;
cropRect: { x: number; y: number; size: number };
previewWidth: number;
previewHeight: number;
};
const PUZZLE_CROP_RESIZE_HANDLES: Array<{
handle: Exclude<PuzzleCropDragHandle, 'move'>;
label: string;
className: string;
}> = [
{
handle: 'northWest',
label: '拖拽左上角裁剪边界',
className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
},
{
handle: 'north',
label: '拖拽上边裁剪边界',
className: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
},
{
handle: 'northEast',
label: '拖拽右上角裁剪边界',
className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
},
{
handle: 'east',
label: '拖拽右边裁剪边界',
className: 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
},
{
handle: 'southEast',
label: '拖拽右下角裁剪边界',
className: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
},
{
handle: 'south',
label: '拖拽下边裁剪边界',
className: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
},
{
handle: 'southWest',
label: '拖拽左下角裁剪边界',
className: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
},
{
handle: 'west',
label: '拖拽左边裁剪边界',
className: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
},
];
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
@@ -202,324 +136,6 @@ function resolveInitialFormState(
};
}
function clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getPuzzleCropSizeBounds(imageSize: { width: number; height: number }) {
const maxSize = Math.max(1, Math.min(imageSize.width, imageSize.height));
const minSize = Math.min(maxSize, Math.max(48, maxSize * 0.18));
return { minSize, maxSize };
}
function clampPuzzleImageCropRect(
imageSize: { width: number; height: number },
crop: { x: number; y: number; size: number },
) {
const { minSize, maxSize } = getPuzzleCropSizeBounds(imageSize);
const size = clampNumber(crop.size, minSize, maxSize);
return {
x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)),
y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)),
size,
};
}
function buildPuzzleCropPreviewStyle(
crop: { x: number; y: number; size: number },
imageSize: { width: number; height: number },
) {
return {
left: `${(crop.x / imageSize.width) * 100}%`,
top: `${(crop.y / imageSize.height) * 100}%`,
width: `${(crop.size / imageSize.width) * 100}%`,
height: `${(crop.size / imageSize.height) * 100}%`,
} satisfies CSSProperties;
}
function resizePuzzleCropRectFromHandle(
snapshot: PuzzleCropDragSnapshot,
deltaX: number,
deltaY: number,
imageSize: { width: number; height: number },
) {
const start = snapshot.cropRect;
const startRight = start.x + start.size;
const startBottom = start.y + start.size;
const startCenterX = start.x + start.size / 2;
const startCenterY = start.y + start.size / 2;
const { minSize, maxSize } = getPuzzleCropSizeBounds(imageSize);
const chooseSize = (sizeFromX: number, sizeFromY: number) => {
const xDistance = Math.abs(sizeFromX - start.size);
const yDistance = Math.abs(sizeFromY - start.size);
return xDistance >= yDistance ? sizeFromX : sizeFromY;
};
const clampSize = (size: number, maxByAnchor = maxSize) =>
clampNumber(size, minSize, Math.max(minSize, Math.min(maxSize, maxByAnchor)));
if (snapshot.handle === 'move') {
return clampPuzzleImageCropRect(imageSize, {
...start,
x: start.x + deltaX,
y: start.y + deltaY,
});
}
if (snapshot.handle === 'east' || snapshot.handle === 'west') {
const isEast = snapshot.handle === 'east';
const anchorX = isEast ? start.x : startRight;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByCenterY =
2 * Math.min(startCenterY, imageSize.height - startCenterY);
const size = clampSize(
start.size + (isEast ? deltaX : -deltaX),
Math.min(maxByAnchorX, maxByCenterY),
);
return clampPuzzleImageCropRect(imageSize, {
x: isEast ? anchorX : anchorX - size,
y: startCenterY - size / 2,
size,
});
}
if (snapshot.handle === 'north' || snapshot.handle === 'south') {
const isSouth = snapshot.handle === 'south';
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
const maxByCenterX =
2 * Math.min(startCenterX, imageSize.width - startCenterX);
const size = clampSize(
start.size + (isSouth ? deltaY : -deltaY),
Math.min(maxByAnchorY, maxByCenterX),
);
return clampPuzzleImageCropRect(imageSize, {
x: startCenterX - size / 2,
y: isSouth ? anchorY : anchorY - size,
size,
});
}
const isEast = snapshot.handle === 'northEast' || snapshot.handle === 'southEast';
const isSouth = snapshot.handle === 'southEast' || snapshot.handle === 'southWest';
const anchorX = isEast ? start.x : startRight;
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
const sizeFromX = start.size + (isEast ? deltaX : -deltaX);
const sizeFromY = start.size + (isSouth ? deltaY : -deltaY);
const size = clampSize(
chooseSize(sizeFromX, sizeFromY),
Math.min(maxByAnchorX, maxByAnchorY),
);
return clampPuzzleImageCropRect(imageSize, {
x: isEast ? anchorX : anchorX - size,
y: isSouth ? anchorY : anchorY - size,
size,
});
}
function PuzzleImageCropModal({
state,
onCropRectChange,
onClose,
onSubmit,
}: {
state: PuzzleImageCropState;
onCropRectChange: (nextCrop: { x: number; y: number; size: number }) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
const dragSnapshotRef = useRef<PuzzleCropDragSnapshot | null>(null);
const [activeDragHandle, setActiveDragHandle] =
useState<PuzzleCropDragHandle | null>(null);
const cropRect = useMemo(
() =>
clampPuzzleImageCropRect(state.imageSize, {
x: state.cropX,
y: state.cropY,
size: state.cropSize,
}),
[state.cropSize, state.cropX, state.cropY, state.imageSize],
);
const previewStyle = useMemo(
() => buildPuzzleCropPreviewStyle(cropRect, state.imageSize),
[cropRect, state.imageSize],
);
const editorPreviewStyle = useMemo(
() =>
({
aspectRatio: `${state.imageSize.width} / ${state.imageSize.height}`,
width: `min(100%, calc(min(52vh, 22rem) * ${
state.imageSize.width / Math.max(1, state.imageSize.height)
}))`,
}) satisfies CSSProperties,
[state.imageSize],
);
const beginCropDrag = (
handle: PuzzleCropDragHandle,
event: PointerEvent<HTMLElement>,
) => {
if (state.isSaving) {
return;
}
const preview = previewRef.current;
if (!preview) {
return;
}
const rect = preview.getBoundingClientRect();
dragSnapshotRef.current = {
pointerId: event.pointerId,
handle,
clientX: event.clientX,
clientY: event.clientY,
cropRect,
previewWidth: rect.width,
previewHeight: rect.height,
};
setActiveDragHandle(handle);
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
};
const updateCropDrag = (event: PointerEvent<HTMLElement>) => {
const snapshot = dragSnapshotRef.current;
if (!snapshot || snapshot.pointerId !== event.pointerId) {
return;
}
const deltaX =
((event.clientX - snapshot.clientX) * state.imageSize.width) /
Math.max(1, snapshot.previewWidth);
const deltaY =
((event.clientY - snapshot.clientY) * state.imageSize.height) /
Math.max(1, snapshot.previewHeight);
onCropRectChange(
resizePuzzleCropRectFromHandle(snapshot, deltaX, deltaY, state.imageSize),
);
};
const stopCropDrag = (event: PointerEvent<HTMLElement>) => {
if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
return;
}
dragSnapshotRef.current = null;
setActiveDragHandle(null);
event.currentTarget.releasePointerCapture(event.pointerId);
};
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-image-crop-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id="puzzle-image-crop-title" className="text-base font-black">
</div>
<button
type="button"
aria-label="关闭拼图图片裁剪"
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<div
ref={previewRef}
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
style={editorPreviewStyle}
aria-label="拼图图片裁剪操作区"
>
<img
src={state.source}
alt="拼图图片裁剪预览"
draggable={false}
className="h-full w-full object-fill"
/>
<div
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
}`}
style={previewStyle}
onPointerDown={(event) => beginCropDrag('move', event)}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
/>
<div
className="pointer-events-none absolute border border-white/70"
style={previewStyle}
>
<div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
<div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
<div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
<div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
</div>
<div className="pointer-events-none absolute" style={previewStyle}>
{PUZZLE_CROP_RESIZE_HANDLES.map((handleConfig) => (
<button
key={handleConfig.handle}
type="button"
aria-label={handleConfig.label}
disabled={state.isSaving}
className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 ${handleConfig.className}`}
onPointerDown={(event) =>
beginCropDrag(handleConfig.handle, event)
}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
>
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)]" />
</button>
))}
</div>
</div>
{state.error ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{state.error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={onSubmit}
disabled={state.isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{state.isSaving ? '裁剪中' : '应用'}
</button>
</div>
</div>
</div>
</div>
);
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
@@ -654,17 +270,15 @@ export function PuzzleAgentWorkspace({
try {
const uploadImage = await readPuzzleReferenceImageForUpload(file);
if (!isPuzzleReferenceImageSquare(uploadImage)) {
const cropSize = Math.min(uploadImage.width, uploadImage.height);
const imageSize = {
width: uploadImage.width,
height: uploadImage.height,
};
setCropState({
source: uploadImage.dataUrl,
label: file.name.trim() || '本地拼图图片',
imageSize: {
width: uploadImage.width,
height: uploadImage.height,
},
cropX: Math.max(0, (uploadImage.width - cropSize) / 2),
cropY: Math.max(0, (uploadImage.height - cropSize) / 2),
cropSize,
imageSize,
cropRect: buildCenteredSquareImageCropRect(imageSize),
error: null,
isSaving: false,
});
@@ -693,12 +307,10 @@ export function PuzzleAgentWorkspace({
if (!current) {
return current;
}
const clamped = clampPuzzleImageCropRect(current.imageSize, nextCrop);
const clamped = clampSquareImageCropRect(current.imageSize, nextCrop);
return {
...current,
cropX: clamped.x,
cropY: clamped.y,
cropSize: clamped.size,
cropRect: clamped,
};
});
};
@@ -718,9 +330,9 @@ export function PuzzleAgentWorkspace({
try {
const dataUrl = await cropPuzzleReferenceImageDataUrl({
source: currentCropState.source,
cropX: currentCropState.cropX,
cropY: currentCropState.cropY,
cropSize: currentCropState.cropSize,
cropX: currentCropState.cropRect.x,
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
setFormState((current) => ({
...current,
@@ -879,7 +491,7 @@ export function PuzzleAgentWorkspace({
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="选择历史图片"
title="选择历史图片"
>
@@ -922,7 +534,7 @@ export function PuzzleAgentWorkspace({
type="button"
disabled={isBusy}
onClick={() => setIsRemoveImageConfirmOpen(true)}
className="absolute right-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
aria-label="移除拼图图片"
title="移除拼图图片"
>
@@ -933,14 +545,9 @@ export function PuzzleAgentWorkspace({
htmlFor="puzzle-image-upload-input"
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
>
/
</label>
)}
{formState.referenceImageSrc ? null : (
<div className="pointer-events-none absolute bottom-16 left-4 right-4 z-10 text-center text-[11px] font-semibold leading-4 text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div>
@@ -1008,15 +615,29 @@ export function PuzzleAgentWorkspace({
<span>稿</span>
{formState.aiRedraw ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
2
</span>
) : null}
</span>
</button>
</div>
{cropState ? (
<PuzzleImageCropModal
state={cropState}
<SquareImageCropModal
source={cropState.source}
imageSize={cropState.imageSize}
cropRect={cropState.cropRect}
titleId="puzzle-image-crop-title"
labels={{
title: '裁剪拼图图片',
close: '关闭拼图图片裁剪',
editor: '拼图图片裁剪操作区',
previewAlt: '拼图图片裁剪预览',
cancel: '取消',
submit: '应用',
saving: '裁剪中',
}}
error={cropState.error}
isSaving={cropState.isSaving}
onCropRectChange={updateCropState}
onClose={() => setCropState(null)}
onSubmit={() => {

View File

@@ -20,11 +20,22 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
'data-testid'?: string;
}) => (
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : null
),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
@@ -37,6 +48,16 @@ vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src
? `https://signed.example.com/${src.replace(/^\/+/u, '')}`
: '',
isResolving: false,
shouldResolve: Boolean(src?.trim().startsWith('/generated-')),
}),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -157,6 +178,8 @@ describe('PuzzleResultView', () => {
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '素材配置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
@@ -171,6 +194,33 @@ describe('PuzzleResultView', () => {
);
});
test('result action bar restores draft trial entry', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
workTitle: '暖灯猫街作品',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
],
}),
);
});
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -238,9 +288,9 @@ describe('PuzzleResultView', () => {
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗点',
name: '确认消耗点',
});
expect(within(confirmDialog).getByText('消耗 2 点')).toBeTruthy();
expect(within(confirmDialog).getByText('消耗 2 点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
expect(onExecuteAction).toHaveBeenCalledWith({
@@ -321,7 +371,7 @@ describe('PuzzleResultView', () => {
expect(
within(dialog).getByRole('button', { name: /生成画面/u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2点')).toBeTruthy();
expect(within(dialog).getByText('消耗2点')).toBeTruthy();
expect(
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
).toBeTruthy();
@@ -384,7 +434,7 @@ describe('PuzzleResultView', () => {
});
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -431,7 +481,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -467,7 +517,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -554,7 +604,7 @@ describe('PuzzleResultView', () => {
rerender(
<PuzzleResultView
session={createSession()}
error="光点余额不足"
error="泥点余额不足"
isBusy={false}
onBack={() => {}}
onExecuteAction={onExecuteAction}
@@ -565,7 +615,7 @@ describe('PuzzleResultView', () => {
name: '发布拼图作品',
});
expect(publishDialog).toBeTruthy();
expect(within(publishDialog).getByText('光点余额不足')).toBeTruthy();
expect(within(publishDialog).getByText('泥点余额不足')).toBeTruthy();
});
test('generates six tags after work title and description are filled', () => {
@@ -619,6 +669,253 @@ describe('PuzzleResultView', () => {
});
});
test('renders UI background tab with saved prompt and runtime preview', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background.png',
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background.png',
);
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
'value',
'雨夜猫街竖屏拼图UI背景',
);
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
const preview = screen.getByRole('dialog', { name: 'UI预览' });
expect(
within(preview)
.getByTestId('puzzle-ui-runtime-preview-background')
.getAttribute('src'),
).toBe('/generated-puzzle-assets/session/ui/background.png');
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
});
test('generates UI background with edited prompt and current levels snapshot', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新拼图UI背景提示词' },
});
expect(screen.getByRole('button', { name: /生成UI背景 · 2泥点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_ui_background',
levelId: 'puzzle-level-1',
promptText: '新拼图UI背景提示词',
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
uiBackgroundPrompt: '新拼图UI背景提示词',
}),
]);
});
test('素材配置背景音乐试听使用签名地址', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/music.mp3',
prompt: '',
title: '雨夜轻响',
updatedAt: '2026-05-12T10:00:00.000Z',
},
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByRole('button', { name: /重新生成音乐 · 5泥点/u })).toBeTruthy();
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/music.mp3',
);
});
test('生成完成回包合并音乐和UI背景后试玩使用最新资源', () => {
const onStartTestRun = vi.fn();
const base = createSession();
const localLevel = {
...base.draft!.levels![0]!,
generationStatus: 'generating' as const,
uiBackgroundPrompt: '旧的UI背景提示词',
uiBackgroundImageSrc: null,
backgroundMusic: null,
};
const incomingLevel = {
...localLevel,
generationStatus: 'ready' as const,
uiBackgroundPrompt: '水果乐园UI背景',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/fruit-background.png',
backgroundMusic: {
taskId: 'music-task-fruit',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-fruit',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
prompt: '',
title: '水果乐园',
updatedAt: '2026-05-14T10:00:00.000Z',
},
};
const { rerender } = render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [localLevel],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
rerender(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
generationStatus: 'ready',
levels: [incomingLevel],
},
updatedAt: '2026-05-14T10:00:00.000Z',
})}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/fruit-background.png',
);
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/fruit.mp3',
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levels: [
expect.objectContaining({
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/fruit-background.png',
backgroundMusic: expect.objectContaining({
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
}),
}),
],
}),
);
});
test('auto saves UI background prompt edits through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
item: {} as never,
});
render(
<PuzzleResultView
session={createSession()}
profileId="puzzle-profile-session-1"
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新的自动保存UI背景提示词' },
});
await act(async () => {
await vi.runAllTimersAsync();
});
expect(puzzleWorksService.updatePuzzleWork).toHaveBeenCalledWith(
'puzzle-profile-session-1',
expect.objectContaining({
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
uiBackgroundPrompt: '新的自动保存UI背景提示词',
}),
],
}),
);
});
test('selects a history puzzle asset as reference image for the selected level', async () => {
const onExecuteAction = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
@@ -668,7 +965,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -714,7 +1011,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -751,7 +1048,7 @@ describe('PuzzleResultView', () => {
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),

View File

@@ -1,8 +1,10 @@
import {
ArrowLeft,
CheckCircle2,
Eye,
History,
ImagePlus,
LayoutTemplate,
Loader2,
MessageSquareText,
Music,
@@ -10,13 +12,14 @@ import {
Plus,
Sparkles,
Trash2,
Wand2,
X,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
@@ -30,6 +33,7 @@ import {
} from '../../services/creation-audio';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
@@ -58,7 +62,8 @@ type PuzzleResultViewProps = {
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'music';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui' | 'music';
type DraftEditState = {
workTitle: string;
@@ -71,9 +76,26 @@ const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_BACKGROUND_MUSIC_POINT_COST = 5;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
{ id: 'levels', label: '拼图关卡' },
{ id: 'work', label: '作品信息' },
{ id: 'assets', label: '素材配置' },
];
const PUZZLE_ASSET_CONFIG_TABS: Array<{
id: PuzzleAssetConfigTabId;
label: string;
}> = [
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '背景音乐' },
];
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
@@ -125,6 +147,26 @@ function normalizeThemeTagInput(value: string) {
];
}
function buildDefaultPuzzleUiBackgroundPrompt(
editState: DraftEditState,
level: PuzzleDraftLevel | null,
) {
const tags = editState.themeTags
.map((tag) => tag.trim())
.filter(Boolean)
.join('');
return [
editState.workTitle.trim(),
editState.workDescription.trim(),
level?.levelName.trim(),
level?.pictureDescription.trim(),
tags,
'移动端拼图游戏 UI 背景,中央正方形拼图区边界清晰,拼图区外氛围与作品名称一致',
]
.filter(Boolean)
.join('。');
}
function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
const selectedCandidate =
level.candidates.find(
@@ -169,6 +211,9 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
levelName: level.levelName?.trim() || '',
pictureDescription: level.pictureDescription?.trim() || draft.summary,
pictureReference: level.pictureReference ?? null,
uiBackgroundPrompt: level.uiBackgroundPrompt ?? null,
uiBackgroundImageSrc: level.uiBackgroundImageSrc ?? null,
uiBackgroundImageObjectKey: level.uiBackgroundImageObjectKey ?? null,
candidates: level.candidates ?? [],
selectedCandidateId: level.selectedCandidateId ?? null,
coverImageSrc: level.coverImageSrc ?? null,
@@ -250,6 +295,14 @@ function mergeDraftEditStateWithIncomingState(
coverImageSrc: incomingLevel.coverImageSrc,
coverAssetId: incomingLevel.coverAssetId,
pictureReference: incomingLevel.pictureReference ?? level.pictureReference,
uiBackgroundPrompt:
incomingLevel.uiBackgroundPrompt ?? level.uiBackgroundPrompt,
uiBackgroundImageSrc:
incomingLevel.uiBackgroundImageSrc ?? level.uiBackgroundImageSrc,
uiBackgroundImageObjectKey:
incomingLevel.uiBackgroundImageObjectKey ??
level.uiBackgroundImageObjectKey,
backgroundMusic: incomingLevel.backgroundMusic ?? level.backgroundMusic,
generationStatus: incomingLevel.generationStatus || 'ready',
};
});
@@ -273,6 +326,9 @@ function createBlankPuzzleLevel(
levelName: '',
pictureDescription: '',
pictureReference: null,
uiBackgroundPrompt: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
@@ -382,11 +438,7 @@ function PuzzleResultTabs({
}) {
return (
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
{ id: 'music' as const, label: '音乐' },
].map((tab) => (
{PUZZLE_RESULT_TABS.map((tab) => (
<button
key={tab.id}
type="button"
@@ -405,6 +457,34 @@ function PuzzleResultTabs({
);
}
function PuzzleAssetConfigTabs({
activeTab,
onChange,
}: {
activeTab: PuzzleAssetConfigTabId;
onChange: (tab: PuzzleAssetConfigTabId) => void;
}) {
return (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
{PUZZLE_ASSET_CONFIG_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition ${
activeTab === tab.id
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)] hover:bg-white/60'
}`}
aria-pressed={activeTab === tab.id}
>
{tab.label}
</button>
))}
</div>
);
}
function PuzzleThemeTagEditor({
editState,
isBusy,
@@ -875,7 +955,7 @@ function PuzzleLevelDetailDialog({
<Sparkles className="h-4 w-4" />
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</span>
</span>
<span className="text-[11px] font-semibold leading-none text-white/78">
@@ -893,7 +973,7 @@ function PuzzleLevelDetailDialog({
<section
role="dialog"
aria-modal="true"
aria-label="确认消耗点"
aria-label="确认消耗点"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
>
@@ -902,11 +982,11 @@ function PuzzleLevelDetailDialog({
<Sparkles className="h-4 w-4" />
</span>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
<button
@@ -1327,6 +1407,247 @@ function PuzzleWorkInfoTab({
);
}
function PuzzleUiAssetsTab({
editState,
imageRefreshKey,
isBusy,
onChange,
onGenerate,
}: {
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
onGenerate: (prompt: string) => void;
}) {
const firstLevel = editState.levels[0] ?? null;
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
editState,
firstLevel,
);
const prompt = firstLevel?.uiBackgroundPrompt ?? defaultPrompt;
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
const backgroundPreviewSrc =
firstLevel?.uiBackgroundImageSrc?.trim() || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const updateFirstLevel = (nextLevel: PuzzleDraftLevel) => {
onChange({
...editState,
levels: [nextLevel, ...editState.levels.slice(1)],
});
};
return (
<div className="space-y-3">
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(13rem,0.78fr)_minmax(0,1fr)]">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="mx-auto aspect-[9/16] max-h-[min(62dvh,34rem)] w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 text-left shadow-sm"
aria-label="打开拼图UI预览"
>
<ResolvedAssetImage
src={backgroundPreviewSrc}
refreshKey={`${imageRefreshKey}:ui-background`}
alt="拼图UI背景图"
className="h-full w-full object-cover"
/>
</button>
<div className="flex min-h-0 flex-col">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
UI背景提示词
</span>
<textarea
value={prompt}
disabled={isBusy || !firstLevel}
rows={8}
onChange={(event) => {
if (!firstLevel) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: event.target.value,
});
}}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="拼图UI背景提示词"
/>
</label>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setIsPreviewOpen(true)}
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3"
>
<Eye className="h-4 w-4" />
UI
</button>
<button
type="button"
disabled={!firstLevel || !normalizedPrompt || isBusy}
onClick={() => {
if (!firstLevel || !normalizedPrompt) {
return;
}
updateFirstLevel({
...firstLevel,
uiBackgroundPrompt: normalizedPrompt,
});
onGenerate(normalizedPrompt);
}}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
</div>
</section>
{isPreviewOpen ? (
<PuzzleUiRuntimePreviewPanel
backgroundPreviewSrc={backgroundPreviewSrc}
imageRefreshKey={imageRefreshKey}
puzzleImageSrc={formalImageSrc}
title={editState.workTitle || firstLevel?.levelName || '拼图'}
onClose={() => setIsPreviewOpen(false)}
/>
) : null}
</div>
);
}
function PuzzleUiRuntimePreviewPanel({
backgroundPreviewSrc,
imageRefreshKey,
puzzleImageSrc,
title,
onClose,
}: {
backgroundPreviewSrc: string;
imageRefreshKey: string;
puzzleImageSrc: string;
title: string;
onClose: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[139] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="UI预览"
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-sm flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
UI预览
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="mx-auto aspect-[9/16] max-h-[min(78dvh,42rem)] w-full max-w-[22rem] overflow-hidden rounded-[1.4rem] border border-white/22 bg-[#16211f] shadow-[0_18px_55px_rgba(15,23,42,0.24)]">
<div className="relative flex h-full w-full flex-col overflow-hidden px-3 pb-4 pt-3 text-white">
<ResolvedAssetImage
src={backgroundPreviewSrc}
refreshKey={`${imageRefreshKey}:ui-runtime-preview`}
alt=""
data-testid="puzzle-ui-runtime-preview-background"
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18)_0%,rgba(15,23,42,0.05)_45%,rgba(15,23,42,0.24)_100%)]" />
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-2">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
<ArrowLeft size={20} />
</span>
<span className="min-w-0 truncate rounded-full border border-white/18 bg-black/26 px-3 py-2 text-center text-sm font-black backdrop-blur">
{title}
</span>
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
<LayoutTemplate className="h-4 w-4" />
</span>
</header>
<section className="relative z-10 mt-4 flex min-h-0 flex-1 items-center justify-center">
<div
className="relative aspect-square max-w-full overflow-hidden rounded-[1.25rem] border-[8px] border-white/88 bg-white/92 shadow-[0_20px_44px_rgba(15,23,42,0.32),inset_0_0_0_2px_rgba(15,23,42,0.12)]"
style={{ width: 'min(88%, 52dvh, 100%)' }}
aria-label="拼图区边界"
>
{puzzleImageSrc ? (
<ResolvedAssetImage
src={puzzleImageSrc}
refreshKey={`${imageRefreshKey}:ui-runtime-board`}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full grid-cols-3 grid-rows-3 gap-1 bg-slate-100 p-2">
{Array.from({ length: 9 }).map((_, index) => (
<span
key={index}
className="rounded-[0.45rem] bg-slate-300/70"
/>
))}
</div>
)}
<div className="pointer-events-none absolute inset-0 rounded-[0.82rem] border-2 border-black/18" />
</div>
</section>
<footer className="relative z-10 mt-3 rounded-[1.35rem] border border-white/16 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<span
key={index}
className="h-12 rounded-xl bg-white/14 sm:h-14"
/>
))}
</div>
</footer>
</div>
</div>
</div>
</section>
</div>,
document.body,
);
}
function PuzzleMusicTab({
editState,
profileId,
@@ -1341,25 +1662,24 @@ function PuzzleMusicTab({
onChange: (nextState: DraftEditState) => void;
}) {
const currentMusic = editState.levels[0]?.backgroundMusic ?? null;
const [prompt, setPrompt] = useState(() =>
[
editState.workTitle.trim(),
editState.workDescription.trim(),
editState.themeTags.join(''),
'轻快、适合拼图游戏循环播放的背景音乐',
]
.filter(Boolean)
.join(''),
);
const [title, setTitle] = useState(() =>
`${editState.workTitle.trim() || '拼图'}背景音乐`.slice(0, 40),
(
currentMusic?.title?.trim() ||
editState.levels[0]?.levelName.trim() ||
editState.workTitle.trim() ||
'拼图'
).slice(0, 40),
);
const [tags, setTags] = useState('轻快, 游戏, 循环, instrumental');
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const { resolvedUrl: resolvedMusicSrc } = useResolvedAssetReadUrl(
currentMusic?.audioSrc,
{ expireSeconds: 300 },
);
const canGenerate = prompt.trim().length > 0 && title.trim().length > 0;
const canGenerate = title.trim().length > 0;
const writeMusic = (music: CreationAudioAsset) => {
const firstLevel = editState.levels[0];
if (!firstLevel) {
@@ -1383,7 +1703,7 @@ function PuzzleMusicTab({
setErrorText(null);
try {
const task = await createBackgroundMusicTask({
prompt: prompt.trim(),
prompt: '',
title: title.trim(),
tags: tags.trim() || null,
});
@@ -1406,7 +1726,7 @@ function PuzzleMusicTab({
assetObjectId: asset.assetObjectId ?? null,
assetKind: asset.assetKind ?? PUZZLE_BACKGROUND_MUSIC_ASSET_KIND,
audioSrc: asset.audioSrc,
prompt: prompt.trim(),
prompt: '',
title: title.trim(),
updatedAt: new Date().toISOString(),
});
@@ -1434,12 +1754,17 @@ function PuzzleMusicTab({
</span>
) : null}
</div>
{currentMusic?.audioSrc ? (
{currentMusic?.audioSrc && resolvedMusicSrc ? (
<audio
className="mt-3 w-full"
controls
src={currentMusic.audioSrc}
src={resolvedMusicSrc}
aria-label="拼图背景音乐"
/>
) : currentMusic?.audioSrc ? (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
@@ -1473,19 +1798,6 @@ function PuzzleMusicTab({
aria-label="背景音乐风格"
/>
</label>
<label className="mt-3 block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={prompt}
disabled={isBusy || isGenerating}
rows={5}
onChange={(event) => setPrompt(event.target.value)}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="背景音乐提示词"
/>
</label>
<button
type="button"
disabled={!canGenerate || isBusy || isGenerating}
@@ -1497,7 +1809,7 @@ function PuzzleMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
</button>
</section>
@@ -1510,22 +1822,75 @@ function PuzzleMusicTab({
);
}
function PuzzleAssetConfigTab({
activeAssetConfigTab,
editState,
imageRefreshKey,
isBusy,
profileId,
sessionId,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
}: {
activeAssetConfigTab: PuzzleAssetConfigTabId;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
profileId: string | null;
sessionId: string;
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
onChange: (nextState: DraftEditState) => void;
onGenerateUiBackground: (prompt: string) => void;
}) {
return (
<div className="min-h-0">
<PuzzleAssetConfigTabs
activeTab={activeAssetConfigTab}
onChange={onAssetConfigTabChange}
/>
{activeAssetConfigTab === 'ui' ? (
<PuzzleUiAssetsTab
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onChange={onChange}
onGenerate={onGenerateUiBackground}
/>
) : null}
{activeAssetConfigTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId}
sessionId={sessionId}
isBusy={isBusy}
onChange={onChange}
/>
) : null}
</div>
);
}
function PuzzleResultActionBar({
actionError,
editState,
imageRefreshKey,
isBusy,
canStartTestRun,
publishReady,
publishBlockers,
onPublish,
onStartTestRun,
}: {
actionError: string | null;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
canStartTestRun: boolean;
publishReady: boolean;
publishBlockers: string[];
onPublish: () => void;
onStartTestRun?: () => void;
}) {
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
@@ -1537,6 +1902,19 @@ function PuzzleResultActionBar({
return (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
{onStartTestRun ? (
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy || !canStartTestRun}
className={`platform-button platform-button--ghost ${isBusy || !canStartTestRun ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
onClick={() => {
@@ -1583,6 +1961,8 @@ export function PuzzleResultView({
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<PuzzleAssetConfigTabId>('ui');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
@@ -1711,6 +2091,10 @@ export function PuzzleResultView({
levelName: level.levelName.trim(),
pictureDescription: level.pictureDescription.trim(),
pictureReference: level.pictureReference?.trim() || null,
uiBackgroundPrompt: level.uiBackgroundPrompt?.trim() || null,
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
uiBackgroundImageObjectKey:
level.uiBackgroundImageObjectKey?.trim() || null,
generationStatus: level.generationStatus || 'idle',
})),
};
@@ -1828,6 +2212,7 @@ export function PuzzleResultView({
generationStatus: level.generationStatus,
levels: [level],
});
const canStartTestRun = Boolean(onStartTestRun && primaryImageSrc);
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
@@ -1909,13 +2294,41 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'music' ? (
<PuzzleMusicTab
{activeTab === 'assets' ? (
<PuzzleAssetConfigTab
activeAssetConfigTab={activeAssetConfigTab}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
profileId={profileId ?? null}
sessionId={session.sessionId}
isBusy={isBusy}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerateUiBackground={(prompt) => {
const firstLevel = editState.levels[0] ?? null;
if (!firstLevel) {
return;
}
onExecuteAction({
action: 'generate_puzzle_ui_background',
levelId: firstLevel.levelId,
promptText: prompt,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: editState.workDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(
editState.levels.map((level, index) =>
index === 0
? {
...level,
uiBackgroundPrompt: prompt,
}
: level,
),
),
});
}}
/>
) : null}
</div>
@@ -1936,8 +2349,14 @@ export function PuzzleResultView({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
canStartTestRun={canStartTestRun}
publishReady={publishState.publishReady}
publishBlockers={publishState.blockers}
onStartTestRun={
onStartTestRun
? () => onStartTestRun(syncedDraft)
: undefined
}
onPublish={() => {
if (!publishState.publishReady) {
return;

View File

@@ -22,7 +22,15 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
const mocapMock = vi.hoisted(() => ({
@@ -623,6 +631,36 @@ test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
expect(screen.queryByText('等待下一关候选')).toBeNull();
});
test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
const runWithUiBackground: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
remainingMs: 300_000,
timeLimitMs: 300_000,
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithUiBackground}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const backgroundImage = container.querySelector(
'img[src="/generated-puzzle-assets/session/ui/background.png"]',
);
expect(backgroundImage).toBeTruthy();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
@@ -1146,7 +1184,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
fireEvent.click(screen.getByRole('button', { name: '提示' }));
expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
await act(async () => {
@@ -1163,7 +1201,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
test('道具使用失败时保留确认弹窗和暂停态', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -1196,7 +1234,7 @@ test('道具使用失败时保留确认弹窗和暂停态', async () => {
});
expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
});
@@ -1348,7 +1386,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
within(failedDialog).getByRole('button', { name: '继续1分钟' }),
);
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
@@ -1358,7 +1396,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
});
test('失败续时扣费失败时保留确认弹窗', async () => {
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -1390,7 +1428,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
});
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
});
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {

View File

@@ -11,7 +11,7 @@ import {
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
@@ -35,6 +35,13 @@ import {
type RuntimeDragInputSession,
type RuntimeInputPoint,
} from '../../services/input-devices';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
playRuntimeCountdownSound,
playRuntimeLevelClearSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useMocapInput } from '../../services/useMocapInput';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -326,6 +333,11 @@ function triggerPuzzlePiecePressHapticFeedback() {
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
}
function triggerPuzzlePiecePressFeedback(volume: number) {
triggerPuzzlePiecePressHapticFeedback();
playRuntimeClickSound(undefined, volume);
}
/**
* 拼图运行时壳层。
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
@@ -378,6 +390,8 @@ export function PuzzleRuntimeShell({
const previousUiPauseActiveRef = useRef(false);
const pauseChangePromiseRef = useRef<Promise<void>>(Promise.resolve());
const timeExpiredSyncKeyRef = useRef<string | null>(null);
const clearSoundKeyRef = useRef<string | null>(null);
const countdownSoundKeyRef = useRef<string | null>(null);
const dragSessionRef = useRef<{
pieceId: string;
inputId: string;
@@ -425,6 +439,7 @@ export function PuzzleRuntimeShell({
const mergeGroupSignatureRef = useRef<string | null>(null);
const hintDemoTimeoutRef = useRef<number | null>(null);
const mergeFlashTimeoutRef = useRef<number | null>(null);
const backgroundAudioRef = useRef<HTMLAudioElement | null>(null);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const currentLevelRef = useRef(currentLevel);
@@ -442,11 +457,32 @@ export function PuzzleRuntimeShell({
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
const runtimeRunId = run?.runId ?? null;
const currentLevelIndex = currentLevel?.levelIndex ?? null;
const currentLevelStartedAtMs = currentLevel?.startedAtMs ?? null;
const currentLevelStatus = currentLevel?.status ?? null;
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
const backgroundMusicSrc = currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const { resolvedUrl: resolvedBackgroundMusicSrc } = useResolvedAssetReadUrl(backgroundMusicSrc);
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
currentLevel?.uiBackgroundImageSrc ?? null,
);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
const primaryMocapHandState = primaryMocapHand?.state;
@@ -472,6 +508,10 @@ export function PuzzleRuntimeShell({
currentLevelRef.current = currentLevel;
}, [currentLevel]);
useEffect(() => {
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
const commitSelectedPieceId = (pieceId: string | null) => {
selectedPieceIdRef.current = pieceId;
setSelectedPieceId(pieceId);
@@ -815,6 +855,41 @@ export function PuzzleRuntimeShell({
run?.runId,
]);
useEffect(() => {
if (
!runtimeRunId ||
currentLevelStatus !== 'playing' ||
currentLevelIndex === null ||
currentLevelStartedAtMs === null
) {
countdownSoundKeyRef.current = null;
return;
}
const secondBucket =
displayRemainingMs <= levelAudioConfig.countdownWarningThresholdMs
? resolveRuntimeCountdownSecondBucket(displayRemainingMs)
: null;
if (secondBucket === null) {
countdownSoundKeyRef.current = null;
return;
}
const soundKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}:${secondBucket}`;
if (countdownSoundKeyRef.current === soundKey) {
return;
}
countdownSoundKeyRef.current = soundKey;
playRuntimeCountdownSound(musicVolume);
}, [
currentLevelIndex,
currentLevelStartedAtMs,
currentLevelStatus,
displayRemainingMs,
levelAudioConfig.countdownWarningThresholdMs,
musicVolume,
runtimeRunId,
]);
useEffect(
() => () => {
if (hintDemoTimeoutRef.current !== null) {
@@ -853,6 +928,10 @@ export function PuzzleRuntimeShell({
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
clearPresentationKeyRef.current = clearResultKey;
if (clearSoundKeyRef.current !== clearResultKey) {
clearSoundKeyRef.current = clearResultKey;
playRuntimeLevelClearSound(musicVolume);
}
clearPresentationTimeouts();
setIsClearFlashVisible(true);
setIsClearResultReady(false);
@@ -864,7 +943,7 @@ export function PuzzleRuntimeShell({
setIsClearResultReady(true);
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
];
}, [clearResultKey, currentLevel, dismissedClearKey]);
}, [clearResultKey, currentLevel, dismissedClearKey, musicVolume]);
const handlePieceTap = (
pieceId: string,
@@ -873,6 +952,7 @@ export function PuzzleRuntimeShell({
if (isInteractionLocked) {
return;
}
tryPlayBackgroundMusic();
if (!selectedPieceIdBeforeInput) {
commitSelectedPieceId(pieceId);
@@ -994,7 +1074,7 @@ export function PuzzleRuntimeShell({
syncRuntimeDragFromController(session);
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
commitSelectedPieceId(session.targetId);
triggerPuzzlePiecePressHapticFeedback();
triggerPuzzlePiecePressFeedback(musicVolume);
},
onDragStart: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
@@ -1181,6 +1261,7 @@ export function PuzzleRuntimeShell({
if (isInteractionLocked) {
return;
}
tryPlayBackgroundMusic();
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);
@@ -1352,16 +1433,38 @@ export function PuzzleRuntimeShell({
<div
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
>
{resolvedBackgroundMusicSrc ? (
<audio
ref={backgroundAudioRef}
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
/>
) : null}
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
{resolvedUiBackgroundImage ? (
<ResolvedAssetImage
src={resolvedUiBackgroundImage}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
{currentLevel.coverImageSrc ? (
<ResolvedAssetImage
src={currentLevel.coverImageSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
className={`absolute inset-0 h-full w-full object-cover blur-2xl ${
resolvedUiBackgroundImage ? 'opacity-[0.06]' : 'opacity-[0.16]'
}`}
/>
) : null}
<div className="puzzle-runtime-stage__grid" />
<div
className={`puzzle-runtime-stage__grid ${
resolvedUiBackgroundImage ? 'opacity-20' : ''
}`}
/>
<div className="absolute left-0 top-0 z-20 w-full px-3 py-3 sm:px-4">
<div className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] sm:gap-3">
@@ -1904,7 +2007,7 @@ export function PuzzleRuntimeShell({
</h2>
</header>
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
1
1
{propConfirmError ? (
<div className="puzzle-runtime-error-chip mt-3 rounded-[0.9rem] border px-3 py-2 text-xs leading-5">
{propConfirmError}

View File

@@ -219,7 +219,7 @@ export function RpgCreationRoleAnimationSection(props: {
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}`}
subLabel={`消耗${animationPointCost}`}
onClick={onGenerateAnimation}
disabled={
isSelectedAnimationGenerating ||

View File

@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 点。\n${params.description}`,
`${params.kindLabel}预计消耗 ${params.points} 点。\n${params.description}`,
);
};

View File

@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
? '重新生成角色形象'
: '生成角色形象'
}
subLabel={`消耗${visualPointCost}`}
subLabel={`消耗${visualPointCost}`}
onClick={onGenerateVisuals}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"

View File

@@ -16,9 +16,9 @@ export function RpgEntryBrandLogo({
className={`platform-brand-logo ${className}`.trim()}
role={decorative ? undefined : 'img'}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : '百梦 GENARRATIVE'}
aria-label={decorative ? undefined : '陶泥儿 GENARRATIVE'}
>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
);

View File

@@ -17,24 +17,31 @@ import type {
PublicUserSummary,
} from '../../../packages/shared/src/contracts/auth';
import type {
CreateProfileRechargeOrderResponse,
ProfileReferralInviteCenterResponse,
ProfileTaskCenterResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { AuthUiContext } from '../auth/AuthUiContext';
import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments';
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 {
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
mockCreateRpgProfileRechargeOrder,
mockGetRpgProfileReferralInviteCenter,
mockGetRpgProfileRechargeCenter,
mockGetRpgProfileTasks,
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
@@ -130,6 +137,88 @@ const {
},
center: buildClaimedTaskCenter(),
})),
mockGetRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [
{
productId: 'points_60',
title: '60泥点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60泥点',
tier: 'normal',
},
],
membershipProducts: [
{
productId: 'member_month',
title: '月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 30,
badgeLabel: '',
description: '30天会员',
tier: 'month',
},
],
benefits: [
{
benefitName: '免泥点回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
yearValue: '100',
},
],
latestOrder: null,
hasPointsRecharged: false,
})),
mockCreateRpgProfileRechargeOrder: vi.fn(
async (): Promise<CreateProfileRechargeOrderResponse> => ({
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'mock',
paidAt: '2026-04-25T10:00:00Z',
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
}),
),
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
center: buildReferralCenter({
invitedUsers: [],
@@ -212,85 +301,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [
{
productId: 'points_60',
title: '60光点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60光点',
tier: 'normal',
},
],
membershipProducts: [
{
productId: 'member_month',
title: '月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 30,
badgeLabel: '',
description: '30天会员',
tier: 'month',
},
],
benefits: [
{
benefitName: '免光点回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
yearValue: '100',
},
],
latestOrder: null,
hasPointsRecharged: false,
})),
createRpgProfileRechargeOrder: vi.fn(async () => ({
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60光点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'mock',
paidAt: '2026-04-25T10:00:00Z',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
})),
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
}));
vi.mock('../ResolvedAssetImage', () => ({
@@ -323,6 +335,38 @@ function dispatchPointerEvent(
target.dispatchEvent(event);
}
function stubImage(width = 800, height = 600) {
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = width;
naturalHeight = height;
width = width;
height = height;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
}
function stubFileReader(dataUrl: string) {
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = dataUrl;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
@@ -445,6 +489,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,
@@ -783,6 +858,7 @@ afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(),
);
@@ -858,9 +934,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('点账单')).toBeTruthy();
expect(await screen.findByText('点账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
expect(screen.getByText('资产操作消耗')).toBeTruthy();
expect(screen.getByText('-1')).toBeTruthy();
@@ -868,6 +944,106 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy();
});
test('profile recharge modal buys points through mock channel outside mini program', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'mock',
);
});
expect(await screen.findByText('已到账')).toBeTruthy();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
const navigateTo = vi.fn((options: { url: string }) => {
const url = new URL(`https://mini.test${options.url}`);
const requestId = url.searchParams.get('requestId');
window.location.hash = `wx_pay_result=${requestId}:success`;
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
window.wx = {
miniProgram: {
navigateTo,
},
};
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-wechat-1',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_mp',
paidAt: null as string | null,
providerTransactionId: null,
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatMiniProgramPayParams: {
timeStamp: '1777110165',
nonceStr: 'nonce',
package: 'prepay_id=wx-prepay',
signType: 'RSA',
paySign: 'signature',
},
});
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
'points_60',
'wechat_mp',
);
});
expect(navigateTo).toHaveBeenCalledWith({
url: expect.stringContaining('/pages/wechat-pay/index?'),
fail: expect.any(Function),
});
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
expect(navigateUrl).toContain('order-wechat-1');
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
expect(await screen.findByText('支付已提交')).toBeTruthy();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
@@ -886,7 +1062,7 @@ test('profile daily task shortcut opens task center and claims reward', async ()
expect(mockClaimRpgProfileTaskReward).toHaveBeenCalledWith('daily_login');
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已领取 10 点')).toBeTruthy();
expect(await screen.findByText('已领取 10 点')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
.disabled,
@@ -930,17 +1106,42 @@ test('desktop account entry uses saved avatar image when available', () => {
expect(within(accountEntry).queryByText('测')).toBeNull();
});
test('profile avatar upload uses the shared square crop tool', async () => {
stubFileReader('data:image/png;base64,avatar-source');
stubImage(800, 600);
renderProfileView();
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
fireEvent.change(screen.getByLabelText('上传头像', { selector: 'input' }), {
target: {
files: [new File(['x'], 'avatar.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '裁剪头像' })).toBeTruthy();
});
expect(screen.getByLabelText('头像裁剪操作区')).toBeTruthy();
expect(
screen.getByRole('button', { name: '拖拽右下角裁剪边界' }),
).toBeTruthy();
expect(screen.queryByText('缩放')).toBeNull();
expect(screen.queryByText('横向')).toBeNull();
expect(screen.queryByText('纵向')).toBeNull();
});
test('wallet ledger modal shows empty and error states', async () => {
const user = userEvent.setup();
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭点账单'));
await user.click(screen.getByLabelText('关闭点账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
@@ -961,7 +1162,7 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('邀请一个用户注册双方都可以获得30点。'),
await screen.findByText('邀请一个用户注册双方都可以获得30点。'),
).toBeTruthy();
expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
expect(screen.getByText('成功邀请')).toBeTruthy();
@@ -1089,11 +1290,57 @@ test('opens reward code modal from profile action on mobile', async () => {
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
});
test('shows a reachable login entry in logged out mobile shell', async () => {
test('profile page shows legal entries and ICP record link', async () => {
const user = userEvent.setup();
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
).toBe(true);
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const legalRegion = screen.getByRole('region', { name: '法律信息' });
expect(
within(legalRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(legalRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(legalRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const recordLink = within(legalRegion).getByRole('link', {
name: ICP_RECORD_NUMBER,
});
expect(recordLink.getAttribute('href')).toBe(ICP_RECORD_URL);
expect(recordLink.getAttribute('target')).toBe('_blank');
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
await user.click(
within(legalRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
});
test('shows a reachable login entry outside mobile recommend tab', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(openLoginModal);
renderLoggedOutHomeView(openLoginModal, {}, 'category');
await user.click(screen.getByRole('button', { name: '登录' }));
expect(openLoginModal).toHaveBeenCalledTimes(1);
@@ -1312,6 +1559,50 @@ 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();
@@ -1360,6 +1651,10 @@ test('logged out mobile shell defaults to discover tab', () => {
expect(
screen.getByPlaceholderText('搜索作品号、名称、作者、描述'),
).toBeTruthy();
expect(container.querySelector('.platform-mobile-topbar')).toBeTruthy();
expect(
container.querySelector('.platform-mobile-entry-shell--recommend'),
).toBeNull();
});
test('logged out recommend tab opens login modal and shows cover only', async () => {
@@ -1381,6 +1676,10 @@ test('logged out recommend tab opens login modal and shows cover only', async ()
expect(
container.querySelector('.platform-recommend-cover-only'),
).toBeTruthy();
expect(container.querySelector('.platform-mobile-topbar')).toBeNull();
expect(
container.querySelector('.platform-mobile-entry-shell--recommend'),
).toBeTruthy();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
@@ -1647,6 +1946,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
});
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy();
const activeRecommendCard = within(meta);
const likeButton = activeRecommendCard.getByRole('button', {
name: '点赞 12',

File diff suppressed because it is too large Load Diff

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';
@@ -36,10 +41,9 @@ test('platform work display text limits names and tags by character count', () =
expect(formatPlatformWorkDisplayName('热门高分拼图超长标题')).toBe(
'热门高分拼图超长',
);
expect(formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签'])).toEqual([
'超长机关',
'星桥',
]);
expect(
formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签']),
).toEqual(['超长机关', '星桥']);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
@@ -132,3 +136,74 @@ 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: '香蕉',
},
],
visualPackage: null,
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';
@@ -108,6 +114,9 @@ export type PlatformMatch3DGalleryCard = {
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
backgroundPrompt?: string | null;
backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
};
@@ -161,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,
@@ -205,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 {
@@ -255,6 +295,9 @@ export function mapMatch3DWorkToPlatformGalleryCard(
visibility: 'published',
publishedAt: work.publishedAt ?? null,
updatedAt: work.updatedAt,
backgroundPrompt: work.backgroundPrompt ?? null,
backgroundImageSrc: work.backgroundImageSrc ?? null,
backgroundImageObjectKey: work.backgroundImageObjectKey ?? null,
generatedItemAssets: work.generatedItemAssets ?? [],
};
}
@@ -280,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,
@@ -343,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,
@@ -482,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);
}
@@ -506,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)) {
@@ -521,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),
@@ -607,6 +687,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}

View File

@@ -63,7 +63,7 @@ test('visual novel workspace only exposes one-line input and visual style entry'
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/dark-gothic.png');
expect(screen.getByText('消耗20点')).toBeTruthy();
expect(screen.getByText('消耗20点')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
expect(screen.queryByRole('button', { name: '空白' })).toBeNull();

View File

@@ -340,7 +340,7 @@ export function VisualNovelAgentWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
20
</span>
</span>
</button>

View File

@@ -0,0 +1,100 @@
import { describe, expect, test } from 'vitest';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
type VisualNovelEntryFormPayload,
} from './visualNovelEntryGeneration';
function createVisualNovelPayload(
overrides: Partial<VisualNovelEntryFormPayload> = {},
): VisualNovelEntryFormPayload {
return {
sourceMode: 'idea',
seedText:
'雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风映画动画\n画风要求电影感动画视觉小说画风。',
sourceAssetIds: [],
ideaText: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
visualStyleId: 'cinematic-anime',
visualStyleLabel: '映画动画',
visualStylePrompt: '电影感动画视觉小说画风。',
...overrides,
};
}
describe('visualNovelEntryGeneration', () => {
test('one-line visual novel generation exposes reference-flow stages', () => {
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'generating',
1_500,
);
expect(progress.steps.map((step) => step.id)).toEqual([
'visual-novel-intent',
'visual-novel-world',
'visual-novel-cast-scenes',
'visual-novel-opening',
'visual-novel-ready',
]);
expect(progress.phaseLabel).toBe('理解一句话创意');
expect(progress.steps[0]?.detail).toBe(
'提取核心题材、视觉画风、玩家身份和互动叙事目标。',
);
expect(progress.estimatedRemainingMs).toBe(44_500);
expect(progress.overallProgress).toBeGreaterThan(0);
});
test('one-line visual novel generation advances to opening choices before ready', () => {
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'generating',
35_000,
);
expect(progress.phaseId).toBe('visual-novel-opening');
expect(progress.phaseLabel).toBe('生成开场与选择');
expect(progress.steps[2]?.status).toBe('completed');
expect(progress.steps[3]?.status).toBe('active');
expect(progress.overallProgress).toBeLessThan(99);
});
test('one-line visual novel generation ready copy points to editable draft', () => {
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'ready',
46_000,
);
expect(progress.phaseId).toBe('ready');
expect(progress.phaseLabel).toBe('生成完成');
expect(progress.phaseDetail).toBe(
'视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。',
);
expect(progress.overallProgress).toBe(100);
});
test('one-line visual novel generation anchors include source, style and target', () => {
const entries = buildVisualNovelEntryGenerationAnchorEntries(
createVisualNovelPayload(),
);
expect(entries).toEqual([
{
id: 'visual-novel-idea',
label: '一句话',
value: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: '映画动画',
},
{
id: 'visual-novel-target',
label: '生成目标',
value: '可编辑并可试玩的视觉小说草稿',
},
]);
});
});

View File

@@ -41,6 +41,11 @@ export function buildVisualNovelEntryGenerationAnchorEntries(
label: '视觉画风',
value: payload.visualStyleLabel,
},
{
id: 'visual-novel-target',
label: '生成目标',
value: '可编辑并可试玩的视觉小说草稿',
},
].filter((entry) => entry.value.trim());
}
@@ -72,27 +77,55 @@ export function buildVisualNovelEntryGenerationProgress(
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-session',
label: '创建创作会话',
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
weight: 24,
durationMs: 5_000,
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-intent',
label: '理解一句话创意',
detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。',
weight: 16,
durationMs: 6_000,
},
{
id: 'visual-novel-world',
label: '扩展世界观',
detail: '生成世界背景、故事前提、文学风格和玩家角色。',
weight: 22,
durationMs: 10_000,
},
{
id: 'visual-novel-cast-scenes',
label: '设计角色与场景',
detail: '补齐主要角色、可生成立绘的外观描述和 opening 场景。',
weight: 28,
durationMs: 16_000,
},
{
id: 'visual-novel-opening',
label: '生成开场与选择',
detail: '写入开场旁白、首句对白、剧情阶段和 2 到 4 个初始选择。',
weight: 24,
durationMs: 10_000,
},
{
id: 'visual-novel-ready',
label: '准备草稿页',
detail: '校验可编辑字段并进入草稿页。',
weight: 20,
durationMs: 4_000,
detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。',
weight: 10,
durationMs: 3_000,
},
];
let elapsedBeforeStep = 0;
@@ -130,9 +163,13 @@ export function buildVisualNovelEntryGenerationProgress(
: phase === 'failed'
? Math.max(1, completedWeight)
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
const estimatedTotalMs = timeline.reduce(
(sum, step) => sum + step.durationMs,
0,
);
return {
phaseId: phase,
phaseId: phase === 'generating' ? activeStep.id : phase,
phaseLabel:
phase === 'ready'
? '生成完成'
@@ -141,7 +178,7 @@ export function buildVisualNovelEntryGenerationProgress(
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成。'
? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
@@ -151,7 +188,7 @@ export function buildVisualNovelEntryGenerationProgress(
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =

View File

@@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView';
vi.mock('../../services/visual-novel-creation', () => ({
createVisualNovelBackgroundMusicTask: vi.fn(),
createVisualNovelSoundEffectTask: vi.fn(),
generateVisualNovelImageAsset: vi.fn(),
buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'),
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
publishVisualNovelBackgroundMusicAsset: vi.fn(),
publishVisualNovelSoundEffectAsset: vi.fn(),
@@ -134,3 +136,58 @@ test('visual novel result uploads scene and character assets into platform refer
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
).toContain('/generated-custom-world-scenes/');
});
test('visual novel result generates scene background from asset picker', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const visualNovelCreation = await import('../../services/visual-novel-creation');
const generateImageMock = vi.mocked(
visualNovelCreation.generateVisualNovelImageAsset,
);
generateImageMock.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-ai.webp',
assetId: 'asset-scene-ai',
model: 'test-image-model',
size: '1280*720',
taskId: 'task-scene-ai',
prompt: '默认图片提示词',
});
render(
<VisualNovelResultView
draft={mockVisualNovelDraft}
onBack={() => {}}
onSaveDraft={onSaveDraft}
/>,
);
await user.click(screen.getByRole('button', { name: '场景' }));
await user.click(screen.getByRole('button', { name: //u }));
const editorDialog = screen.getByRole('dialog', { name: '风雪站台' });
await user.click(
within(editorDialog).getAllByRole('button', { name: '背景图' })[0]!,
);
await user.click(
within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', {
name: 'AI生成',
}),
);
await user.click(within(editorDialog).getByRole('button', { name: '关闭' }));
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
expect(generateImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'scene_background',
scene: expect.objectContaining({
sceneId: mockVisualNovelDraft.scenes[0]?.sceneId,
}),
prompt: '默认图片提示词',
}),
);
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/vn-profile/scene-ai.webp',
);
});

View File

@@ -4,16 +4,16 @@ import {
ImagePlus,
Images,
Loader2,
type LucideIcon,
Music,
Save,
PenLine,
Play,
Save,
Settings,
Sparkles,
Upload,
Waves,
X,
type LucideIcon,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -27,9 +27,12 @@ import type {
VisualNovelStoryPhaseDraft,
VisualNovelValidationIssue,
} from '../../../packages/shared/src/contracts/visualNovel';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import {
buildVisualNovelImageGenerationPrompt,
createVisualNovelBackgroundMusicTask,
createVisualNovelSoundEffectTask,
generateVisualNovelImageAsset,
listVisualNovelHistoryAssets,
publishVisualNovelBackgroundMusicAsset,
publishVisualNovelSoundEffectAsset,
@@ -38,7 +41,6 @@ import {
type VisualNovelHistoryAssetKind,
type VisualNovelUploadAssetKind,
} from '../../services/visual-novel-creation';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
@@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = {
profileId?: string | null;
entityId?: string | null;
previewTone: 'image' | 'audio';
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
};
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
type VisualNovelImageGeneratorKind =
| 'cover'
| 'scene_background'
| 'character_standee';
type VisualNovelImageGeneratorConfig = {
kind: VisualNovelImageGeneratorKind;
draft: VisualNovelResultDraft;
scene?: VisualNovelSceneDraft | null;
character?: VisualNovelCharacterDraft | null;
};
type VisualNovelAudioGeneratorConfig = {
kind: VisualNovelAudioGeneratorKind;
scene: VisualNovelSceneDraft;
@@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({
Boolean(config.historyKind),
);
const [isUploading, setIsUploading] = useState(false);
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({
};
}, [config.historyKind]);
const handleGenerateImage = async () => {
if (!config.imageGeneratorConfig || config.previewTone !== 'image') {
return;
}
setIsGeneratingImage(true);
setError(null);
try {
const result = await generateVisualNovelImageAsset({
...config.imageGeneratorConfig,
prompt: buildVisualNovelImageGenerationPrompt(config.imageGeneratorConfig),
});
onSelect({
assetObjectId: result.assetId || result.taskId,
assetKind:
config.uploadKind === 'character_standee'
? 'character_visual'
: config.uploadKind === 'cover'
? 'visual_novel_cover_image'
: 'scene_image',
objectKey: '',
imageSrc: result.imageSrc,
profileId: config.profileId ?? null,
entityId: config.entityId ?? null,
});
} catch (generationError) {
setError(
generationError instanceof Error
? generationError.message
: 'AI 图片生成失败。',
);
} finally {
setIsGeneratingImage(false);
}
};
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
@@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
<div className="mb-4 flex flex-wrap gap-2">
<button
type="button"
disabled={disabled || isUploading}
disabled={disabled || isUploading || isGeneratingImage}
onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
>
@@ -523,11 +575,28 @@ function VisualNovelAssetPickerDialog({
)}
</button>
{config.imageGeneratorConfig && config.previewTone === 'image' ? (
<button
type="button"
disabled={disabled || isUploading || isGeneratingImage}
onClick={() => {
void handleGenerateImage();
}}
className="platform-button platform-button--primary min-h-10 px-4 py-2 text-sm"
>
{isGeneratingImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
AI生成
</button>
) : null}
<input
ref={fileInputRef}
type="file"
accept={config.accept}
disabled={disabled || isUploading}
disabled={disabled || isUploading || isGeneratingImage}
onChange={(event) => {
void handleUpload(event);
}}
@@ -609,6 +678,7 @@ function VisualNovelAssetField({
entityId,
historyKind,
icon: Icon,
imageGeneratorConfig,
label,
onSelect,
previewTone,
@@ -621,6 +691,7 @@ function VisualNovelAssetField({
entityId?: string | null;
historyKind?: VisualNovelHistoryAssetKind;
icon: LucideIcon;
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
label: string;
onSelect: (asset: VisualNovelAssetReference) => void;
previewTone: 'image' | 'audio';
@@ -710,6 +781,7 @@ function VisualNovelAssetField({
profileId,
entityId,
previewTone,
imageGeneratorConfig,
}}
disabled={disabled}
onClose={() => setIsPickerOpen(false)}
@@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({
accept="image/png,image/jpeg,image/webp"
profileId={draft.profileId}
previewTone="image"
imageGeneratorConfig={{ kind: 'cover', draft }}
onSelect={(asset) =>
onChange({ ...draft, coverImageSrc: asset.imageSrc })
}
@@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({
function VisualNovelCharacterEditor({
item,
disabled,
draft,
onChange,
}: {
item: VisualNovelCharacterDraft;
disabled: boolean;
draft: VisualNovelResultDraft;
onChange: (item: VisualNovelCharacterDraft) => void;
}) {
return (
@@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({
profileId={null}
entityId={item.characterId}
previewTone="image"
imageGeneratorConfig={{
kind: 'character_standee',
draft,
character: item,
}}
onSelect={(asset) =>
onChange({
...item,
@@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({
item,
disabled,
profileId,
draft,
onChange,
}: {
item: VisualNovelSceneDraft;
disabled: boolean;
profileId?: string | null;
draft: VisualNovelResultDraft;
onChange: (item: VisualNovelSceneDraft) => void;
}) {
return (
@@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({
profileId={profileId ?? null}
entityId={item.sceneId}
previewTone="image"
imageGeneratorConfig={{
kind: 'scene_background',
draft,
scene: item,
}}
onSelect={(asset) =>
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
}
@@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({
<VisualNovelCharacterEditor
item={target.item}
disabled={disabled}
draft={draft}
onChange={updateCharacter}
/>
) : null}
@@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({
item={target.item}
disabled={disabled}
profileId={draft.profileId}
draft={draft}
onChange={updateScene}
/>
) : null}