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:
@@ -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]">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)]">
|
||||
暂无可展示的登录设备。
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
124
src/components/common/LegalDocumentModal.tsx
Normal file
124
src/components/common/LegalDocumentModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
444
src/components/common/SquareImageCropModal.tsx
Normal file
444
src/components/common/SquareImageCropModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/components/common/legalDocuments.ts
Normal file
157
src/components/common/legalDocuments.ts
Normal 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');
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export function CreativeAgentHome({
|
||||
<CreativeAgentInputComposer
|
||||
variant="floating"
|
||||
isBusy={isBusy}
|
||||
placeholder="问一问百梦"
|
||||
placeholder="问一问陶泥儿"
|
||||
onSubmit={(payload) => {
|
||||
const content = buildCreativeHomeInputParts(payload);
|
||||
if (content.length === 0) {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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('最后修改');
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { BabyObjectMatchWorkspace } from './BabyObjectMatchWorkspace';
|
||||
|
||||
test('baby object match workspace requires two item names before submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreateDraft = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchWorkspace onBack={() => {}} onCreateDraft={onCreateDraft} />,
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /生成宝贝识物草稿/u,
|
||||
});
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('物品 A'), '苹果');
|
||||
expect(submitButton).toHaveProperty('disabled', true);
|
||||
|
||||
await user.type(screen.getByLabelText('物品 B'), '香蕉');
|
||||
expect(submitButton).toHaveProperty('disabled', false);
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(onCreateDraft).toHaveBeenCalledWith({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
});
|
||||
|
||||
test('baby object match workspace calls back when return button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onBack = vi.fn();
|
||||
|
||||
render(
|
||||
<BabyObjectMatchWorkspace onBack={onBack} onCreateDraft={() => {}} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
187
src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx
Normal file
187
src/components/edutainment-creation/BabyObjectMatchWorkspace.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { ArrowLeft, Gift, Loader2, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { CreateBabyObjectMatchDraftRequest } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { validateBabyObjectMatchItemNames } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
|
||||
type BabyObjectMatchWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
initialPayload?: CreateBabyObjectMatchDraftRequest | null;
|
||||
onBack: () => void;
|
||||
onCreateDraft: (payload: CreateBabyObjectMatchDraftRequest) => void;
|
||||
showBackButton?: boolean;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
type BabyObjectMatchFormState = {
|
||||
itemAName: string;
|
||||
itemBName: string;
|
||||
};
|
||||
|
||||
function resolveInitialFormState(
|
||||
initialPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
|
||||
): BabyObjectMatchFormState {
|
||||
return {
|
||||
itemAName: initialPayload?.itemAName ?? '',
|
||||
itemBName: initialPayload?.itemBName ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function BabyObjectMatchWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
initialPayload = null,
|
||||
onBack,
|
||||
onCreateDraft,
|
||||
showBackButton = true,
|
||||
title = null,
|
||||
}: BabyObjectMatchWorkspaceProps) {
|
||||
const [formState, setFormState] = useState<BabyObjectMatchFormState>(() =>
|
||||
resolveInitialFormState(initialPayload),
|
||||
);
|
||||
const appliedInitialKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextInitialKey = JSON.stringify(initialPayload ?? null);
|
||||
if (appliedInitialKeyRef.current === nextInitialKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
appliedInitialKeyRef.current = nextInitialKey;
|
||||
setFormState(resolveInitialFormState(initialPayload));
|
||||
}, [initialPayload]);
|
||||
|
||||
const validation = useMemo(
|
||||
() => validateBabyObjectMatchItemNames(formState),
|
||||
[formState],
|
||||
);
|
||||
const canSubmit = validation.valid && !isBusy;
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
onCreateDraft({
|
||||
itemAName: validation.itemAName,
|
||||
itemBName: validation.itemBName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
|
||||
{showBackButton ? (
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{title ? (
|
||||
<div className="mb-3 shrink-0 sm:mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={`grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.56fr)] ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<div className="grid min-h-0 gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
物品 A
|
||||
</span>
|
||||
<input
|
||||
value={formState.itemAName}
|
||||
disabled={isBusy}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
itemAName: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
|
||||
aria-label="物品 A"
|
||||
/>
|
||||
</label>
|
||||
<label className="block min-h-0">
|
||||
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
|
||||
物品 B
|
||||
</span>
|
||||
<input
|
||||
value={formState.itemBName}
|
||||
disabled={isBusy}
|
||||
placeholder=""
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
itemBName: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
|
||||
aria-label="物品 B"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[8rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(145deg,rgba(236,253,245,0.92),rgba(255,247,237,0.86))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
|
||||
<div className="absolute -right-8 -top-8 h-28 w-28 rounded-full bg-emerald-200/42" />
|
||||
<div className="absolute -bottom-10 left-6 h-24 w-24 rounded-full bg-amber-200/48" />
|
||||
<div className="relative flex h-full min-h-[7rem] flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="grid h-14 w-14 place-items-center rounded-[1.1rem] bg-white/82 text-emerald-600 shadow-[0_12px_30px_rgba(16,185,129,0.14)]">
|
||||
<Gift className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
宝贝识物
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={submitForm}
|
||||
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
<span>生成宝贝识物草稿</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BabyObjectMatchWorkspace;
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
219
src/components/edutainment-result/BabyObjectMatchResultView.tsx
Normal file
219
src/components/edutainment-result/BabyObjectMatchResultView.tsx
Normal 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;
|
||||
@@ -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%');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
135
src/components/edutainment-runtime/babyLoveDrawingModel.ts
Normal file
135
src/components/edutainment-runtime/babyLoveDrawingModel.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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()];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
15
src/components/match3d-runtime/match3dRuntimeUiStyles.ts
Normal file
15
src/components/match3d-runtime/match3dRuntimeUiStyles.ts
Normal 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';
|
||||
@@ -21,6 +21,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onSelectPuzzle: () => void;
|
||||
onSelectCreativeAgent: () => void;
|
||||
onSelectVisualNovel: () => void;
|
||||
onSelectBabyObjectMatch: () => void;
|
||||
}
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
@@ -101,6 +102,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
onSelectPuzzle,
|
||||
onSelectCreativeAgent,
|
||||
onSelectVisualNovel,
|
||||
onSelectBabyObjectMatch,
|
||||
}: PlatformEntryCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({
|
||||
if (item.id === 'visual-novel') {
|
||||
onSelectVisualNovel();
|
||||
}
|
||||
if (item.id === 'baby-object-match') {
|
||||
onSelectBabyObjectMatch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -52,6 +57,31 @@ function createPuzzleEntry(): PlatformPuzzleGalleryCard {
|
||||
};
|
||||
}
|
||||
|
||||
function createBabyObjectMatchEntry(): PlatformEdutainmentGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '陶泥儿主',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['寓教于乐'],
|
||||
playCount: 12,
|
||||
remixCount: 0,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T12:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -140,6 +170,23 @@ test('PlatformWorkDetailView switches remix action label for owned work edit', (
|
||||
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView labels baby object match works', () => {
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
entry={createBabyObjectMatchEntry()}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={vi.fn()}
|
||||
onLike={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onRemix={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('宝贝识物')).toBeTruthy();
|
||||
expect(screen.getByText('EDU-BABY01')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
isEdutainmentGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
@@ -66,6 +67,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
|
||||
return '视觉小说';
|
||||
}
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.templateName;
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
filterEdutainmentPublicWorks,
|
||||
@@ -28,6 +32,27 @@ function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard {
|
||||
};
|
||||
}
|
||||
|
||||
function buildBabyObjectMatchCard(themeTags: string[]): PlatformPublicGalleryCard {
|
||||
return {
|
||||
sourceType: 'edutainment',
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
workId: 'baby-object-match-work-1',
|
||||
profileId: 'baby-object-match-profile-1',
|
||||
publicWorkCode: 'EDU-BABY01',
|
||||
ownerUserId: 'user-education',
|
||||
authorDisplayName: '动作 Demo 作者',
|
||||
worldName: '宝贝识物水果篮',
|
||||
subtitle: '宝贝识物',
|
||||
summaryText: '将物品放入对应的篮子里。',
|
||||
coverImageSrc: null,
|
||||
themeTags,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-11T10:00:00.000Z',
|
||||
updatedAt: '2026-05-11T10:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
@@ -56,4 +81,14 @@ describe('platformEdutainmentVisibility', () => {
|
||||
expect(canExposePublicWork(exact)).toBe(false);
|
||||
expect(canExposePublicWork(general)).toBe(true);
|
||||
});
|
||||
|
||||
test('applies the same exact tag rule to baby object match cards', () => {
|
||||
const exact = buildBabyObjectMatchCard(['寓教于乐', '宝贝识物']);
|
||||
const fuzzy = buildBabyObjectMatchCard(['寓教于乐 ', '宝贝识物']);
|
||||
|
||||
expect(isEdutainmentPublicWork(exact)).toBe(true);
|
||||
expect(isEdutainmentPublicWork(fuzzy)).toBe(false);
|
||||
expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]);
|
||||
expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,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']);
|
||||
});
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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: '确定' },
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
}
|
||||
|
||||
return window.confirm(
|
||||
`${params.kindLabel}预计消耗 ${params.points} 光点。\n${params.description}`,
|
||||
`${params.kindLabel}预计消耗 ${params.points} 泥点。\n${params.description}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
|
||||
? '重新生成角色形象'
|
||||
: '生成角色形象'
|
||||
}
|
||||
subLabel={`消耗${visualPointCost}光点`}
|
||||
subLabel={`消耗${visualPointCost}泥点`}
|
||||
onClick={onGenerateVisuals}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
tone="sky"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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('寓教于乐');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '可编辑并可试玩的视觉小说草稿',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user