1
This commit is contained in:
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
@@ -95,6 +96,7 @@ const mockUser: AuthUser = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
@@ -141,6 +143,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,
|
||||
}: {
|
||||
@@ -346,6 +357,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 +372,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 +463,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 +502,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 +553,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,6 +779,7 @@ 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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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');
|
||||
}
|
||||
@@ -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,8 +72,9 @@ 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.getByRole('button', { name: '自定义' })).toBeTruthy();
|
||||
expect(screen.getByText('消耗20光点')).toBeTruthy();
|
||||
expect(screen.queryByText('参考图')).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,32 @@ test('match3d workspace supports custom 3d asset style prompt', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace can enable click sound generation from entry toggle', () => {
|
||||
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({
|
||||
themeText: '海岛甜品',
|
||||
generateClickSound: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -157,14 +187,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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
|
||||
import { Loader2, Music2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -26,13 +26,15 @@ type Match3DFormState = {
|
||||
difficultyOptionId: Match3DDifficultyOptionId;
|
||||
assetStyleId: Match3DAssetStyleOptionId;
|
||||
customAssetStylePrompt: string;
|
||||
generateClickSound: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: Match3DFormState = {
|
||||
themeText: '',
|
||||
difficultyOptionId: 'standard',
|
||||
assetStyleId: 'clay-toy',
|
||||
assetStyleId: 'flat-icon',
|
||||
customAssetStylePrompt: '',
|
||||
generateClickSound: false,
|
||||
};
|
||||
|
||||
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
|
||||
@@ -40,7 +42,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 +50,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 游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,不使用真实 3D 渲染。',
|
||||
},
|
||||
{
|
||||
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 +157,7 @@ function resolveAssetStyleOptionId(
|
||||
return matchedOption.id;
|
||||
}
|
||||
|
||||
return assetStylePrompt?.trim() ? 'custom' : 'clay-toy';
|
||||
return assetStylePrompt?.trim() ? 'custom' : 'flat-icon';
|
||||
}
|
||||
|
||||
function resolveInitialFormState(
|
||||
@@ -164,21 +172,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,
|
||||
@@ -186,6 +186,10 @@ function resolveInitialFormState(
|
||||
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
|
||||
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
|
||||
customAssetStylePrompt: assetStylePrompt,
|
||||
generateClickSound:
|
||||
initialFormPayload?.generateClickSound ??
|
||||
config?.generateClickSound ??
|
||||
false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,11 +259,13 @@ export function Match3DAgentWorkspace({
|
||||
assetStyleId: formState.assetStyleId,
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
generateClickSound: formState.generateClickSound,
|
||||
}),
|
||||
[
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
formState.assetStyleId,
|
||||
formState.generateClickSound,
|
||||
selectedDifficultyOption,
|
||||
themeText,
|
||||
],
|
||||
@@ -290,7 +296,10 @@ export function Match3DAgentWorkspace({
|
||||
}
|
||||
|
||||
if (session) {
|
||||
onExecuteAction({ action: 'match3d_compile_draft' });
|
||||
onExecuteAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: formState.generateClickSound,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -350,11 +359,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 +433,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}
|
||||
@@ -450,6 +458,43 @@ export function Match3DAgentWorkspace({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
generateClickSound: !current.generateClickSound,
|
||||
}))
|
||||
}
|
||||
className={`flex min-h-12 shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border px-3 py-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 ${
|
||||
formState.generateClickSound
|
||||
? 'border-rose-200 bg-rose-50/80 text-rose-700 shadow-[0_8px_18px_rgba(244,63,94,0.10)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/58 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white/86'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={formState.generateClickSound}
|
||||
aria-label="生成音效"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
<Music2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm font-black">生成音效</span>
|
||||
</span>
|
||||
<span
|
||||
className={`relative h-6 w-11 shrink-0 rounded-full transition ${
|
||||
formState.generateClickSound
|
||||
? 'bg-rose-400'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition ${
|
||||
formState.generateClickSound ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -511,10 +556,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 {
|
||||
@@ -79,7 +80,10 @@ afterEach(() => {
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
});
|
||||
|
||||
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 +96,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
@@ -102,6 +107,7 @@ function renderRuntime(run: Match3DRunSnapshot) {
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
@@ -122,7 +128,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 +165,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 +188,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 +302,81 @@ 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('本地试玩按难度档位生成类型并兼容历史硬核消除数', () => {
|
||||
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 +392,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 +423,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 +438,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 +484,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 +501,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,20 @@ 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,
|
||||
} 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 +47,11 @@ import {
|
||||
Match3DVisualIcon,
|
||||
resolveVisualSeed,
|
||||
} from './match3dVisualAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
|
||||
type Match3DRuntimeShellProps = {
|
||||
run: Match3DRunSnapshot | null;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
backgroundImageSrc?: string | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean;
|
||||
@@ -85,7 +96,6 @@ function resolveTrayPreviewItem(
|
||||
};
|
||||
}
|
||||
|
||||
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
|
||||
const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
|
||||
|
||||
function formatTimer(value: number) {
|
||||
@@ -133,6 +143,103 @@ 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 buildMatch3DImageSourcesByType(
|
||||
run: Match3DRunSnapshot | null,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
if (!run) {
|
||||
return new Map<string, string[]>();
|
||||
}
|
||||
const typeIds = resolveMatch3DGeneratedTypeIds(run);
|
||||
const readyAssets = generatedItemAssets
|
||||
.map((asset) => getMatch3DGeneratedImageViewSources(asset))
|
||||
.filter((sources) => sources.length > 0);
|
||||
|
||||
return new Map(
|
||||
typeIds.flatMap((typeId, index) => {
|
||||
const sources = readyAssets[index];
|
||||
return sources ? [[typeId, 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 +274,12 @@ function buildOptimisticRun(
|
||||
|
||||
function Match3DToken({
|
||||
item,
|
||||
imageSrc,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
item: Match3DItemSnapshot;
|
||||
imageSrc?: string;
|
||||
disabled: boolean;
|
||||
onClick: (item: Match3DItemSnapshot) => void;
|
||||
}) {
|
||||
@@ -208,17 +317,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 +346,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 +433,7 @@ function Match3DSettlement({
|
||||
export function Match3DRuntimeShell({
|
||||
run,
|
||||
generatedItemAssets = [],
|
||||
backgroundImageSrc = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
embedded = false,
|
||||
@@ -317,22 +446,16 @@ 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;
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeftMs(run?.remainingMs ?? 0);
|
||||
@@ -362,18 +485,87 @@ 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() ||
|
||||
generatedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.imageSrc?.trim() ||
|
||||
asset.backgroundAsset?.imageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
'';
|
||||
const imageSourcesByType = useMemo(
|
||||
() => buildMatch3DImageSourcesByType(run, generatedItemAssets),
|
||||
[generatedItemAssets, 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)
|
||||
?.backgroundMusic?.audioSrc ?? null;
|
||||
const [resolvedBackgroundMusicSrc, setResolvedBackgroundMusicSrc] = useState('');
|
||||
const clickSoundByTypeId = useMemo(() => {
|
||||
if (!run) {
|
||||
return new Map<string, string>();
|
||||
@@ -394,7 +586,7 @@ export function Match3DRuntimeShell({
|
||||
|
||||
useEffect(() => {
|
||||
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 +594,128 @@ 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]);
|
||||
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(() => {
|
||||
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 [];
|
||||
@@ -507,10 +796,18 @@ 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"
|
||||
/>
|
||||
@@ -546,24 +843,10 @@ 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%)',
|
||||
}}
|
||||
@@ -571,26 +854,18 @@ export function Match3DRuntimeShell({
|
||||
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}
|
||||
{run.items.map((item) => (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
item={item}
|
||||
imageSrc={resolveMatch3DImageForItem(
|
||||
item,
|
||||
resolvedImageSourcesByType,
|
||||
)}
|
||||
disabled={Boolean(pendingClick)}
|
||||
generatedItemAssets={generatedItemAssets}
|
||||
onClickItem={(item) => {
|
||||
void handleItemClick(item);
|
||||
}}
|
||||
onFallback={() => setForce2DRender(true)}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
) : (
|
||||
run.items.map((item) => (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
item={item}
|
||||
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">
|
||||
@@ -606,15 +881,14 @@ export function Match3DRuntimeShell({
|
||||
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}
|
||||
@@ -623,7 +897,14 @@ export function Match3DRuntimeShell({
|
||||
>
|
||||
<Match3DTrayToken
|
||||
slot={slot}
|
||||
use3DPreview={shouldUse3DRender}
|
||||
imageSrc={
|
||||
trayItem
|
||||
? resolveMatch3DImageForItem(
|
||||
trayItem,
|
||||
resolvedImageSourcesByType,
|
||||
)
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -161,6 +161,7 @@ import {
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
} from '../../services/match3d-works';
|
||||
import { preloadMatch3DGeneratedModelAssets } from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
buildBigFishGenerationAnchorEntries,
|
||||
buildMatch3DGenerationAnchorEntries,
|
||||
@@ -602,6 +603,13 @@ function mapPublicWorkDetailToMatch3DWork(
|
||||
updatedAt: entry.updatedAt,
|
||||
publishedAt: entry.publishedAt,
|
||||
publishReady: true,
|
||||
backgroundPrompt: entry.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: entry.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset:
|
||||
entry.generatedItemAssets
|
||||
?.map((asset) => asset.backgroundAsset ?? null)
|
||||
.find(Boolean) ?? null,
|
||||
generatedItemAssets: entry.generatedItemAssets ?? [],
|
||||
};
|
||||
}
|
||||
@@ -633,10 +641,24 @@ function buildMatch3DProfileFromSession(
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
backgroundPrompt: draft.backgroundPrompt ?? null,
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets: draft.generatedItemAssets,
|
||||
};
|
||||
}
|
||||
|
||||
function hasMatch3DGeneratedModelAsset(
|
||||
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
assets?.some(
|
||||
(asset) => asset.modelSrc?.trim() || asset.modelObjectKey?.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
@@ -650,7 +672,7 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
: [];
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
if (profileAssets.length > 0) {
|
||||
if (hasMatch3DGeneratedModelAsset(profileAssets)) {
|
||||
return profileAssets;
|
||||
}
|
||||
|
||||
@@ -659,7 +681,9 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicDetailAssets;
|
||||
return hasMatch3DGeneratedModelAsset(publicDetailAssets)
|
||||
? publicDetailAssets
|
||||
: profileAssets;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,7 +696,57 @@ function resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
return publicDetailAssets;
|
||||
}
|
||||
|
||||
return profileAssets.length > 0 ? profileAssets : publicDetailAssets;
|
||||
return hasMatch3DGeneratedModelAsset(profileAssets)
|
||||
? profileAssets
|
||||
: publicDetailAssets;
|
||||
}
|
||||
|
||||
function resolveActiveMatch3DRuntimeProfile(
|
||||
run: Match3DRunSnapshot | null,
|
||||
runtimeProfile: Match3DWorkProfile | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
|
||||
return runtimeProfile;
|
||||
}
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profile;
|
||||
}
|
||||
return runtimeProfile ?? profile;
|
||||
}
|
||||
|
||||
function resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
run: Match3DRunSnapshot | null,
|
||||
profile: Match3DWorkProfile | null,
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileBackground =
|
||||
profile?.backgroundImageSrc?.trim() ||
|
||||
profile?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
profile?.backgroundImageObjectKey?.trim() ||
|
||||
profile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? publicWorkDetail.backgroundImageSrc?.trim() ||
|
||||
publicWorkDetail.backgroundImageObjectKey?.trim() ||
|
||||
''
|
||||
: '';
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
if (
|
||||
runProfileId &&
|
||||
publicWorkDetail &&
|
||||
isMatch3DGalleryEntry(publicWorkDetail) &&
|
||||
publicWorkDetail.profileId === runProfileId
|
||||
) {
|
||||
return publicBackground || profileBackground || null;
|
||||
}
|
||||
return profileBackground || publicBackground || null;
|
||||
}
|
||||
|
||||
function resolveMatch3DGenerationStateFromAssets(
|
||||
@@ -699,7 +773,7 @@ function resolveMatch3DGenerationStateFromAssets(
|
||||
...current,
|
||||
phase:
|
||||
imageReadyCount > 0 || modelReadyCount > 0
|
||||
? 'match3d-generate-models'
|
||||
? 'match3d-generate-views'
|
||||
: current.phase,
|
||||
completedAssetCount: modelReadyCount,
|
||||
totalAssetCount,
|
||||
@@ -870,6 +944,11 @@ const PUZZLE_ONBOARDING_COPY = '待定待定待定';
|
||||
const PUZZLE_ONBOARDING_CLEAR_COPY = '只差一步,就可以永久保留你的梦';
|
||||
const PUZZLE_ONBOARDING_GENERATED_DELAY_MS = 700;
|
||||
|
||||
function isPuzzleOnboardingEnabled(): boolean {
|
||||
// 中文注释:当前产品要求隐藏首访新手引导,旧流程暂时保留用于后续显式恢复。
|
||||
return false;
|
||||
}
|
||||
|
||||
function escapePuzzleOnboardingSvgText(value: string) {
|
||||
return value
|
||||
.replace(/&/gu, '&')
|
||||
@@ -1020,6 +1099,10 @@ function maybeAlertWorkNotFoundAndReturnHome() {
|
||||
}
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
if (!isPuzzleOnboardingEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
@@ -1946,6 +2029,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
>([]);
|
||||
const [match3dProfile, setMatch3DProfile] =
|
||||
useState<Match3DWorkProfile | null>(null);
|
||||
const [match3dRuntimeProfile, setMatch3DRuntimeProfile] =
|
||||
useState<Match3DWorkProfile | null>(null);
|
||||
const [match3dRun, setMatch3DRun] = useState<Match3DRunSnapshot | null>(null);
|
||||
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = useState<
|
||||
'match3d-result' | 'work-detail'
|
||||
@@ -3283,6 +3368,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
session.sessionId,
|
||||
]);
|
||||
markPendingDraftGenerating('big-fish', session.sessionId);
|
||||
selectionStageRef.current = 'big-fish-generating';
|
||||
setSelectionStage('big-fish-generating');
|
||||
setBigFishGenerationState(createMiniGameDraftGenerationState('big-fish'));
|
||||
},
|
||||
@@ -3358,20 +3444,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
const profileId = response.session.draft?.profileId;
|
||||
if (!profileId) {
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
return { openResult };
|
||||
}
|
||||
|
||||
let runtimeProfile: Match3DWorkProfile | null = null;
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
setMatch3DProfile({
|
||||
runtimeProfile = {
|
||||
...item,
|
||||
generatedItemAssets:
|
||||
response.session.draft?.generatedItemAssets ??
|
||||
item.generatedItemAssets,
|
||||
});
|
||||
};
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
} catch {
|
||||
setMatch3DProfile(buildMatch3DProfileFromSession(response.session));
|
||||
runtimeProfile = buildMatch3DProfileFromSession(response.session);
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
}
|
||||
markPendingDraftReady('match3d', response.session.sessionId, openResult);
|
||||
markDraftReady(
|
||||
@@ -3379,6 +3469,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
[profileId, response.session.sessionId],
|
||||
openResult,
|
||||
);
|
||||
if (openResult && runtimeProfile) {
|
||||
try {
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const { run } = await startMatch3DRun(runtimeProfile.profileId);
|
||||
setMatch3DRuntimeProfile(runtimeProfile);
|
||||
setMatch3DRun(run);
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
setMatch3DRuntimeReturnStage('match3d-result');
|
||||
setSelectionStage('match3d-runtime');
|
||||
} catch (error) {
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(error, '启动抓大鹅玩法失败。'),
|
||||
);
|
||||
setSelectionStage('match3d-result');
|
||||
}
|
||||
return { openResult: false };
|
||||
}
|
||||
return { openResult };
|
||||
},
|
||||
beforeExecuteAction: ({ payload, session }) => {
|
||||
@@ -3391,6 +3501,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
session.sessionId,
|
||||
]);
|
||||
markPendingDraftGenerating('match3d', session.sessionId);
|
||||
selectionStageRef.current = 'match3d-generating';
|
||||
setSelectionStage('match3d-generating');
|
||||
setMatch3DGenerationState(createMiniGameDraftGenerationState('match3d'));
|
||||
},
|
||||
@@ -3468,6 +3579,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSquareHoleGenerationState(
|
||||
createMiniGameDraftGenerationState('square-hole'),
|
||||
);
|
||||
selectionStageRef.current = 'square-hole-generating';
|
||||
setSelectionStage('square-hole-generating');
|
||||
}
|
||||
if (payload.action === 'square_hole_generate_visual_assets') {
|
||||
@@ -3484,6 +3596,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
}));
|
||||
selectionStageRef.current = 'square-hole-generating';
|
||||
setSelectionStage('square-hole-generating');
|
||||
}
|
||||
},
|
||||
@@ -3746,6 +3859,46 @@ export function PlatformEntryFlowShellImpl({
|
||||
openResult,
|
||||
);
|
||||
void refreshPuzzleShelf();
|
||||
if (openResult && response.session.draft) {
|
||||
const draft = response.session.draft;
|
||||
const draftProfileId =
|
||||
response.session.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(response.session.sessionId);
|
||||
if (!draft.coverImageSrc || !draftProfileId) {
|
||||
setPuzzleError(
|
||||
!draft.coverImageSrc
|
||||
? '请先选择一张正式拼图图片。'
|
||||
: '这份拼图草稿缺少会话信息,请重新开始创作。',
|
||||
);
|
||||
setSelectionStage('puzzle-result');
|
||||
return { openResult: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const { item } = await updatePuzzleWork(draftProfileId, {
|
||||
workTitle: draft.workTitle,
|
||||
workDescription: draft.workDescription,
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
levels: draft.levels ?? [],
|
||||
});
|
||||
const run = startLocalPuzzleRun(item);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'),
|
||||
);
|
||||
setSelectionStage('puzzle-result');
|
||||
}
|
||||
return { openResult: false };
|
||||
}
|
||||
return { openResult };
|
||||
}
|
||||
|
||||
@@ -3792,6 +3945,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
buildPuzzleResultProfileId(session.sessionId),
|
||||
]);
|
||||
markPendingDraftGenerating('puzzle', session.sessionId);
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
setSelectionStage('puzzle-generating');
|
||||
setPuzzleGenerationState(createMiniGameDraftGenerationState('puzzle'));
|
||||
},
|
||||
@@ -4089,6 +4243,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setMatch3DGenerationState(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
@@ -4102,7 +4257,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftGenerating('match3d', nextSession.sessionId);
|
||||
|
||||
await match3dFlow.executeAction(
|
||||
{ action: 'match3d_compile_draft' },
|
||||
{
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: payload.generateClickSound,
|
||||
},
|
||||
nextSession,
|
||||
);
|
||||
},
|
||||
@@ -4258,6 +4416,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishError(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
setMatch3DFormDraftPayload(null);
|
||||
setActiveCreationFormType('puzzle');
|
||||
setMatch3DWorks([]);
|
||||
@@ -4436,6 +4595,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const leaveMatch3DFlow = useCallback(() => {
|
||||
setMatch3DRun(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
setMatch3DFormDraftPayload(null);
|
||||
setMatch3DGenerationState(null);
|
||||
setMatch3DRuntimeReturnStage('match3d-result');
|
||||
@@ -4888,6 +5048,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
void executeMatch3DAction({
|
||||
action: 'match3d_compile_draft',
|
||||
generateClickSound: match3dFormDraftPayload?.generateClickSound,
|
||||
});
|
||||
}, [
|
||||
createMatch3DDraftFromForm,
|
||||
@@ -5322,7 +5483,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
profile: Match3DWorkProfile | Match3DWorkSummary,
|
||||
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
|
||||
mirrorErrorToPublicDetail = false,
|
||||
options: { embedded?: boolean } = {},
|
||||
options: { embedded?: boolean; itemTypeCountOverride?: number } = {},
|
||||
) => {
|
||||
if (isMatch3DBusy) {
|
||||
return false;
|
||||
@@ -5333,7 +5494,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
let runtimeProfile = profile;
|
||||
if ((profile.generatedItemAssets ?? []).length === 0) {
|
||||
if (!hasMatch3DGeneratedModelAsset(profile.generatedItemAssets)) {
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profile.profileId);
|
||||
runtimeProfile = item;
|
||||
@@ -5341,12 +5502,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
|
||||
}
|
||||
}
|
||||
const { run } = options.embedded
|
||||
? await startMatch3DRun(
|
||||
runtimeProfile.profileId,
|
||||
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
|
||||
)
|
||||
: await startMatch3DRun(runtimeProfile.profileId);
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const runtimeOptions = {
|
||||
...(options.embedded ? RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS : {}),
|
||||
...(typeof options.itemTypeCountOverride === 'number'
|
||||
? { itemTypeCountOverride: options.itemTypeCountOverride }
|
||||
: {}),
|
||||
};
|
||||
const { run } =
|
||||
Object.keys(runtimeOptions).length > 0
|
||||
? await startMatch3DRun(runtimeProfile.profileId, runtimeOptions)
|
||||
: await startMatch3DRun(runtimeProfile.profileId);
|
||||
// 中文注释:运行态必须锁定本次启动时的完整 profile,避免首次切屏渲染读到旧草稿 state。
|
||||
setMatch3DRuntimeProfile(runtimeProfile);
|
||||
setMatch3DRun(run);
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
setMatch3DRuntimeReturnStage(returnStage);
|
||||
@@ -5492,18 +5663,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
const startPuzzleTestRunFromDraft = useCallback(
|
||||
async (draft: PuzzleResultDraft) => {
|
||||
if (isPuzzleBusy) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!draft.coverImageSrc) {
|
||||
setPuzzleError('请先选择一张正式拼图图片。');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const profileId =
|
||||
puzzleSession?.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(puzzleSession?.sessionId);
|
||||
if (!profileId) {
|
||||
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
@@ -5525,8 +5696,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
return true;
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'));
|
||||
return false;
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
@@ -7168,6 +7341,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRuntimeProfile(null);
|
||||
markDraftNoticeSeen(
|
||||
collectDraftNoticeKeys('match3d', [
|
||||
item.workId,
|
||||
@@ -7789,6 +7963,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
if (activeRecommendRuntimeKind === 'match3d') {
|
||||
const activeMatch3DRuntimeProfile = resolveActiveMatch3DRuntimeProfile(
|
||||
match3dRun,
|
||||
match3dRuntimeProfile,
|
||||
match3dProfile,
|
||||
);
|
||||
return (
|
||||
<Match3DRuntimeShell
|
||||
run={match3dRun}
|
||||
@@ -7797,7 +7976,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
embedded
|
||||
generatedItemAssets={resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
match3dRun,
|
||||
match3dProfile,
|
||||
activeMatch3DRuntimeProfile,
|
||||
activeEntry,
|
||||
)}
|
||||
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
activeEntry,
|
||||
)}
|
||||
onBack={() => {
|
||||
@@ -8004,6 +8188,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
match3dFlow,
|
||||
match3dProfile,
|
||||
match3dRun,
|
||||
match3dRuntimeProfile,
|
||||
platformBootstrap.platformTab,
|
||||
platformThemeClass,
|
||||
puzzleError,
|
||||
@@ -9733,9 +9918,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
stage: 'work-detail',
|
||||
});
|
||||
}}
|
||||
onStartTestRun={(profile) => {
|
||||
onStartTestRun={(profile, options) => {
|
||||
setMatch3DProfile(profile);
|
||||
void startMatch3DRunFromProfile(profile, 'match3d-result');
|
||||
setMatch3DRuntimeProfile(profile);
|
||||
void startMatch3DRunFromProfile(
|
||||
profile,
|
||||
'match3d-result',
|
||||
false,
|
||||
options,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -9743,6 +9934,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-runtime' && (
|
||||
(() => {
|
||||
const activeMatch3DRuntimeProfile =
|
||||
resolveActiveMatch3DRuntimeProfile(
|
||||
match3dRun,
|
||||
match3dRuntimeProfile,
|
||||
match3dProfile,
|
||||
);
|
||||
return (
|
||||
<motion.div
|
||||
key="match3d-runtime"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -9759,7 +9958,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
error={match3dError}
|
||||
generatedItemAssets={resolveMatch3DRuntimeGeneratedItemAssets(
|
||||
match3dRun,
|
||||
match3dProfile,
|
||||
activeMatch3DRuntimeProfile,
|
||||
selectedPublicWorkDetail,
|
||||
)}
|
||||
backgroundImageSrc={resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
match3dRun,
|
||||
activeMatch3DRuntimeProfile,
|
||||
selectedPublicWorkDetail,
|
||||
)}
|
||||
onBack={() => {
|
||||
@@ -9824,6 +10028,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{selectionStage === 'square-hole-agent-workspace' && (
|
||||
|
||||
@@ -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');
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -879,7 +879,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 +922,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 +933,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>
|
||||
|
||||
@@ -619,6 +619,120 @@ 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: 'UI' }));
|
||||
|
||||
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).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: 'UI' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新拼图UI背景提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
|
||||
|
||||
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('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: 'UI' }));
|
||||
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([
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
History,
|
||||
ImagePlus,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Music,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -58,7 +61,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'music';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'ui' | 'music';
|
||||
|
||||
type DraftEditState = {
|
||||
workTitle: string;
|
||||
@@ -74,6 +77,8 @@ const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
|
||||
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';
|
||||
|
||||
type PuzzleLevelGenerationRuntime = {
|
||||
startedAtMs: number;
|
||||
@@ -125,6 +130,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 +194,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 +278,13 @@ 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,
|
||||
generationStatus: incomingLevel.generationStatus || 'ready',
|
||||
};
|
||||
});
|
||||
@@ -273,6 +308,9 @@ function createBlankPuzzleLevel(
|
||||
levelName: '',
|
||||
pictureDescription: '',
|
||||
pictureReference: null,
|
||||
uiBackgroundPrompt: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
@@ -381,10 +419,11 @@ function PuzzleResultTabs({
|
||||
onChange: (tab: PuzzleResultTab) => void;
|
||||
}) {
|
||||
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">
|
||||
<div className="mb-3 grid grid-cols-4 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: 'ui' as const, label: 'UI' },
|
||||
{ id: 'music' as const, label: '音乐' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
@@ -1327,6 +1366,246 @@ 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背景'}
|
||||
</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=""
|
||||
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 +1620,20 @@ 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 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 +1657,7 @@ function PuzzleMusicTab({
|
||||
setErrorText(null);
|
||||
try {
|
||||
const task = await createBackgroundMusicTask({
|
||||
prompt: prompt.trim(),
|
||||
prompt: '',
|
||||
title: title.trim(),
|
||||
tags: tags.trim() || null,
|
||||
});
|
||||
@@ -1406,7 +1680,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(),
|
||||
});
|
||||
@@ -1473,19 +1747,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 +1758,7 @@ function PuzzleMusicTab({
|
||||
) : (
|
||||
<Music className="h-4 w-4" />
|
||||
)}
|
||||
生成音乐
|
||||
{currentMusic ? '重新生成音乐' : '生成音乐'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -1711,6 +1972,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',
|
||||
})),
|
||||
};
|
||||
@@ -1909,6 +2174,39 @@ export function PuzzleResultView({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'ui' ? (
|
||||
<PuzzleUiAssetsTab
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
onChange={setEditState}
|
||||
onGenerate={(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}
|
||||
{activeTab === 'music' ? (
|
||||
<PuzzleMusicTab
|
||||
editState={editState}
|
||||
|
||||
@@ -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,21 @@ 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 mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
|
||||
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
|
||||
const primaryMocapHandState = primaryMocapHand?.state;
|
||||
@@ -472,6 +497,18 @@ export function PuzzleRuntimeShell({
|
||||
currentLevelRef.current = currentLevel;
|
||||
}, [currentLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
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 commitSelectedPieceId = (pieceId: string | null) => {
|
||||
selectedPieceIdRef.current = pieceId;
|
||||
setSelectedPieceId(pieceId);
|
||||
@@ -815,6 +852,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 +925,10 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
|
||||
clearPresentationKeyRef.current = clearResultKey;
|
||||
if (clearSoundKeyRef.current !== clearResultKey) {
|
||||
clearSoundKeyRef.current = clearResultKey;
|
||||
playRuntimeLevelClearSound(musicVolume);
|
||||
}
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(true);
|
||||
setIsClearResultReady(false);
|
||||
@@ -864,7 +940,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,
|
||||
@@ -994,7 +1070,7 @@ export function PuzzleRuntimeShell({
|
||||
syncRuntimeDragFromController(session);
|
||||
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
|
||||
commitSelectedPieceId(session.targetId);
|
||||
triggerPuzzlePiecePressHapticFeedback();
|
||||
triggerPuzzlePiecePressFeedback(musicVolume);
|
||||
},
|
||||
onDragStart: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
@@ -1352,16 +1428,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">
|
||||
|
||||
@@ -97,7 +97,10 @@ import {
|
||||
dragLocalPuzzlePiece,
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import {
|
||||
listPuzzleWorks,
|
||||
updatePuzzleWork,
|
||||
} from '../../services/puzzle-works';
|
||||
import {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
@@ -376,6 +379,7 @@ vi.mock('../../services/creationEntryConfigService', () => ({
|
||||
|
||||
vi.mock('../../services/puzzle-works', () => ({
|
||||
listPuzzleWorks: vi.fn(),
|
||||
updatePuzzleWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-gallery', () => ({
|
||||
@@ -437,6 +441,10 @@ vi.mock('../../services/match3d-works', () => ({
|
||||
updateMatch3DGeneratedItemAssets: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3dGeneratedModelCache', () => ({
|
||||
preloadMatch3DGeneratedModelAssets: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', () => ({
|
||||
clickMatch3DItem: vi.fn(),
|
||||
finishMatch3DTimeUp: vi.fn(),
|
||||
@@ -555,6 +563,7 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
PuzzleResultView: ({
|
||||
isBusy,
|
||||
onExecuteAction,
|
||||
onStartTestRun,
|
||||
session,
|
||||
onBack,
|
||||
}: {
|
||||
@@ -564,7 +573,8 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
levelId?: string;
|
||||
promptText?: string;
|
||||
}) => void;
|
||||
session: { draft?: { levelName: string } | null };
|
||||
onStartTestRun?: (draft: PuzzleResultDraft) => void;
|
||||
session: { draft?: PuzzleResultDraft | null };
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="puzzle-result-view-mock">
|
||||
@@ -585,6 +595,17 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
|
||||
>
|
||||
重新生成画面
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!session.draft}
|
||||
onClick={() => {
|
||||
if (session.draft) {
|
||||
onStartTestRun?.(session.draft);
|
||||
}
|
||||
}}
|
||||
>
|
||||
试玩
|
||||
</button>
|
||||
<button type="button" disabled={isBusy}>
|
||||
新增关卡
|
||||
</button>
|
||||
@@ -660,6 +681,31 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-result/Match3DResultView', () => ({
|
||||
Match3DResultView: ({
|
||||
draft,
|
||||
onBack,
|
||||
onStartTestRun,
|
||||
profile,
|
||||
}: {
|
||||
draft?: { gameName?: string | null } | null;
|
||||
onBack: () => void;
|
||||
onStartTestRun: (profile: Match3DWorkSummary) => void;
|
||||
profile: Match3DWorkSummary;
|
||||
}) => (
|
||||
<div className="match3d-result-view-mock">
|
||||
<div>抓大鹅结果页</div>
|
||||
<div>{draft?.gameName ?? profile.gameName}</div>
|
||||
<button type="button" onClick={() => onStartTestRun(profile)}>
|
||||
试玩
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
Match3DAgentWorkspace: ({
|
||||
session,
|
||||
@@ -672,6 +718,7 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
|
||||
referenceImageSrc: string | null;
|
||||
clearCount: number;
|
||||
difficulty: number;
|
||||
generateClickSound?: boolean;
|
||||
}) => void;
|
||||
}) => (
|
||||
<div className="match3d-agent-workspace-mock">
|
||||
@@ -2246,6 +2293,31 @@ beforeEach(() => {
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(updatePuzzleWork).mockImplementation(async (profileId, payload) => ({
|
||||
item: {
|
||||
workId: `puzzle-work-${profileId}`,
|
||||
profileId,
|
||||
ownerUserId: mockAuthUser.id,
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: mockAuthUser.displayName,
|
||||
workTitle: payload.workTitle ?? payload.levelName,
|
||||
workDescription: payload.workDescription ?? payload.summary,
|
||||
levelName: payload.levelName,
|
||||
summary: payload.summary,
|
||||
themeTags: payload.themeTags,
|
||||
coverImageSrc: payload.coverImageSrc ?? null,
|
||||
coverAssetId: payload.coverAssetId ?? null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
levels: payload.levels,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
},
|
||||
}));
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
@@ -2566,7 +2638,9 @@ test('running match3d form generation can return to draft tab and reopen progres
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
|
||||
await user.click(screen.getByRole('button', { name: '生成抓大鹅草稿' }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
|
||||
@@ -2585,6 +2659,280 @@ test('running match3d form generation can return to draft tab and reopen progres
|
||||
});
|
||||
});
|
||||
|
||||
test('match3d result trial passes generated models into first runtime mount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
const match3dDraftWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-draft-1',
|
||||
profileId: 'match3d-profile-draft-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-draft-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-01T10:30:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedItemAssets,
|
||||
};
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [match3dDraftWork],
|
||||
});
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: buildMockMatch3DAgentSession({
|
||||
sessionId: 'match3d-session-draft-1',
|
||||
draft: {
|
||||
profileId: 'match3d-profile-draft-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
generatedItemAssets,
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDraftWork,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dDraftWork.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: /继续创作《水果抓大鹅》/u }),
|
||||
);
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-draft-1');
|
||||
});
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
|
||||
test('match3d draft generation auto starts trial and runtime back opens draft result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
const generatedSession = buildMockMatch3DAgentSession({
|
||||
stage: 'draft_ready',
|
||||
draft: {
|
||||
profileId: 'match3d-profile-auto-1',
|
||||
gameName: '自动试玩抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅', '试玩'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
generatedItemAssets,
|
||||
},
|
||||
});
|
||||
const generatedProfile: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-auto-1',
|
||||
profileId: 'match3d-profile-auto-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: generatedSession.sessionId,
|
||||
gameName: '自动试玩抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '',
|
||||
tags: ['水果', '抓大鹅', '试玩'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generatedItemAssets,
|
||||
};
|
||||
|
||||
vi.mocked(match3dCreationClient.executeAction).mockResolvedValueOnce({
|
||||
session: generatedSession,
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: generatedProfile,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(generatedProfile.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-auto-1');
|
||||
expect(
|
||||
await screen.findByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
|
||||
expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
|
||||
const user = userEvent.setup();
|
||||
const generatedDraft: PuzzleResultDraft = {
|
||||
workTitle: '自动试玩拼图',
|
||||
workDescription: '生成完成后直接试玩。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜', '拼图'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/auto-candidate.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫街',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
const generatedSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-auto-1',
|
||||
seedText: '屋檐下的猫与暖灯街角。',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: buildPuzzleAnchorPack(),
|
||||
draft: generatedDraft,
|
||||
messages: [],
|
||||
lastAssistantReply: '拼图草稿已经生成。',
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: {
|
||||
draft: generatedDraft,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
updatedAt: '2026-05-12T10:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
|
||||
operation: {
|
||||
operationId: 'compile-puzzle-auto-1',
|
||||
type: 'compile_puzzle_draft',
|
||||
status: 'completed',
|
||||
phaseLabel: '已完成',
|
||||
phaseDetail: '草稿已生成',
|
||||
progress: 1,
|
||||
},
|
||||
session: generatedSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreateTemplateHub(user);
|
||||
await user.click(screen.getByRole('button', { name: '生成草稿' }));
|
||||
|
||||
expect(await screen.findByText('雨夜猫街')).toBeTruthy();
|
||||
expect(updatePuzzleWork).toHaveBeenCalledWith(
|
||||
'puzzle-profile-auto-1',
|
||||
expect.objectContaining({
|
||||
levelName: '雨夜猫街',
|
||||
coverImageSrc: '/puzzle/auto-candidate.png',
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回上一页' }));
|
||||
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('embedded puzzle form routes through requireAuth while logged out', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -3545,6 +3893,82 @@ test('home recommendation Match3D runtime keeps profile generated models when ca
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation Match3D runtime refetches detail when stale card only has image assets', async () => {
|
||||
const match3dCard: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-card-image-only',
|
||||
profileId: 'match3d-profile-card-image-only',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'match3d-session-card-image-only',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '消除水果模型。',
|
||||
tags: ['水果', '抓大鹅'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 3,
|
||||
difficulty: 5,
|
||||
publicationStatus: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const match3dDetail: Match3DWorkSummary = {
|
||||
...match3dCard,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...match3dCard.generatedItemAssets![0]!,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1-item/model/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: 'task-strawberry',
|
||||
subscriptionKey: 'sub-strawberry',
|
||||
status: 'model_ready',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dCard],
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockResolvedValue({
|
||||
item: match3dDetail,
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dCard.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getMatch3DWorkDetail).toHaveBeenCalledWith(
|
||||
'match3d-profile-card-image-only',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-runtime-generated-model-count'),
|
||||
).toHaveProperty('textContent', '1');
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation surfaces start failure instead of staying in loading state', async () => {
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -3983,8 +4407,7 @@ test('published puzzle work card restores its source session for editing', async
|
||||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('first launch puzzle onboarding can be skipped from top right', async () => {
|
||||
const user = userEvent.setup();
|
||||
test('first launch hides puzzle onboarding by default', async () => {
|
||||
window.localStorage.removeItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
);
|
||||
@@ -4000,61 +4423,16 @@ test('first launch puzzle onboarding can be skipped from top right', async () =>
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('待定待定待定')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '跳过' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('待定待定待定')).toBeNull();
|
||||
});
|
||||
expect(screen.queryByPlaceholderText('把你的梦讲给我听吧')).toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
|
||||
).toBe('1');
|
||||
).toBeNull();
|
||||
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('first launch puzzle onboarding falls back to local run when generate route is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.removeItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
);
|
||||
vi.mocked(generatePuzzleOnboardingWork).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '资源不存在',
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: () => {},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('把你的梦讲给我听吧'),
|
||||
'我想飞上天',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('puzzle-board', undefined, { timeout: 3000 }),
|
||||
).toBeTruthy();
|
||||
expect(generatePuzzleOnboardingWork).toHaveBeenCalledWith({
|
||||
promptText: '我想飞上天',
|
||||
});
|
||||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.puzzle-onboarding.first-visit.v1'),
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
|
||||
const user = userEvent.setup();
|
||||
const clearedFirstLevel = buildClearedPuzzleRun({
|
||||
|
||||
@@ -21,6 +21,10 @@ import type {
|
||||
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,
|
||||
@@ -1089,11 +1093,48 @@ 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);
|
||||
@@ -1360,6 +1401,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 +1426,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 +1696,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',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Bell,
|
||||
BookOpen,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
Coins,
|
||||
Compass,
|
||||
Copy,
|
||||
FileText,
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
@@ -75,6 +75,14 @@ import {
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
ICP_RECORD_NUMBER,
|
||||
ICP_RECORD_URL,
|
||||
LEGAL_DOCUMENTS,
|
||||
type LegalDocumentId,
|
||||
} from '../common/legalDocuments';
|
||||
import {
|
||||
canExposePublicWork,
|
||||
EDUTAINMENT_WORK_TAG,
|
||||
@@ -825,7 +833,10 @@ function RecommendSwipeCard({
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
>
|
||||
<div className="platform-recommend-swipe-card__visual">{visual}</div>
|
||||
<div className="platform-recommend-swipe-card__meta">
|
||||
<div
|
||||
className="platform-recommend-swipe-card__meta"
|
||||
data-recommend-swipe-zone={isActive ? 'true' : 'false'}
|
||||
>
|
||||
<RecommendRuntimeMeta
|
||||
entry={entry}
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
@@ -2103,6 +2114,53 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileLegalSection({
|
||||
onOpenDocument,
|
||||
}: {
|
||||
onOpenDocument: (documentId: LegalDocumentId) => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
|
||||
aria-label="法律信息"
|
||||
>
|
||||
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
法律信息
|
||||
</div>
|
||||
<div className="platform-subpanel overflow-hidden rounded-[1.25rem]">
|
||||
{LEGAL_DOCUMENTS.map((document, index) => (
|
||||
<button
|
||||
key={document.id}
|
||||
type="button"
|
||||
onClick={() => onOpenDocument(document.id)}
|
||||
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
|
||||
index > 0 ? 'border-t border-[var(--platform-subpanel-border)]' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="platform-profile-chip flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
|
||||
<FileText className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{document.title}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={ICP_RECORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 block text-center text-xs font-semibold text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)]"
|
||||
>
|
||||
{ICP_RECORD_NUMBER}
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileReferralUserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
@@ -3176,6 +3234,8 @@ export function RpgEntryHomeView({
|
||||
const [profileCopyState, setProfileCopyState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const [activeLegalDocumentId, setActiveLegalDocumentId] =
|
||||
useState<LegalDocumentId | null>(null);
|
||||
const profileCopyResetTimerRef = useRef<number | null>(null);
|
||||
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
|
||||
@@ -3296,6 +3356,9 @@ export function RpgEntryHomeView({
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
|
||||
const activeLegalDocument = activeLegalDocumentId
|
||||
? getLegalDocument(activeLegalDocumentId)
|
||||
: null;
|
||||
const avatarCropSize = avatarImageSize
|
||||
? Math.min(avatarImageSize.width, avatarImageSize.height) / avatarScale
|
||||
: 0;
|
||||
@@ -4931,7 +4994,7 @@ export function RpgEntryHomeView({
|
||||
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
|
||||
aria-label="常用功能"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileShortcutButton
|
||||
label="每日任务"
|
||||
subLabel={
|
||||
@@ -4999,6 +5062,8 @@ export function RpgEntryHomeView({
|
||||
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
|
||||
</>
|
||||
) : (
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
@@ -5385,36 +5450,33 @@ export function RpgEntryHomeView({
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
|
||||
return (
|
||||
<div className="platform-mobile-entry-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-icon-button platform-mobile-topbar__action shrink-0"
|
||||
aria-label="通知与账户"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
|
||||
>
|
||||
{!isMobileRecommendTab ? (
|
||||
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
|
||||
<RpgEntryBrandLogo />
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
登录
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
{tabPanels}
|
||||
</div>
|
||||
|
||||
<div className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0">
|
||||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
@@ -5504,6 +5566,12 @@ export function RpgEntryHomeView({
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
platformTheme={authUi?.platformTheme}
|
||||
onClose={() => setActiveLegalDocumentId(null)}
|
||||
/>
|
||||
{profileEditModals}
|
||||
</div>
|
||||
);
|
||||
@@ -5528,14 +5596,6 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
className="platform-icon-button"
|
||||
aria-label="通知与账户"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUserSurface}
|
||||
@@ -5651,6 +5711,12 @@ export function RpgEntryHomeView({
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
<LegalDocumentModal
|
||||
document={activeLegalDocument}
|
||||
open={Boolean(activeLegalDocument)}
|
||||
platformTheme={authUi?.platformTheme}
|
||||
onClose={() => setActiveLegalDocumentId(null)}
|
||||
/>
|
||||
{profileEditModals}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -108,6 +108,9 @@ export type PlatformMatch3DGalleryCard = {
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
backgroundPrompt?: string | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundImageObjectKey?: string | null;
|
||||
generatedItemAssets?: Match3DGeneratedItemAsset[];
|
||||
};
|
||||
|
||||
@@ -255,6 +258,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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2347,12 +2347,6 @@ body {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.platform-mobile-topbar__action {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -2371,6 +2365,13 @@ body {
|
||||
padding-bottom: var(--platform-bottom-dock-outer-height);
|
||||
}
|
||||
|
||||
.platform-mobile-entry-shell--recommend {
|
||||
padding-top: 0;
|
||||
padding-bottom: calc(
|
||||
var(--platform-bottom-dock-outer-height) - 0.95rem
|
||||
);
|
||||
}
|
||||
|
||||
.platform-mobile-bottom-dock {
|
||||
position: fixed;
|
||||
right: max(0.75rem, env(safe-area-inset-right, 0px));
|
||||
@@ -2451,14 +2452,14 @@ body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
padding: 0 0 0.1rem;
|
||||
padding: 0 0 0.02rem;
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-panel {
|
||||
@@ -2538,7 +2539,7 @@ body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.platform-recommend-swipe-card__visual {
|
||||
@@ -2547,14 +2548,22 @@ body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-recommend-runtime-border);
|
||||
border-radius: 1.65rem;
|
||||
border-radius: 1.65rem 1.65rem 0 0;
|
||||
border-bottom: 0;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
box-shadow: var(--platform-recommend-runtime-shadow);
|
||||
}
|
||||
|
||||
.platform-recommend-swipe-card__meta {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 0 0 clamp(6.8rem, 18dvh, 8.4rem);
|
||||
min-width: 0;
|
||||
border: 1px solid var(--platform-recommend-runtime-border);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 1.65rem 1.65rem;
|
||||
background: var(--platform-recommend-runtime-fill);
|
||||
box-shadow: var(--platform-recommend-runtime-shadow);
|
||||
}
|
||||
|
||||
.platform-recommend-runtime-preview__body {
|
||||
@@ -2651,8 +2660,12 @@ body {
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
padding: 0.68rem 0.78rem 1.12rem;
|
||||
color: var(--platform-text-strong);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
@@ -2727,8 +2740,8 @@ body {
|
||||
}
|
||||
|
||||
.platform-recommend-work-meta__row {
|
||||
margin-top: 0.12rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -2,6 +2,8 @@ export {
|
||||
buildLocalMatch3DOptimisticRun,
|
||||
confirmLocalMatch3DClick,
|
||||
MATCH3D_VISUAL_SEEDS,
|
||||
normalizeLocalMatch3DRuntimeClearCount,
|
||||
resolveLocalMatch3DItemTypeCount,
|
||||
resolveLocalMatch3DTimer,
|
||||
startLocalMatch3DRun,
|
||||
stopLocalMatch3DRun,
|
||||
|
||||
@@ -221,8 +221,23 @@ function resolveSizeTierPlan(typeCount: number) {
|
||||
return baseCounts.flatMap((rule) => Array(rule.count).fill(rule));
|
||||
}
|
||||
|
||||
export function resolveLocalMatch3DItemTypeCount(clearCount: number) {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
if (normalizedClearCount === 8) return 3;
|
||||
if (normalizedClearCount === 12) return 9;
|
||||
if (normalizedClearCount === 16) return 15;
|
||||
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 21;
|
||||
return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount);
|
||||
}
|
||||
|
||||
export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩按新硬核 21 组三消执行。
|
||||
return normalizedClearCount === 20 ? 21 : normalizedClearCount;
|
||||
}
|
||||
|
||||
function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] {
|
||||
const typeCount = Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, clearCount);
|
||||
const typeCount = resolveLocalMatch3DItemTypeCount(clearCount);
|
||||
const seeds = [...MATCH3D_VISUAL_SEEDS];
|
||||
let state = hashNumber(clearCount * 2_654_435_761);
|
||||
for (let index = seeds.length - 1; index > 0; index -= 1) {
|
||||
@@ -410,7 +425,7 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
}
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const normalizedClearCount = normalizeLocalMatch3DRuntimeClearCount(clearCount);
|
||||
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
Match3DClickRejectReason,
|
||||
Match3DClickResponse,
|
||||
Match3DRunResponse,
|
||||
StartMatch3DRunRequest,
|
||||
StopMatch3DRunRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
@@ -30,7 +31,9 @@ type Match3DRuntimeRequestOptions = Pick<
|
||||
| 'skipRefresh'
|
||||
| 'notifyAuthStateChange'
|
||||
| 'clearAuthOnUnauthorized'
|
||||
>;
|
||||
> & {
|
||||
itemTypeCountOverride?: number | null;
|
||||
};
|
||||
|
||||
function normalizeRejectStatus(reason?: Match3DClickRejectReason | null) {
|
||||
switch (reason) {
|
||||
@@ -73,12 +76,17 @@ export function startMatch3DRun(
|
||||
profileId: string,
|
||||
options: Match3DRuntimeRequestOptions = {},
|
||||
) {
|
||||
const payload: StartMatch3DRunRequest = {
|
||||
profileId,
|
||||
itemTypeCountOverride: options.itemTypeCountOverride ?? null,
|
||||
};
|
||||
|
||||
return requestJson<Match3DRunResponse>(
|
||||
`/api/runtime/match3d/works/${encodeURIComponent(profileId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId }),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动抓大鹅玩法失败',
|
||||
{
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
generateMatch3DBackgroundImage,
|
||||
generateMatch3DCoverImage,
|
||||
generateMatch3DItemAssets,
|
||||
generateMatch3DWorkTags,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
match3dWorksClient,
|
||||
persistMatch3DGeneratedModel,
|
||||
publishMatch3DWork,
|
||||
updateMatch3DAudioAssets,
|
||||
updateMatch3DGeneratedItemAssets,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type {
|
||||
GenerateMatch3DBackgroundImageRequest,
|
||||
GenerateMatch3DBackgroundImageResponse,
|
||||
GenerateMatch3DCoverImageRequest,
|
||||
GenerateMatch3DCoverImageResponse,
|
||||
GenerateMatch3DItemAssetsRequest,
|
||||
GenerateMatch3DItemAssetsResponse,
|
||||
GenerateMatch3DWorkTagsRequest,
|
||||
GenerateMatch3DWorkTagsResponse,
|
||||
Match3DWorkDetailResponse,
|
||||
Match3DWorkMutationResponse,
|
||||
Match3DWorksResponse,
|
||||
PersistMatch3DGeneratedModelRequest,
|
||||
PersistMatch3DGeneratedModelResponse,
|
||||
PutMatch3DAudioAssetsRequest,
|
||||
PutMatch3DWorkRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
@@ -103,10 +111,100 @@ export function updateMatch3DGeneratedItemAssets(
|
||||
|
||||
export const updateMatch3DAudioAssets = updateMatch3DGeneratedItemAssets;
|
||||
|
||||
/**
|
||||
* 将历史外部 GLB 链接转存为抓大鹅私有模型资产;新草稿不再调用。
|
||||
*/
|
||||
export function persistMatch3DGeneratedModel(
|
||||
profileId: string,
|
||||
payload: PersistMatch3DGeneratedModelRequest,
|
||||
) {
|
||||
return requestJson<PersistMatch3DGeneratedModelResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/generated-models`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'保存抓大鹅历史模型失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 240_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成并保存抓大鹅作品封面图。
|
||||
*/
|
||||
export function generateMatch3DCoverImage(
|
||||
profileId: string,
|
||||
payload: GenerateMatch3DCoverImageRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DCoverImageResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/cover-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成抓大鹅封面图失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 240_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按画面描述重新生成并保存抓大鹅局内 UI 背景图。
|
||||
*/
|
||||
export function generateMatch3DBackgroundImage(
|
||||
profileId: string,
|
||||
payload: GenerateMatch3DBackgroundImageRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DBackgroundImageResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/background-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成抓大鹅背景图失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 240_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称批量生成抓大鹅 2D 五视角物品图片。
|
||||
*/
|
||||
export function generateMatch3DItemAssets(
|
||||
profileId: string,
|
||||
payload: GenerateMatch3DItemAssetsRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DItemAssetsResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/${encodeURIComponent(profileId)}/item-assets`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成抓大鹅物品素材失败',
|
||||
{
|
||||
retry: MATCH3D_WORKS_WRITE_RETRY,
|
||||
timeoutMs: 20 * 60 * 1000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前作品名称与题材生成发布标签。
|
||||
*/
|
||||
export function generateMatch3DWorkTags(payload: GenerateMatch3DWorkTagsRequest) {
|
||||
export function generateMatch3DWorkTags(
|
||||
payload: GenerateMatch3DWorkTagsRequest,
|
||||
) {
|
||||
return requestJson<GenerateMatch3DWorkTagsResponse>(
|
||||
`${MATCH3D_WORKS_API_BASE}/tags`,
|
||||
{
|
||||
@@ -145,10 +243,14 @@ export function deleteMatch3DWork(profileId: string) {
|
||||
|
||||
export const match3dWorksClient = {
|
||||
delete: deleteMatch3DWork,
|
||||
generateBackgroundImage: generateMatch3DBackgroundImage,
|
||||
generateCoverImage: generateMatch3DCoverImage,
|
||||
generateItemAssets: generateMatch3DItemAssets,
|
||||
generateTags: generateMatch3DWorkTags,
|
||||
getDetail: getMatch3DWorkDetail,
|
||||
listGallery: listMatch3DGallery,
|
||||
list: listMatch3DWorks,
|
||||
persistGeneratedModel: persistMatch3DGeneratedModel,
|
||||
publish: publishMatch3DWork,
|
||||
updateAudioAssets: updateMatch3DAudioAssets,
|
||||
updateGeneratedItemAssets: updateMatch3DGeneratedItemAssets,
|
||||
|
||||
116
src/services/match3dGeneratedModelCache.test.ts
Normal file
116
src/services/match3dGeneratedModelCache.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
clearMatch3DGeneratedModelBytesCache,
|
||||
getMatch3DGeneratedModelAssetSources,
|
||||
preloadMatch3DGeneratedModelAssets,
|
||||
readMatch3DGeneratedModelBytes,
|
||||
} from './match3dGeneratedModelCache';
|
||||
|
||||
describe('match3dGeneratedModelCache', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearMatch3DGeneratedModelBytesCache();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
test('预加载生成模型字节并复用本地缓存', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(new Uint8Array([103, 108, 84, 70]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'model/gltf-binary',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preloadMatch3DGeneratedModelAssets(
|
||||
[
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc:
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
modelObjectKey: null,
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
const bytes = await readMatch3DGeneratedModelBytes(
|
||||
'/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
{ expireSeconds: 300 },
|
||||
);
|
||||
|
||||
expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('模型源列表会去重并兼容 modelObjectKey', () => {
|
||||
const sources = getMatch3DGeneratedModelAssetSources([
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc: null,
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
itemId: 'match3d-item-1-duplicate',
|
||||
itemName: '草莓副本',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
modelObjectKey: null,
|
||||
modelFileName: 'strawberry.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sources).toEqual([
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb',
|
||||
]);
|
||||
});
|
||||
|
||||
test('同时存在外部 modelSrc 和平台 modelObjectKey 时优先预加载平台对象', () => {
|
||||
const sources = getMatch3DGeneratedModelAssetSources([
|
||||
{
|
||||
itemId: 'match3d-item-legacy',
|
||||
itemName: '苹果',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
modelSrc: 'https://rodin.example.com/expired/model.glb',
|
||||
modelObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb',
|
||||
modelFileName: 'apple.glb',
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'model_ready',
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(sources).toEqual([
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb',
|
||||
]);
|
||||
});
|
||||
});
|
||||
200
src/services/match3dGeneratedModelCache.ts
Normal file
200
src/services/match3dGeneratedModelCache.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
|
||||
import { readAssetBytes } from './assetReadUrlService';
|
||||
|
||||
type CachedMatch3DModelBytes = {
|
||||
accessedAt: number;
|
||||
promise: Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
type Match3DModelBytesOptions = {
|
||||
expireSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const MATCH3D_MODEL_BYTES_CACHE_LIMIT = 36;
|
||||
const match3dModelBytesCache = new Map<string, CachedMatch3DModelBytes>();
|
||||
|
||||
function normalizeMatch3DModelSource(source: string | null | undefined) {
|
||||
return source?.trim() ?? '';
|
||||
}
|
||||
|
||||
function isExternalMatch3DModelSource(source: string) {
|
||||
return /^(?:https?:)?\/\//iu.test(source.trim());
|
||||
}
|
||||
|
||||
function trimMatch3DModelBytesCache() {
|
||||
if (match3dModelBytesCache.size <= MATCH3D_MODEL_BYTES_CACHE_LIMIT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const staleKeys = [...match3dModelBytesCache.entries()]
|
||||
.sort((left, right) => left[1].accessedAt - right[1].accessedAt)
|
||||
.slice(0, match3dModelBytesCache.size - MATCH3D_MODEL_BYTES_CACHE_LIMIT)
|
||||
.map(([source]) => source);
|
||||
staleKeys.forEach((source) => match3dModelBytesCache.delete(source));
|
||||
}
|
||||
|
||||
function waitWithAbort<T>(promise: Promise<T>, signal?: AbortSignal) {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(new DOMException('加载已取消', 'AbortError'));
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const handleAbort = () => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
reject(new DOMException('加载已取消', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', handleAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', handleAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatch3DGeneratedModelAssetSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
// 中文注释:历史草稿可能同时保留已过期的 Rodin 外部 modelSrc 和后续修复出的平台 objectKey;
|
||||
// 试玩、正式游戏和预览都必须优先读取平台私有对象,避免继续请求过期外链。
|
||||
const modelSrc = normalizeMatch3DModelSource(asset.modelSrc);
|
||||
const objectKey = normalizeMatch3DModelSource(asset.modelObjectKey);
|
||||
if (modelSrc && (!isExternalMatch3DModelSource(modelSrc) || !objectKey)) {
|
||||
return modelSrc;
|
||||
}
|
||||
return objectKey || modelSrc;
|
||||
}
|
||||
|
||||
export function resolveMatch3DGeneratedImageViewSource(
|
||||
view:
|
||||
| NonNullable<Match3DGeneratedItemAsset['imageViews']>[number]
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
const imageSrc = normalizeMatch3DModelSource(view?.imageSrc);
|
||||
const objectKey = normalizeMatch3DModelSource(view?.imageObjectKey);
|
||||
return objectKey || imageSrc;
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedImageViewSources(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
const sources =
|
||||
asset.imageViews
|
||||
?.map(resolveMatch3DGeneratedImageViewSource)
|
||||
.filter((source) => source.length > 0) ?? [];
|
||||
const primarySource =
|
||||
normalizeMatch3DModelSource(asset.imageObjectKey) ||
|
||||
normalizeMatch3DModelSource(asset.imageSrc);
|
||||
return [...new Set(primarySource ? [primarySource, ...sources] : sources)];
|
||||
}
|
||||
|
||||
export function resolveMatch3DGeneratedImageAssetSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
return getMatch3DGeneratedImageViewSources(asset)[0] ?? '';
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedImageAssetSources(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
assets.flatMap((asset) => getMatch3DGeneratedImageViewSources(asset)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedModelAssetSources(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
assets
|
||||
.map(resolveMatch3DGeneratedModelAssetSource)
|
||||
.filter((source) => source.length > 0),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function readMatch3DGeneratedModelBytes(
|
||||
source: string | null | undefined,
|
||||
options: Match3DModelBytesOptions = {},
|
||||
) {
|
||||
const normalizedSource = normalizeMatch3DModelSource(source);
|
||||
if (!normalizedSource) {
|
||||
return Promise.reject(new Error('抓大鹅 3D 模型路径不能为空'));
|
||||
}
|
||||
|
||||
const cached = match3dModelBytesCache.get(normalizedSource);
|
||||
if (cached) {
|
||||
cached.accessedAt = Date.now();
|
||||
return waitWithAbort(cached.promise, options.signal);
|
||||
}
|
||||
|
||||
const entry: CachedMatch3DModelBytes = {
|
||||
accessedAt: Date.now(),
|
||||
promise: readAssetBytes(normalizedSource, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
}).then(async (response) => {
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength <= 0) {
|
||||
throw new Error('抓大鹅 3D 模型内容为空');
|
||||
}
|
||||
return bytes;
|
||||
}),
|
||||
};
|
||||
match3dModelBytesCache.set(normalizedSource, entry);
|
||||
trimMatch3DModelBytesCache();
|
||||
|
||||
entry.promise.catch(() => {
|
||||
if (match3dModelBytesCache.get(normalizedSource) === entry) {
|
||||
match3dModelBytesCache.delete(normalizedSource);
|
||||
}
|
||||
});
|
||||
|
||||
return waitWithAbort(entry.promise, options.signal);
|
||||
}
|
||||
|
||||
export async function preloadMatch3DGeneratedModelSources(
|
||||
sources: readonly string[],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
const normalizedSources = [
|
||||
...new Set(
|
||||
sources
|
||||
.map(normalizeMatch3DModelSource)
|
||||
.filter((source) => source.length > 0),
|
||||
),
|
||||
];
|
||||
await Promise.allSettled(
|
||||
normalizedSources.map((source) =>
|
||||
readMatch3DGeneratedModelBytes(source, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function preloadMatch3DGeneratedModelAssets(
|
||||
assets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
return preloadMatch3DGeneratedModelSources(
|
||||
getMatch3DGeneratedModelAssetSources(assets),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearMatch3DGeneratedModelBytesCache() {
|
||||
match3dModelBytesCache.clear();
|
||||
}
|
||||
@@ -166,7 +166,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
'match3d-material-sheet',
|
||||
'match3d-slice-images',
|
||||
'match3d-upload-images',
|
||||
'match3d-generate-models',
|
||||
'match3d-generate-views',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('match3d-material-sheet');
|
||||
expect(progress?.phaseLabel).toBe('生成素材图');
|
||||
@@ -186,10 +186,10 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
|
||||
});
|
||||
|
||||
test('match3d draft generation keeps backend observed model phase', () => {
|
||||
test('match3d draft generation keeps backend observed asset phase', () => {
|
||||
const state = {
|
||||
...createMiniGameDraftGenerationState('match3d'),
|
||||
phase: 'match3d-generate-models' as const,
|
||||
phase: 'match3d-generate-views' as const,
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 3,
|
||||
};
|
||||
@@ -199,12 +199,13 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
state.startedAtMs + 20_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-generate-models');
|
||||
expect(progress?.phaseId).toBe('match3d-generate-views');
|
||||
expect(progress?.steps.at(-1)?.detail).toContain('点击音效');
|
||||
expect(progress?.steps.at(-1)?.completed).toBe(1);
|
||||
expect(progress?.steps.at(-1)?.total).toBe(3);
|
||||
});
|
||||
|
||||
test('match3d generation anchors show theme and fixed three items', () => {
|
||||
test('match3d generation anchors show theme and difficulty item count', () => {
|
||||
const entries = buildMatch3DGenerationAnchorEntries(null, {
|
||||
themeText: '水果',
|
||||
clearCount: 20,
|
||||
@@ -221,7 +222,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
{
|
||||
id: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: '3 件',
|
||||
value: '21 件',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'match3d-material-sheet'
|
||||
| 'match3d-slice-images'
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-generate-models'
|
||||
| 'match3d-generate-views'
|
||||
| 'match3d-ready'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-select-image'
|
||||
@@ -151,31 +151,31 @@ const MATCH3D_STEPS = [
|
||||
{
|
||||
id: 'match3d-item-names',
|
||||
label: '生成物品名称',
|
||||
detail: '根据题材生成本局的 3 个物品名称。',
|
||||
detail: '根据难度生成本局物品名称。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'match3d-material-sheet',
|
||||
label: '生成素材图',
|
||||
detail: '生成一张 1:1 的网格素材图。',
|
||||
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
|
||||
weight: 18,
|
||||
},
|
||||
{
|
||||
id: 'match3d-slice-images',
|
||||
label: '切割独立图片',
|
||||
detail: '把素材图切成独立物品参考图。',
|
||||
detail: '把素材图切成每个物品的五个视角。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'match3d-upload-images',
|
||||
label: '上传图片资产',
|
||||
detail: '写入素材图和独立物品参考图。',
|
||||
detail: '写入独立 2D 视角素材。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'match3d-generate-models',
|
||||
label: '生成3D模型',
|
||||
detail: '调用 Hyper3D Rodin 生成 GLB 模型并转存。',
|
||||
id: 'match3d-generate-views',
|
||||
label: '整理素材',
|
||||
detail: '校验多视角素材并按需并行生成点击音效。',
|
||||
weight: 50,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
@@ -188,7 +188,7 @@ const MATCH3D_PHASE_ORDER: Partial<
|
||||
'match3d-material-sheet': 2,
|
||||
'match3d-slice-images': 3,
|
||||
'match3d-upload-images': 4,
|
||||
'match3d-generate-models': 5,
|
||||
'match3d-generate-views': 5,
|
||||
};
|
||||
|
||||
function clampProgress(value: number) {
|
||||
@@ -298,7 +298,7 @@ function resolveMatch3DPhaseByElapsedMs(
|
||||
): MiniGameDraftGenerationPhase {
|
||||
const elapsedPhase =
|
||||
elapsedMs >= 92_000
|
||||
? 'match3d-generate-models'
|
||||
? 'match3d-generate-views'
|
||||
: elapsedMs >= 72_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 58_000
|
||||
@@ -552,7 +552,9 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
}
|
||||
|
||||
const config = session?.config;
|
||||
const itemCount = 3;
|
||||
const clearCount = formPayload?.clearCount ?? config?.clearCount ?? null;
|
||||
const difficulty = formPayload?.difficulty ?? config?.difficulty ?? null;
|
||||
const itemCount = resolveMatch3DGeneratedItemCount(clearCount, difficulty);
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'match3d-theme',
|
||||
@@ -580,6 +582,24 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedItemCount(
|
||||
clearCount: number | null | undefined,
|
||||
difficulty: number | null | undefined,
|
||||
) {
|
||||
if (clearCount === 8) return 3;
|
||||
if (clearCount === 12) return 9;
|
||||
if (clearCount === 16) return 15;
|
||||
if (clearCount === 20 || clearCount === 21) return 21;
|
||||
const normalizedDifficulty =
|
||||
typeof difficulty === 'number' && Number.isFinite(difficulty)
|
||||
? Math.max(1, Math.min(10, Math.round(difficulty)))
|
||||
: 4;
|
||||
if (normalizedDifficulty <= 2) return 3;
|
||||
if (normalizedDifficulty <= 4) return 9;
|
||||
if (normalizedDifficulty <= 6) return 15;
|
||||
return 21;
|
||||
}
|
||||
|
||||
export function buildSquareHoleGenerationAnchorEntries(
|
||||
session: SquareHoleSessionSnapshot | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
|
||||
69
src/services/runtimeAudioFeedback.ts
Normal file
69
src/services/runtimeAudioFeedback.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export const DEFAULT_RUNTIME_CLICK_SOUND_SRC = '/audio/ui-click-soft.wav';
|
||||
export const DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC =
|
||||
'/audio/ui-level-clear.wav';
|
||||
export const DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC =
|
||||
'/audio/ui-countdown-warning.wav';
|
||||
export const DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS = 5_000;
|
||||
|
||||
export const DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG = {
|
||||
clickSoundSrc: DEFAULT_RUNTIME_CLICK_SOUND_SRC,
|
||||
levelClearSoundSrc: DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC,
|
||||
countdownSoundSrc: DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC,
|
||||
countdownWarningThresholdMs: DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS,
|
||||
} as const;
|
||||
|
||||
const runtimeAudioCache = new Map<string, HTMLAudioElement>();
|
||||
|
||||
function clampRuntimeAudioVolume(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0.6;
|
||||
}
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function playRuntimeClickSound(
|
||||
source = DEFAULT_RUNTIME_CLICK_SOUND_SRC,
|
||||
volume = 0.6,
|
||||
) {
|
||||
if (import.meta.env.MODE === 'test' || typeof Audio === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSource = source.trim();
|
||||
if (!normalizedSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio =
|
||||
runtimeAudioCache.get(normalizedSource) ?? new Audio(normalizedSource);
|
||||
runtimeAudioCache.set(normalizedSource, audio);
|
||||
audio.currentTime = 0;
|
||||
audio.volume = clampRuntimeAudioVolume(volume);
|
||||
try {
|
||||
const playResult = audio.play();
|
||||
void playResult?.catch?.(() => {
|
||||
// 中文注释:浏览器可能在用户手势外拒绝播放,点击反馈不应中断主交互。
|
||||
});
|
||||
} catch {
|
||||
// 中文注释:测试环境或极端浏览器可能未实现 play,同样不能影响主交互。
|
||||
}
|
||||
}
|
||||
|
||||
export function playRuntimeLevelClearSound(volume = 0.6) {
|
||||
playRuntimeClickSound(DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC, volume);
|
||||
}
|
||||
|
||||
export function playRuntimeCountdownSound(volume = 0.6) {
|
||||
playRuntimeClickSound(DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC, volume);
|
||||
}
|
||||
|
||||
export function resolveRuntimeCountdownSecondBucket(remainingMs: number) {
|
||||
if (
|
||||
!Number.isFinite(remainingMs) ||
|
||||
remainingMs <= 0 ||
|
||||
remainingMs > DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(1, Math.ceil(remainingMs / 1000));
|
||||
}
|
||||
Reference in New Issue
Block a user