This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -13,6 +13,7 @@ import type {
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
confirmLocalMatch3DClick,
resolveLocalMatch3DItemTypeCount,
startLocalMatch3DRun,
} from '../../services/match3d-runtime';
import {
@@ -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()];

View File

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

View File

@@ -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, '&amp;')
@@ -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' && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? [],
};
}

View File

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

View File

@@ -2,6 +2,8 @@ export {
buildLocalMatch3DOptimisticRun,
confirmLocalMatch3DClick,
MATCH3D_VISUAL_SEEDS,
normalizeLocalMatch3DRuntimeClearCount,
resolveLocalMatch3DItemTypeCount,
resolveLocalMatch3DTimer,
startLocalMatch3DRun,
stopLocalMatch3DRun,

View File

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

View File

@@ -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),
},
'启动抓大鹅玩法失败',
{

View File

@@ -1,10 +1,14 @@
export {
deleteMatch3DWork,
generateMatch3DBackgroundImage,
generateMatch3DCoverImage,
generateMatch3DItemAssets,
generateMatch3DWorkTags,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,
match3dWorksClient,
persistMatch3DGeneratedModel,
publishMatch3DWork,
updateMatch3DAudioAssets,
updateMatch3DGeneratedItemAssets,

View File

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

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

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

View File

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

View File

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

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