Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -275,3 +275,64 @@ test('creative image input panel can show an image without exposing AI redraw co
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creative image input panel can upload prompt references while showing a main image', () => {
|
||||
const onPromptReferenceFilesSelect = vi.fn();
|
||||
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canUploadPromptReferences
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[
|
||||
{
|
||||
id: 'prompt-ref-1',
|
||||
label: '描述参考图 1',
|
||||
imageSrc: 'data:image/png;base64,prompt-ref-1',
|
||||
},
|
||||
]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传主图',
|
||||
replaceImage: '更换主图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除主图',
|
||||
removeImageConfirmTitle: '移除主图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const promptReferenceInput = screen.getByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
fireEvent.change(promptReferenceInput, {
|
||||
target: {
|
||||
files: [new File(['a'], 'prompt-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([
|
||||
expect.any(File),
|
||||
]);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '预览参考图 描述参考图 1' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ export type CreativeImageInputPanelProps = {
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
canRemoveMainImage?: boolean;
|
||||
canToggleAiRedraw?: boolean;
|
||||
canUploadPromptReferences?: boolean;
|
||||
uploadedImageSrc: string;
|
||||
uploadedImageAlt: string;
|
||||
uploadedImageRefreshKey?: string | number | null;
|
||||
@@ -79,6 +80,7 @@ export function CreativeImageInputPanel({
|
||||
mainImageMode = 'edit',
|
||||
canRemoveMainImage = true,
|
||||
canToggleAiRedraw = true,
|
||||
canUploadPromptReferences,
|
||||
uploadedImageSrc,
|
||||
uploadedImageAlt,
|
||||
uploadedImageRefreshKey = null,
|
||||
@@ -114,6 +116,8 @@ export function CreativeImageInputPanel({
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
||||
const shouldShowPromptReferences =
|
||||
canUploadPromptReferences ?? !uploadedImageSrc;
|
||||
const promptReferenceUploadDisabled =
|
||||
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
||||
const canEditMainImage = mainImageMode === 'edit';
|
||||
@@ -157,7 +161,7 @@ export function CreativeImageInputPanel({
|
||||
{labels.imageField}
|
||||
</div>
|
||||
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
|
||||
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full">
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
@@ -292,7 +296,7 @@ export function CreativeImageInputPanel({
|
||||
aria-label={promptAriaLabel ?? promptLabel}
|
||||
/>
|
||||
{imageModelPicker}
|
||||
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
|
||||
{shouldShowPromptReferences && onPromptReferenceFilesSelect ? (
|
||||
<label
|
||||
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[var(--platform-accent)] ${
|
||||
promptReferenceUploadDisabled
|
||||
@@ -321,7 +325,7 @@ export function CreativeImageInputPanel({
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{!uploadedImageSrc && promptReferenceImages.length > 0 ? (
|
||||
{shouldShowPromptReferences && promptReferenceImages.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{promptReferenceImages.map((reference) => (
|
||||
<div
|
||||
|
||||
@@ -80,9 +80,9 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.getByText('2D素材风格')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '自定义' })).toBeNull();
|
||||
expect(screen.getByText('消耗10泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
|
||||
expect(screen.queryByText('参考图')).toBeNull();
|
||||
@@ -107,54 +107,12 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
referenceImageSrc: null,
|
||||
clearCount: 16,
|
||||
difficulty: 6,
|
||||
assetStyleId: 'flat-icon',
|
||||
assetStyleLabel: '扁平图标',
|
||||
assetStylePrompt:
|
||||
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
|
||||
generateClickSound: false,
|
||||
});
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('match3d workspace supports custom 2d asset style prompt', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '海底甜品店' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText('自定义2D素材风格描述'), {
|
||||
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
seedText: '海底甜品店题材,消除12次,难度4',
|
||||
themeText: '海底甜品店',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'custom',
|
||||
assetStyleLabel: '自定义风格',
|
||||
assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace submits strict pixel-retro style prompt', () => {
|
||||
test('match3d workspace omits legacy asset style fields from entry payload', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -169,22 +127,13 @@ test('match3d workspace submits strict pixel-retro style prompt', () => {
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '复古水果铺' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetStyleId: 'pixel-retro',
|
||||
assetStyleLabel: '像素复古',
|
||||
assetStylePrompt: expect.stringContaining('64x64'),
|
||||
}),
|
||||
);
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetStylePrompt: expect.stringContaining('禁止抗锯齿'),
|
||||
}),
|
||||
);
|
||||
const payload = onCreateFromForm.mock.calls[0]?.[0] ?? {};
|
||||
expect('assetStyleId' in payload).toBe(false);
|
||||
expect('assetStyleLabel' in payload).toBe(false);
|
||||
expect('assetStylePrompt' in payload).toBe(false);
|
||||
});
|
||||
|
||||
test('match3d workspace keeps click sound generation disabled from entry form', () => {
|
||||
@@ -231,11 +180,8 @@ test('match3d workspace falls back to compile action when restored from the lega
|
||||
expect(
|
||||
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '赛璐璐卡通' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '赛璐璐卡通' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
|
||||
import { Loader2, Sparkles, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -24,15 +24,11 @@ type Match3DAgentWorkspaceProps = {
|
||||
type Match3DFormState = {
|
||||
themeText: string;
|
||||
difficultyOptionId: Match3DDifficultyOptionId;
|
||||
assetStyleId: Match3DAssetStyleOptionId;
|
||||
customAssetStylePrompt: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: Match3DFormState = {
|
||||
themeText: '',
|
||||
difficultyOptionId: 'standard',
|
||||
assetStyleId: 'flat-icon',
|
||||
customAssetStylePrompt: '',
|
||||
};
|
||||
|
||||
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
|
||||
@@ -46,60 +42,6 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
|
||||
type Match3DDifficultyOptionId =
|
||||
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
|
||||
|
||||
const MATCH3D_ASSET_STYLE_OPTIONS = [
|
||||
{
|
||||
id: 'flat-icon',
|
||||
label: '扁平图标',
|
||||
imageSrc: '/match3d-style-references/flat-icon.png',
|
||||
prompt:
|
||||
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
|
||||
},
|
||||
{
|
||||
id: 'cel-cartoon',
|
||||
label: '赛璐璐卡通',
|
||||
imageSrc: '/match3d-style-references/cel-cartoon.png',
|
||||
prompt:
|
||||
'明亮赛璐璐卡通2D游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
|
||||
},
|
||||
{
|
||||
id: 'pixel-retro',
|
||||
label: '像素复古',
|
||||
imageSrc: '/match3d-style-references/pixel-retro.png',
|
||||
prompt:
|
||||
'64x64 复古像素 2D 游戏道具 sprite 风格,限制调色板,硬像素边缘,清晰正面剪影,禁止抗锯齿,禁止柔光渐变,透明背景。',
|
||||
},
|
||||
{
|
||||
id: 'watercolor',
|
||||
label: '手绘水彩',
|
||||
imageSrc: '/match3d-style-references/watercolor.png',
|
||||
prompt:
|
||||
'手绘水彩2D道具素材风格',
|
||||
},
|
||||
{
|
||||
id: 'sticker-outline',
|
||||
label: '贴纸描边',
|
||||
imageSrc: '/match3d-style-references/sticker-outline.png',
|
||||
prompt:
|
||||
'贴纸描边2D游戏道具素材风格,粗白边与深色外轮廓',
|
||||
},
|
||||
{
|
||||
id: 'painterly-icon',
|
||||
label: '厚涂图标',
|
||||
imageSrc: '/match3d-style-references/painterly-icon.png',
|
||||
prompt:
|
||||
'厚涂2D游戏道具图标风格,笔触细腻,体积光影明确,中心构图,保持图标级清晰剪影。',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
label: '自定义',
|
||||
imageSrc: null,
|
||||
prompt: '',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Match3DAssetStyleOptionId =
|
||||
(typeof MATCH3D_ASSET_STYLE_OPTIONS)[number]['id'];
|
||||
|
||||
function normalizeDifficulty(value: number) {
|
||||
return Math.max(1, Math.min(10, Math.round(value)));
|
||||
}
|
||||
@@ -137,27 +79,6 @@ function getDifficultyOption(optionId: Match3DDifficultyOptionId) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAssetStyleOption(optionId: Match3DAssetStyleOptionId) {
|
||||
return (
|
||||
MATCH3D_ASSET_STYLE_OPTIONS.find((option) => option.id === optionId) ??
|
||||
MATCH3D_ASSET_STYLE_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAssetStyleOptionId(
|
||||
assetStyleId: string | null | undefined,
|
||||
assetStylePrompt: string | null | undefined,
|
||||
): Match3DAssetStyleOptionId {
|
||||
const matchedOption = MATCH3D_ASSET_STYLE_OPTIONS.find(
|
||||
(option) => option.id === assetStyleId,
|
||||
);
|
||||
if (matchedOption) {
|
||||
return matchedOption.id;
|
||||
}
|
||||
|
||||
return assetStylePrompt?.trim() ? 'custom' : 'flat-icon';
|
||||
}
|
||||
|
||||
function resolveInitialFormState(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
initialFormPayload: CreateMatch3DSessionRequest | null = null,
|
||||
@@ -173,17 +94,11 @@ function resolveInitialFormState(
|
||||
initialFormPayload?.clearCount ?? config?.clearCount ?? null;
|
||||
const difficulty =
|
||||
initialFormPayload?.difficulty ?? config?.difficulty ?? null;
|
||||
const assetStyleId =
|
||||
initialFormPayload?.assetStyleId ?? config?.assetStyleId ?? null;
|
||||
const assetStylePrompt =
|
||||
initialFormPayload?.assetStylePrompt ?? config?.assetStylePrompt ?? '';
|
||||
|
||||
return {
|
||||
...EMPTY_FORM_STATE,
|
||||
themeText,
|
||||
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
|
||||
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
|
||||
customAssetStylePrompt: assetStylePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,9 +120,7 @@ export function Match3DAgentWorkspace({
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
|
||||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -219,30 +132,14 @@ export function Match3DAgentWorkspace({
|
||||
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setIsCustomStylePanelOpen(false);
|
||||
setIsPointCostConfirmOpen(false);
|
||||
setDraftCustomStylePrompt('');
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const themeText = formState.themeText.trim();
|
||||
const selectedDifficultyOption = getDifficultyOption(
|
||||
formState.difficultyOptionId,
|
||||
);
|
||||
const selectedAssetStyleOption = getAssetStyleOption(formState.assetStyleId);
|
||||
const assetStylePrompt =
|
||||
formState.assetStyleId === 'custom'
|
||||
? formState.customAssetStylePrompt.trim()
|
||||
: selectedAssetStyleOption.prompt;
|
||||
const assetStyleLabel =
|
||||
formState.assetStyleId === 'custom'
|
||||
? '自定义风格'
|
||||
: selectedAssetStyleOption.label;
|
||||
const canSubmit = Boolean(
|
||||
themeText &&
|
||||
!isBusy &&
|
||||
(formState.assetStyleId !== 'custom' ||
|
||||
formState.customAssetStylePrompt.trim()),
|
||||
);
|
||||
const canSubmit = Boolean(themeText && !isBusy);
|
||||
const formPayload = useMemo<CreateMatch3DSessionRequest>(
|
||||
() => ({
|
||||
seedText: themeText
|
||||
@@ -252,34 +149,11 @@ export function Match3DAgentWorkspace({
|
||||
referenceImageSrc: null,
|
||||
clearCount: selectedDifficultyOption.clearCount,
|
||||
difficulty: selectedDifficultyOption.difficulty,
|
||||
assetStyleId: formState.assetStyleId,
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
generateClickSound: false,
|
||||
}),
|
||||
[
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
formState.assetStyleId,
|
||||
selectedDifficultyOption,
|
||||
themeText,
|
||||
],
|
||||
[selectedDifficultyOption, themeText],
|
||||
);
|
||||
|
||||
const openCustomStylePanel = () => {
|
||||
setDraftCustomStylePrompt(formState.customAssetStylePrompt);
|
||||
setIsCustomStylePanelOpen(true);
|
||||
};
|
||||
|
||||
const applyCustomStylePrompt = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
assetStyleId: 'custom',
|
||||
customAssetStylePrompt: draftCustomStylePrompt.trim(),
|
||||
}));
|
||||
setIsCustomStylePanelOpen(false);
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
@@ -362,76 +236,6 @@ export function Match3DAgentWorkspace({
|
||||
</label>
|
||||
|
||||
<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)]">
|
||||
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="2D素材风格"
|
||||
>
|
||||
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
|
||||
const selected = formState.assetStyleId === option.id;
|
||||
const isCustom = option.id === 'custom';
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (isCustom) {
|
||||
openCustomStylePanel();
|
||||
return;
|
||||
}
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
assetStyleId: option.id,
|
||||
}));
|
||||
}}
|
||||
className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] sm:h-[5.45rem] sm:w-[6.4rem] ${
|
||||
selected
|
||||
? 'border-[var(--platform-surface-hover-border)] bg-white shadow-[0_8px_18px_rgba(112,57,30,0.10)] ring-2 ring-[var(--platform-warm-border)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-[var(--platform-surface-hover-border)] hover:bg-white/95'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={option.label}
|
||||
>
|
||||
{option.imageSrc ? (
|
||||
<img
|
||||
src={option.imageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
|
||||
)}
|
||||
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.02)_0%,rgba(255,255,255,0.18)_44%,rgba(255,255,255,0.82)_100%)]" />
|
||||
{selected ? (
|
||||
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-[var(--platform-accent)] shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
|
||||
) : null}
|
||||
{isCustom ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[var(--platform-accent)]">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-white/82 shadow-[0_6px_18px_rgba(112,57,30,0.12)]">
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`absolute inset-x-2 bottom-1.5 truncate rounded-full px-1.5 py-0.5 text-center text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.10)] ${
|
||||
selected
|
||||
? 'bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
|
||||
: 'bg-white/88 text-[var(--platform-text-strong)]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/44 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
难度
|
||||
@@ -498,60 +302,6 @@ export function Match3DAgentWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustomStylePanelOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-custom-style-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div
|
||||
id="match3d-custom-style-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
自定义风格
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭自定义风格"
|
||||
onClick={() => setIsCustomStylePanelOpen(false)}
|
||||
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={draftCustomStylePrompt}
|
||||
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-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
|
||||
aria-label="自定义2D素材风格描述"
|
||||
/>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCustomStylePanelOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!draftCustomStylePrompt.trim()}
|
||||
onClick={applyCustomStylePrompt}
|
||||
className={`platform-button platform-button--primary justify-center ${!draftCustomStylePrompt.trim() ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPointCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
@@ -14,6 +14,10 @@ import * as match3dWorksService from '../../services/match3d-works';
|
||||
import { clearMatch3DGeneratedModelBytesCache } from '../../services/match3dGeneratedModelCache';
|
||||
import { Match3DResultView } from './Match3DResultView';
|
||||
|
||||
const match3dSpritesheetParser = vi.hoisted(() => ({
|
||||
loadMatch3DSpritesheetAssetRegions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
@@ -45,9 +49,20 @@ vi.mock('../../services/match3d-works', () => ({
|
||||
updateMatch3DWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
|
||||
return {
|
||||
...actual,
|
||||
loadMatch3DSpritesheetAssetRegions:
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearMatch3DGeneratedModelBytesCache();
|
||||
vi.clearAllMocks();
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -687,8 +702,10 @@ describe('Match3DResultView', () => {
|
||||
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.getByRole('button', { name: '物品' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'UI' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'UI素材' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
expect(screen.getAllByRole('button', { name: /打开.+物品素材/u }))
|
||||
.toHaveLength(20);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开水果核心物件物品素材' }),
|
||||
@@ -1075,7 +1092,7 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
});
|
||||
expect(screen.getByText('63 件')).toBeTruthy();
|
||||
expect(screen.getAllByText('21 种').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('20 种').length).toBeGreaterThan(0);
|
||||
expect(onSaved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clearCount: 21,
|
||||
@@ -1323,13 +1340,32 @@ describe('Match3DResultView', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 展示默认提示词并支持预览UI页面', () => {
|
||||
test('素材配置 UI素材子 Tab 仅预览背景图和UI spritesheet', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
backgroundPrompt: '果园主题抓大鹅竖屏背景',
|
||||
backgroundImageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '果园主题抓大鹅竖屏背景',
|
||||
levelScenePrompt: '果园完整关卡画面',
|
||||
levelSceneImageSrc:
|
||||
'/generated-match3d-assets/session/profile/level-scene/scene.png',
|
||||
levelSceneImageObjectKey: null,
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: 'UI spritesheet',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
containerPrompt: null,
|
||||
containerImageSrc: null,
|
||||
containerImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
@@ -1337,15 +1373,18 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
|
||||
|
||||
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty(
|
||||
'value',
|
||||
'果园主题抓大鹅竖屏背景',
|
||||
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
);
|
||||
expect(
|
||||
screen.queryByLabelText('UI背景图画面描述提示词'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
|
||||
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
|
||||
@@ -1359,7 +1398,7 @@ describe('Match3DResultView', () => {
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
);
|
||||
expect(containerImage).toBeTruthy();
|
||||
expect(containerImage?.className).toContain('w-[min(108vw,38rem)]');
|
||||
expect(containerImage?.className).toContain('w-[min(116vw,42rem)]');
|
||||
expect(containerImage?.className).toContain('-translate-x-1/2');
|
||||
expect(
|
||||
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
|
||||
@@ -1371,7 +1410,7 @@ describe('Match3DResultView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 从物品挂载资产展示生成背景和容器', async () => {
|
||||
test('素材配置 UI素材子 Tab 从物品挂载资产展示生成背景和UI spritesheet', async () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const profile = createProfile({
|
||||
backgroundPrompt: null,
|
||||
@@ -1388,11 +1427,14 @@ describe('Match3DResultView', () => {
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/background.png',
|
||||
containerPrompt: '果园容器',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
uiSpritesheetPrompt: '果园UI素材',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
containerPrompt: null,
|
||||
containerImageSrc: null,
|
||||
containerImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
@@ -1417,15 +1459,16 @@ describe('Match3DResultView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
|
||||
|
||||
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty(
|
||||
'value',
|
||||
'果园背景',
|
||||
expect(screen.getByAltText('UI素材图').getAttribute('src')).toBe(
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
);
|
||||
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
|
||||
expect(
|
||||
@@ -1435,14 +1478,14 @@ describe('Match3DResultView', () => {
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]',
|
||||
'img[src="/generated-match3d-assets/session/profile/ui-spritesheet/ui.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
),
|
||||
).toBeNull();
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
@@ -1455,8 +1498,8 @@ describe('Match3DResultView', () => {
|
||||
generatedBackgroundAsset: expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
@@ -1466,7 +1509,81 @@ describe('Match3DResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => {
|
||||
test('素材配置 UI素材子 Tab 预览物品spritesheet解析结果', async () => {
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
|
||||
Array.from({ length: 100 }, (_, index) => ({
|
||||
label: `素材${index + 1}`,
|
||||
x: index * 2,
|
||||
y: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
sheetWidth: 200,
|
||||
sheetHeight: 2,
|
||||
imageSrc: `data:image/png;base64,item-${index + 1}`,
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
generatedItemAssets: [
|
||||
{ ...createReadyGeneratedItemAsset(1), itemName: '草莓' },
|
||||
{ ...createReadyGeneratedItemAsset(2), itemName: '苹果' },
|
||||
],
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '果园主题抓大鹅竖屏背景',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: 'UI spritesheet',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
itemSpritesheetPrompt: '物品 spritesheet',
|
||||
itemSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
|
||||
itemSpritesheetImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
|
||||
|
||||
expect(screen.getByAltText('物品素材图').getAttribute('src')).toBe(
|
||||
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-item-spritesheet-preview-0-0')
|
||||
.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,item-1');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-item-spritesheet-preview-1-4')
|
||||
.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,item-10');
|
||||
});
|
||||
expect(screen.getByText('草莓')).toBeTruthy();
|
||||
expect(screen.getByText('苹果')).toBeTruthy();
|
||||
expect(
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxRegions: 100,
|
||||
source:
|
||||
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('素材配置 UI素材子 Tab 不提供背景或容器重新生成入口', () => {
|
||||
const profile = createProfile({
|
||||
generatedItemAssets: [
|
||||
{
|
||||
@@ -1500,267 +1617,24 @@ describe('Match3DResultView', () => {
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
const nextProfile = createProfile({
|
||||
...profile,
|
||||
backgroundPrompt: '新背景提示词',
|
||||
backgroundImageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/new/background.png',
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...profile.generatedItemAssets![0]!,
|
||||
backgroundAsset: {
|
||||
prompt: '新背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/new/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/new/background.png',
|
||||
containerPrompt: '新容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '新背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/new/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/new/background.png',
|
||||
containerPrompt: '新容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
vi.mocked(
|
||||
match3dWorksService.generateMatch3DBackgroundImage,
|
||||
).mockResolvedValue({
|
||||
item: nextProfile,
|
||||
backgroundImageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/new/background.png',
|
||||
backgroundImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/new/background.png',
|
||||
generatedBackgroundAsset: nextProfile.generatedBackgroundAsset!,
|
||||
prompt: '新背景提示词',
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onSaved={onSaved}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
|
||||
target: { value: '新背景提示词' },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: /重新生成 · 2泥点/u }),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
|
||||
expect(screen.getByRole('dialog', { name: '确认消耗泥点' })).toBeTruthy();
|
||||
confirmPointCost();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI素材' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DBackgroundImage,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
prompt: '新背景提示词',
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
backgroundAsset: expect.objectContaining({
|
||||
prompt: '新背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/new/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 重新生成后显示90秒倒计时进度', async () => {
|
||||
const deferred =
|
||||
createDeferred<
|
||||
Awaited<
|
||||
ReturnType<typeof match3dWorksService.generateMatch3DBackgroundImage>
|
||||
>
|
||||
>();
|
||||
vi.mocked(
|
||||
match3dWorksService.generateMatch3DBackgroundImage,
|
||||
).mockReturnValue(deferred.promise);
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '旧容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('progressbar', { name: 'UI背景图生成进度' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('素材配置容器形象子 Tab 单独调用容器图生成接口并刷新素材', async () => {
|
||||
const profile = createProfile({
|
||||
generatedBackgroundAsset: {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '旧容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...createReadyGeneratedItemAsset(1),
|
||||
backgroundAsset: {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '旧容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/old/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const nextBackgroundAsset = {
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerPrompt: '新容器提示词',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
};
|
||||
const nextProfile = createProfile({
|
||||
...profile,
|
||||
generatedBackgroundAsset: nextBackgroundAsset,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...profile.generatedItemAssets![0]!,
|
||||
backgroundAsset: nextBackgroundAsset,
|
||||
},
|
||||
],
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
vi.mocked(
|
||||
match3dWorksService.generateMatch3DContainerImage,
|
||||
).mockResolvedValue({
|
||||
item: nextProfile,
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
generatedBackgroundAsset: nextBackgroundAsset,
|
||||
prompt: '新容器提示词',
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onSaved={onSaved}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '容器形象' }));
|
||||
fireEvent.change(screen.getByLabelText('容器形象画面描述提示词'), {
|
||||
target: { value: '新容器提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
|
||||
confirmPointCost();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DContainerImage,
|
||||
).toHaveBeenCalledWith(profile.profileId, {
|
||||
prompt: '新容器提示词',
|
||||
});
|
||||
expect(
|
||||
match3dWorksService.generateMatch3DBackgroundImage,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(onSaved).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
generatedItemAssets: [
|
||||
expect.objectContaining({
|
||||
itemId: 'match3d-item-1',
|
||||
backgroundAsset: expect.objectContaining({
|
||||
prompt: '旧背景提示词',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/old/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/new/container.png',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.queryByLabelText('UI背景图画面描述提示词')).toBeNull();
|
||||
expect(screen.queryByLabelText('容器形象画面描述提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '容器形象' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重新生成/u })).toBeNull();
|
||||
expect(match3dWorksService.generateMatch3DBackgroundImage).not.toHaveBeenCalled();
|
||||
expect(match3dWorksService.generateMatch3DContainerImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('历史草稿同时带旧 draft 和 profile 素材时以 profile 多视角素材补齐试玩资产', async () => {
|
||||
|
||||
@@ -32,8 +32,6 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
generateMatch3DBackgroundImage,
|
||||
generateMatch3DContainerImage,
|
||||
generateMatch3DCoverImage,
|
||||
generateMatch3DItemAssets,
|
||||
generateMatch3DWorkTags,
|
||||
@@ -48,6 +46,11 @@ import {
|
||||
resolveMatch3DGeneratedImageAssetSource,
|
||||
resolveMatch3DGeneratedModelAssetSource,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
buildMatch3DItemSpritesheetViewRegions,
|
||||
loadMatch3DSpritesheetAssetRegions,
|
||||
type Match3DDecodedSpritesheetRegion,
|
||||
} from '../../services/match3dSpritesheetParser';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
@@ -83,7 +86,7 @@ type Match3DResultViewProps = {
|
||||
|
||||
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type Match3DResultTab = 'work' | 'config' | 'assets';
|
||||
type Match3DAssetConfigTab = 'items' | 'ui' | 'container';
|
||||
type Match3DAssetConfigTab = 'items' | 'ui';
|
||||
type Match3DAssetTaskStatus =
|
||||
| 'idle'
|
||||
| 'submitting'
|
||||
@@ -102,6 +105,12 @@ type Match3DBatchItemGenerationState = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type Match3DItemSpritesheetPreviewGroup = {
|
||||
itemIndex: number;
|
||||
itemName: string;
|
||||
regions: Match3DDecodedSpritesheetRegion[];
|
||||
};
|
||||
|
||||
type Match3DTimedGenerationProgress = {
|
||||
startedAtMs: number;
|
||||
nowMs: number;
|
||||
@@ -158,11 +167,9 @@ type Match3DCoverReferenceDraft = {
|
||||
const MATCH3D_MIN_TAG_COUNT = 3;
|
||||
const MATCH3D_MAX_TAG_COUNT = 6;
|
||||
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
const MATCH3D_DEFAULT_ASSET_COUNT = 6;
|
||||
const MATCH3D_UI_BACKGROUND_POINTS_COST = 2;
|
||||
const MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS = 90;
|
||||
const MATCH3D_DEFAULT_ASSET_COUNT = 20;
|
||||
const MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH = 2;
|
||||
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 5;
|
||||
const MATCH3D_ITEM_ASSETS_BILLING_BATCH_SIZE = 20;
|
||||
const MATCH3D_COVER_REFERENCE_IMAGE_LIMIT = 6;
|
||||
|
||||
const MATCH3D_RESULT_TABS: Array<{ id: Match3DResultTab; label: string }> = [
|
||||
@@ -176,8 +183,7 @@ const MATCH3D_ASSET_CONFIG_TABS: Array<{
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'items', label: '物品' },
|
||||
{ id: 'ui', label: 'UI' },
|
||||
{ id: 'container', label: '容器形象' },
|
||||
{ id: 'ui', label: 'UI素材' },
|
||||
];
|
||||
|
||||
// 中文注释:结果页难度配置必须与创作入口页保持同一组派生参数。
|
||||
@@ -202,7 +208,7 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
|
||||
label: '硬核',
|
||||
clearCount: 21,
|
||||
difficulty: 8,
|
||||
itemTypeCount: 21,
|
||||
itemTypeCount: 20,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -385,6 +391,56 @@ function resolveMatch3DContainerPreviewSource(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DUiSpritesheetPreviewSource(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return (
|
||||
draft?.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
draft?.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
draft?.generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
draft?.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
generatedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DItemSpritesheetPreviewSource(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return (
|
||||
draft?.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
draft?.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
profile.generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
profile.generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
generatedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DContainerPrompt(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null,
|
||||
@@ -589,6 +645,12 @@ function hasPersistableMatch3DGeneratedItemAsset(
|
||||
asset.subscriptionKey?.trim() ||
|
||||
asset.backgroundAsset?.imageSrc?.trim() ||
|
||||
asset.backgroundAsset?.imageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.levelSceneImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
asset.backgroundAsset?.prompt?.trim() ||
|
||||
@@ -627,8 +689,17 @@ function getMatch3DGeneratedItemAssetPersistenceSignature(
|
||||
asset.backgroundMusic?.taskId?.trim() ??
|
||||
'',
|
||||
asset.backgroundAsset?.prompt?.trim() ?? '',
|
||||
asset.backgroundAsset?.levelScenePrompt?.trim() ?? '',
|
||||
asset.backgroundAsset?.levelSceneImageSrc?.trim() ?? '',
|
||||
asset.backgroundAsset?.levelSceneImageObjectKey?.trim() ?? '',
|
||||
asset.backgroundAsset?.imageSrc?.trim() ?? '',
|
||||
asset.backgroundAsset?.imageObjectKey?.trim() ?? '',
|
||||
asset.backgroundAsset?.uiSpritesheetPrompt?.trim() ?? '',
|
||||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ?? '',
|
||||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ?? '',
|
||||
asset.backgroundAsset?.itemSpritesheetPrompt?.trim() ?? '',
|
||||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ?? '',
|
||||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ?? '',
|
||||
asset.backgroundAsset?.containerPrompt?.trim() ?? '',
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ?? '',
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim() ?? '',
|
||||
@@ -770,9 +841,22 @@ function createMatch3DAssetDrafts(
|
||||
name: `${theme}场景小物`,
|
||||
usage: '圆形空间周边装饰物',
|
||||
},
|
||||
].slice(0, MATCH3D_DEFAULT_ASSET_COUNT);
|
||||
];
|
||||
const fallbackSeeds = Array.from(
|
||||
{ length: MATCH3D_DEFAULT_ASSET_COUNT },
|
||||
(_, index) => {
|
||||
const seed = seeds[index];
|
||||
return (
|
||||
seed ?? {
|
||||
id: `generated-item-${index + 1}`,
|
||||
name: `${theme}物品${index + 1}`,
|
||||
usage: '局内点击消除物件',
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return seeds.map((seed) => ({
|
||||
return fallbackSeeds.map((seed) => ({
|
||||
...seed,
|
||||
prompt: buildMatch3DAssetPrompt(profile, seed.name, seed.usage),
|
||||
referenceImageSrc: profile.referenceImageSrc ?? profile.coverImageSrc ?? '',
|
||||
@@ -2675,7 +2759,7 @@ function Match3DAssetConfigTabs({
|
||||
onChange: (tab: Match3DAssetConfigTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
|
||||
{MATCH3D_ASSET_CONFIG_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -2697,39 +2781,62 @@ function Match3DAssetConfigTabs({
|
||||
|
||||
function Match3DUIAssetsTab({
|
||||
backgroundPreviewSrc,
|
||||
containerPreviewSrc,
|
||||
backgroundPrompt,
|
||||
busy,
|
||||
isGenerating,
|
||||
uiSpritesheetPreviewSrc,
|
||||
itemSpritesheetPreviewSrc,
|
||||
itemNames,
|
||||
error,
|
||||
progressRuntime,
|
||||
onGenerate,
|
||||
}: {
|
||||
backgroundPreviewSrc: string;
|
||||
containerPreviewSrc: string;
|
||||
backgroundPrompt: string;
|
||||
busy: boolean;
|
||||
isGenerating: boolean;
|
||||
uiSpritesheetPreviewSrc: string;
|
||||
itemSpritesheetPreviewSrc: string;
|
||||
itemNames: readonly string[];
|
||||
error: string | null;
|
||||
progressRuntime: Match3DTimedGenerationProgress | null;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState(backgroundPrompt);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
const [itemSpritesheetGroups, setItemSpritesheetGroups] = useState<
|
||||
Match3DItemSpritesheetPreviewGroup[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(backgroundPrompt);
|
||||
}, [backgroundPrompt]);
|
||||
if (!itemSpritesheetPreviewSrc) {
|
||||
setItemSpritesheetGroups((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedPrompt = prompt.trim();
|
||||
const generationProgress =
|
||||
resolveMatch3DTimedGenerationProgress(progressRuntime);
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
void loadMatch3DSpritesheetAssetRegions({
|
||||
source: itemSpritesheetPreviewSrc,
|
||||
maxRegions: 100,
|
||||
minArea: 16,
|
||||
alphaThreshold: 8,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((regions) => {
|
||||
if (!cancelled) {
|
||||
setItemSpritesheetGroups(
|
||||
buildMatch3DItemSpritesheetViewRegions(regions, itemNames),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setItemSpritesheetGroups([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [itemNames, itemSpritesheetPreviewSrc]);
|
||||
|
||||
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(14rem,0.72fr)_minmax(0,1fr)]">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
@@ -2744,66 +2851,67 @@ function Match3DUIAssetsTab({
|
||||
<span className="sr-only">打开UI页面预览</span>
|
||||
</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)]">
|
||||
画面描述提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={busy || isGenerating}
|
||||
rows={7}
|
||||
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="UI背景图画面描述提示词"
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
|
||||
<ResolvedAssetImage
|
||||
src={uiSpritesheetPreviewSrc}
|
||||
alt="UI素材图"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</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={!normalizedPrompt || busy || isGenerating}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
重新生成 · {MATCH3D_UI_BACKGROUND_POINTS_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
{isGenerating ? (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="UI背景图生成进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={generationProgress.progressPercent}
|
||||
className="platform-progress-track relative mt-3 h-10 overflow-hidden rounded-full"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
|
||||
style={{ width: `${generationProgress.progressPercent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-xs font-bold text-white">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
预计剩余 {generationProgress.secondsLeft} 秒
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{itemSpritesheetPreviewSrc ? (
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4 sm:p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
|
||||
<div className="aspect-square overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 shadow-sm">
|
||||
<ResolvedAssetImage
|
||||
src={itemSpritesheetPreviewSrc}
|
||||
alt="物品素材图"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
{itemSpritesheetGroups.length > 0 ? (
|
||||
<div className="grid max-h-[24rem] content-start gap-3 overflow-y-auto pr-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{itemSpritesheetGroups.map((group) => (
|
||||
<div
|
||||
key={`${group.itemIndex}-${group.itemName}`}
|
||||
className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
|
||||
>
|
||||
<div className="mb-2 truncate text-xs font-black text-[var(--platform-text-strong)]">
|
||||
{group.itemName}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{group.regions.map((region, regionIndex) => (
|
||||
<img
|
||||
key={`${group.itemIndex}-${regionIndex}-${region.imageSrc}`}
|
||||
src={region.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-testid={`match3d-item-spritesheet-preview-${group.itemIndex}-${regionIndex}`}
|
||||
className="aspect-square w-full rounded-[0.55rem] border border-white/70 bg-white/82 object-contain p-1"
|
||||
draggable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
@@ -2813,217 +2921,10 @@ function Match3DUIAssetsTab({
|
||||
{isPreviewOpen ? (
|
||||
<Match3DUIRuntimePreviewPanel
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
containerPreviewSrc={containerPreviewSrc}
|
||||
containerPreviewSrc={MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-ui-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="match3d-ui-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 {MATCH3D_UI_BACKGROUND_POINTS_COST} 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!normalizedPrompt || busy || isGenerating}
|
||||
onClick={() => {
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DContainerAssetsTab({
|
||||
backgroundPreviewSrc,
|
||||
containerPreviewSrc,
|
||||
containerPrompt,
|
||||
busy,
|
||||
isGenerating,
|
||||
error,
|
||||
progressRuntime,
|
||||
onGenerate,
|
||||
}: {
|
||||
backgroundPreviewSrc: string;
|
||||
containerPreviewSrc: string;
|
||||
containerPrompt: string;
|
||||
busy: boolean;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
progressRuntime: Match3DTimedGenerationProgress | null;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState(containerPrompt);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
const hasContainerPreview = Boolean(containerPreviewSrc.trim());
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(containerPrompt);
|
||||
}, [containerPrompt]);
|
||||
|
||||
const normalizedPrompt = prompt.trim();
|
||||
const generationProgress =
|
||||
resolveMatch3DTimedGenerationProgress(progressRuntime);
|
||||
|
||||
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(14rem,0.72fr)_minmax(0,1fr)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="mx-auto aspect-square w-full max-w-[18rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-3 text-left shadow-sm"
|
||||
aria-label="打开容器形象预览"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={containerPreviewSrc}
|
||||
alt="容器形象"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
<span className="sr-only">打开容器形象预览</span>
|
||||
</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)]">
|
||||
容器形象提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={busy || isGenerating}
|
||||
rows={7}
|
||||
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>
|
||||
<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" />
|
||||
预览容器形象
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!normalizedPrompt || busy || isGenerating}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
重新生成 · {MATCH3D_UI_BACKGROUND_POINTS_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
{isGenerating ? (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="容器形象生成进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={generationProgress.progressPercent}
|
||||
className="platform-progress-track relative mt-3 h-10 overflow-hidden rounded-full"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
|
||||
style={{ width: `${generationProgress.progressPercent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-xs font-bold text-white">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
预计剩余 {generationProgress.secondsLeft} 秒
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPreviewOpen ? (
|
||||
<Match3DUIRuntimePreviewPanel
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
containerPreviewSrc={hasContainerPreview ? containerPreviewSrc : ''}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-container-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="match3d-container-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 {MATCH3D_UI_BACKGROUND_POINTS_COST} 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!normalizedPrompt || busy || isGenerating}
|
||||
onClick={() => {
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!normalizedPrompt || busy || isGenerating ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3115,49 +3016,33 @@ function Match3DAssetConfigTab({
|
||||
activeAssetId,
|
||||
assetDrafts,
|
||||
backgroundPreviewSrc,
|
||||
containerPreviewSrc,
|
||||
backgroundPrompt,
|
||||
containerPrompt,
|
||||
uiSpritesheetPreviewSrc,
|
||||
itemSpritesheetPreviewSrc,
|
||||
itemNames,
|
||||
backgroundGenerationError,
|
||||
containerGenerationError,
|
||||
batchGenerationState,
|
||||
busy,
|
||||
backgroundGenerationProgress,
|
||||
containerGenerationProgress,
|
||||
isGeneratingBackground,
|
||||
isGeneratingContainer,
|
||||
onActiveAssetChange,
|
||||
onAddBatch,
|
||||
onRegenerateBatch,
|
||||
onAssetChange,
|
||||
onAssetConfigTabChange,
|
||||
onDeleteAsset,
|
||||
onGenerateBackground,
|
||||
onGenerateContainer,
|
||||
}: {
|
||||
activeAssetConfigTab: Match3DAssetConfigTab;
|
||||
activeAssetId: string | null;
|
||||
assetDrafts: Match3DItemAssetDraft[];
|
||||
backgroundPreviewSrc: string;
|
||||
containerPreviewSrc: string;
|
||||
backgroundPrompt: string;
|
||||
containerPrompt: string;
|
||||
uiSpritesheetPreviewSrc: string;
|
||||
itemSpritesheetPreviewSrc: string;
|
||||
itemNames: readonly string[];
|
||||
backgroundGenerationError: string | null;
|
||||
containerGenerationError: string | null;
|
||||
batchGenerationState: Match3DBatchItemGenerationState;
|
||||
busy: boolean;
|
||||
backgroundGenerationProgress: Match3DTimedGenerationProgress | null;
|
||||
containerGenerationProgress: Match3DTimedGenerationProgress | null;
|
||||
isGeneratingBackground: boolean;
|
||||
isGeneratingContainer: boolean;
|
||||
onActiveAssetChange: (assetId: string | null) => void;
|
||||
onAddBatch: () => void;
|
||||
onRegenerateBatch: () => void;
|
||||
onAssetChange: (asset: Match3DItemAssetDraft) => void;
|
||||
onAssetConfigTabChange: (tab: Match3DAssetConfigTab) => void;
|
||||
onDeleteAsset: (assetId: string) => void;
|
||||
onGenerateBackground: (prompt: string) => void;
|
||||
onGenerateContainer: (prompt: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-0">
|
||||
@@ -3180,25 +3065,10 @@ function Match3DAssetConfigTab({
|
||||
{activeAssetConfigTab === 'ui' ? (
|
||||
<Match3DUIAssetsTab
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
containerPreviewSrc={containerPreviewSrc}
|
||||
backgroundPrompt={backgroundPrompt}
|
||||
busy={busy}
|
||||
isGenerating={isGeneratingBackground}
|
||||
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
|
||||
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
|
||||
itemNames={itemNames}
|
||||
error={backgroundGenerationError}
|
||||
progressRuntime={backgroundGenerationProgress}
|
||||
onGenerate={onGenerateBackground}
|
||||
/>
|
||||
) : null}
|
||||
{activeAssetConfigTab === 'container' ? (
|
||||
<Match3DContainerAssetsTab
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
containerPreviewSrc={containerPreviewSrc}
|
||||
containerPrompt={containerPrompt}
|
||||
busy={busy}
|
||||
isGenerating={isGeneratingContainer}
|
||||
error={containerGenerationError}
|
||||
progressRuntime={containerGenerationProgress}
|
||||
onGenerate={onGenerateContainer}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -3255,18 +3125,9 @@ export function Match3DResultView({
|
||||
const [batchRegenerateError, setBatchRegenerateError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isGeneratingBackground, setIsGeneratingBackground] = useState(false);
|
||||
const [backgroundGenerationError, setBackgroundGenerationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [backgroundGenerationProgress, setBackgroundGenerationProgress] =
|
||||
useState<Match3DTimedGenerationProgress | null>(null);
|
||||
const [isGeneratingContainer, setIsGeneratingContainer] = useState(false);
|
||||
const [containerGenerationError, setContainerGenerationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [containerGenerationProgress, setContainerGenerationProgress] =
|
||||
useState<Match3DTimedGenerationProgress | null>(null);
|
||||
const [autoSaveState, setAutoSaveState] =
|
||||
useState<Match3DAutoSaveState>('idle');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
@@ -3299,24 +3160,6 @@ export function Match3DResultView({
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const backgroundPrompt = useMemo(
|
||||
() =>
|
||||
resolveMatch3DBackgroundPrompt(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const containerPrompt = useMemo(
|
||||
() =>
|
||||
resolveMatch3DContainerPrompt(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const containerPreviewSrc = useMemo(
|
||||
() =>
|
||||
resolveMatch3DContainerPreviewSource(
|
||||
@@ -3327,6 +3170,28 @@ export function Match3DResultView({
|
||||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const uiSpritesheetPreviewSrc = useMemo(
|
||||
() =>
|
||||
resolveMatch3DUiSpritesheetPreviewSource(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const itemSpritesheetPreviewSrc = useMemo(
|
||||
() =>
|
||||
resolveMatch3DItemSpritesheetPreviewSource(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const generatedItemNames = useMemo(
|
||||
() => generatedItemAssets.map((asset) => asset.itemName),
|
||||
[generatedItemAssets],
|
||||
);
|
||||
const coverSourceAssets = useMemo(
|
||||
() =>
|
||||
resolveMatch3DCoverSourceAssets(
|
||||
@@ -3349,11 +3214,6 @@ export function Match3DResultView({
|
||||
setCoverAiRedraw(false);
|
||||
setCoverPanelError(null);
|
||||
setBackgroundGenerationError(null);
|
||||
setIsGeneratingBackground(false);
|
||||
setBackgroundGenerationProgress(null);
|
||||
setContainerGenerationError(null);
|
||||
setIsGeneratingContainer(false);
|
||||
setContainerGenerationProgress(null);
|
||||
// 中文注释:表单草稿只在切换作品或服务端更新时间变化时重置,避免父级重渲染打断本地编辑。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profile.profileId, profile.updatedAt]);
|
||||
@@ -3369,56 +3229,6 @@ export function Match3DResultView({
|
||||
profile.profileId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGeneratingBackground) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
setBackgroundGenerationProgress({
|
||||
startedAtMs,
|
||||
nowMs: startedAtMs,
|
||||
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
|
||||
});
|
||||
const timer = window.setInterval(() => {
|
||||
setBackgroundGenerationProgress((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
nowMs: Date.now(),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isGeneratingBackground]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGeneratingContainer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
setContainerGenerationProgress({
|
||||
startedAtMs,
|
||||
nowMs: startedAtMs,
|
||||
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
|
||||
});
|
||||
const timer = window.setInterval(() => {
|
||||
setContainerGenerationProgress((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
nowMs: Date.now(),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isGeneratingContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
@@ -3461,12 +3271,6 @@ export function Match3DResultView({
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (isGeneratingBackground) {
|
||||
return;
|
||||
}
|
||||
if (isGeneratingContainer) {
|
||||
return;
|
||||
}
|
||||
setAutoSaveState('error');
|
||||
setLocalError(
|
||||
saveError instanceof Error ? saveError.message : '自动保存失败。',
|
||||
@@ -3481,8 +3285,6 @@ export function Match3DResultView({
|
||||
}, [
|
||||
editState,
|
||||
generatedItemAssets,
|
||||
isGeneratingBackground,
|
||||
isGeneratingContainer,
|
||||
onSaved,
|
||||
profile,
|
||||
]);
|
||||
@@ -3906,92 +3708,6 @@ export function Match3DResultView({
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerateBackground = async (prompt: string) => {
|
||||
const normalizedPrompt = prompt.trim();
|
||||
if (!normalizedPrompt || isGeneratingBackground) {
|
||||
setBackgroundGenerationError('请填写画面描述提示词。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingBackground(true);
|
||||
setBackgroundGenerationProgress({
|
||||
startedAtMs: Date.now(),
|
||||
nowMs: Date.now(),
|
||||
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
|
||||
});
|
||||
setBackgroundGenerationError(null);
|
||||
try {
|
||||
const response = await generateMatch3DBackgroundImage(profile.profileId, {
|
||||
prompt: normalizedPrompt,
|
||||
});
|
||||
const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
|
||||
response.item.generatedItemAssets?.length
|
||||
? response.item.generatedItemAssets
|
||||
: generatedItemAssets,
|
||||
response.generatedBackgroundAsset,
|
||||
);
|
||||
const refreshedProfile = attachMatch3DGeneratedItemAssets(
|
||||
response.item,
|
||||
nextGeneratedAssets,
|
||||
);
|
||||
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
|
||||
onSaved?.(refreshedProfile);
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setBackgroundGenerationError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: 'UI背景图生成失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingBackground(false);
|
||||
setBackgroundGenerationProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateContainer = async (prompt: string) => {
|
||||
const normalizedPrompt = prompt.trim();
|
||||
if (!normalizedPrompt || isGeneratingContainer) {
|
||||
setContainerGenerationError('请填写容器形象提示词。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingContainer(true);
|
||||
setContainerGenerationProgress({
|
||||
startedAtMs: Date.now(),
|
||||
nowMs: Date.now(),
|
||||
estimateSeconds: MATCH3D_UI_BACKGROUND_GENERATION_ESTIMATE_SECONDS,
|
||||
});
|
||||
setContainerGenerationError(null);
|
||||
try {
|
||||
const response = await generateMatch3DContainerImage(profile.profileId, {
|
||||
prompt: normalizedPrompt,
|
||||
});
|
||||
const nextGeneratedAssets = attachMatch3DGeneratedBackgroundAsset(
|
||||
response.item.generatedItemAssets?.length
|
||||
? response.item.generatedItemAssets
|
||||
: generatedItemAssets,
|
||||
response.generatedBackgroundAsset,
|
||||
);
|
||||
const refreshedProfile = attachMatch3DGeneratedItemAssets(
|
||||
response.item,
|
||||
nextGeneratedAssets,
|
||||
);
|
||||
setAssetDrafts(createMatch3DAssetDrafts(refreshedProfile, null));
|
||||
onSaved?.(refreshedProfile);
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setContainerGenerationError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '容器形象生成失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingContainer(false);
|
||||
setContainerGenerationProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTestRun = async () => {
|
||||
if (!canStartTestRun || isStartingTestRun) {
|
||||
setLocalError(testRunBlockers[0] ?? null);
|
||||
@@ -4063,9 +3779,7 @@ export function Match3DResultView({
|
||||
isBusy ||
|
||||
isPublishing ||
|
||||
isStartingTestRun ||
|
||||
isGeneratingCover ||
|
||||
isGeneratingBackground ||
|
||||
isGeneratingContainer;
|
||||
isGeneratingCover;
|
||||
const workBusy = busy || isGeneratingTags;
|
||||
const displayError = error ?? localError;
|
||||
const dialogPublishError = hasAttemptedPublish ? error ?? localError : null;
|
||||
@@ -4104,17 +3818,11 @@ export function Match3DResultView({
|
||||
activeAssetId={activeAssetId}
|
||||
assetDrafts={assetDrafts}
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
containerPreviewSrc={containerPreviewSrc}
|
||||
backgroundPrompt={backgroundPrompt}
|
||||
containerPrompt={containerPrompt}
|
||||
uiSpritesheetPreviewSrc={uiSpritesheetPreviewSrc}
|
||||
itemSpritesheetPreviewSrc={itemSpritesheetPreviewSrc}
|
||||
itemNames={generatedItemNames}
|
||||
backgroundGenerationError={backgroundGenerationError}
|
||||
containerGenerationError={containerGenerationError}
|
||||
batchGenerationState={batchGenerationState}
|
||||
busy={busy}
|
||||
backgroundGenerationProgress={backgroundGenerationProgress}
|
||||
containerGenerationProgress={containerGenerationProgress}
|
||||
isGeneratingBackground={isGeneratingBackground}
|
||||
isGeneratingContainer={isGeneratingContainer}
|
||||
onActiveAssetChange={setActiveAssetId}
|
||||
onAddBatch={() => {
|
||||
setBatchAddError(null);
|
||||
@@ -4137,12 +3845,6 @@ export function Match3DResultView({
|
||||
onDeleteAsset={(assetId) => {
|
||||
void handleDeleteAssetDraft(assetId);
|
||||
}}
|
||||
onGenerateBackground={(prompt) => {
|
||||
void handleGenerateBackground(prompt);
|
||||
}}
|
||||
onGenerateContainer={(prompt) => {
|
||||
void handleGenerateContainer(prompt);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -173,7 +173,7 @@ const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
|
||||
const MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE = 1.9;
|
||||
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 25;
|
||||
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 20;
|
||||
const MATCH3D_SELECTED_MODEL_SCALE = 1.1;
|
||||
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
|
||||
new Set(['ring', 'arch']);
|
||||
|
||||
@@ -55,6 +55,9 @@ import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
const runtimeAudioFeedback = vi.hoisted(() => ({
|
||||
playRuntimeMergeSound: vi.fn(),
|
||||
}));
|
||||
const match3dSpritesheetParser = vi.hoisted(() => ({
|
||||
loadMatch3DSpritesheetAssetRegions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -65,6 +68,16 @@ vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/match3dSpritesheetParser', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/match3dSpritesheetParser')>();
|
||||
return {
|
||||
...actual,
|
||||
loadMatch3DSpritesheetAssetRegions:
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
@@ -96,6 +109,7 @@ afterEach(() => {
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1493,6 +1507,194 @@ test('运行态会从顶层 UI 资产加载背景和容器图', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
|
||||
['返回', '设置', '方格', '移出', '凑齐', '打乱'].map((label, index) => ({
|
||||
label,
|
||||
x: index * 10,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
sheetWidth: 64,
|
||||
sheetHeight: 64,
|
||||
imageSrc: `data:image/png;base64,${label}`,
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={[]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: '果园UI',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,返回');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,设置');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,移出');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-collect').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,凑齐');
|
||||
expect(
|
||||
screen.getByTestId('match3d-ui-sprite-prop-shuffle').getAttribute('src'),
|
||||
).toBe('data:image/png;base64,打乱');
|
||||
});
|
||||
expect(
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
labels: ['返回', '设置', '方格', '移出', '凑齐', '打乱'],
|
||||
maxRegions: 6,
|
||||
source: '/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态不把兼容写入的UI spritesheet当中心容器图', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockResolvedValue(
|
||||
[],
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
generatedItemAssets={[]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
uiSpritesheetPrompt: '果园UI',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
containerPrompt: '兼容UI素材',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-spritesheet/ui.png',
|
||||
containerImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('match3d-container-image').getAttribute('src'),
|
||||
).toBe('/match3d-background-references/pot-fused-reference.png');
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态缺少imageViews时从物品spritesheet解析五视角图片', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const selectedItem = run.items[0]!;
|
||||
const nextRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
items: run.items.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: selectedItem.itemInstanceId,
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
match3dSpritesheetParser.loadMatch3DSpritesheetAssetRegions.mockImplementation(
|
||||
async ({ source }: { source: string }) => {
|
||||
if (source.includes('item-spritesheet')) {
|
||||
return Array.from({ length: 100 }, (_, index) => ({
|
||||
label: `素材${index + 1}`,
|
||||
x: index * 2,
|
||||
y: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
sheetWidth: 200,
|
||||
sheetHeight: 2,
|
||||
imageSrc: `data:image/png;base64,item-${index + 1}`,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={nextRun}
|
||||
generatedItemAssets={[
|
||||
{
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc: null,
|
||||
imageObjectKey: null,
|
||||
imageViews: [],
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
status: 'image_ready',
|
||||
},
|
||||
]}
|
||||
generatedBackgroundAsset={{
|
||||
prompt: '果园纯背景',
|
||||
imageSrc: '/match3d/background.png',
|
||||
imageObjectKey: null,
|
||||
itemSpritesheetPrompt: '果园物品',
|
||||
itemSpritesheetImageSrc:
|
||||
'/generated-match3d-assets/session/profile/item-spritesheet/items.png',
|
||||
itemSpritesheetImageObjectKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByTestId('match3d-tray-image') as HTMLImageElement).src,
|
||||
).toBe('data:image/png;base64,item-1');
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态从任意素材读取作品级背景音乐并换签播放', async () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
const playSpy = vi
|
||||
@@ -1570,10 +1772,10 @@ test('本地试玩按难度档位生成类型并兼容历史硬核消除数', ()
|
||||
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(resolveLocalMatch3DItemTypeCount(20)).toBe(20);
|
||||
expect(resolveLocalMatch3DItemTypeCount(21)).toBe(20);
|
||||
expect(countTypes(smallRun)).toBe(9);
|
||||
expect(countTypes(hardRun)).toBe(21);
|
||||
expect(countTypes(hardRun)).toBe(20);
|
||||
expect(hardRun.clearCount).toBe(21);
|
||||
expect(hardRun.items).toHaveLength(63);
|
||||
});
|
||||
@@ -1593,9 +1795,9 @@ test('硬核档位生成不重复积木视觉签名', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(21);
|
||||
expect(visualKeys.size).toBe(21);
|
||||
expect(signatures.size).toBe(21);
|
||||
expect(firstItemByType.size).toBe(20);
|
||||
expect(visualKeys.size).toBe(20);
|
||||
expect(signatures.size).toBe(20);
|
||||
});
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
@@ -1690,7 +1892,7 @@ test('硬核档位按五档体积比例生成尺寸', () => {
|
||||
}
|
||||
|
||||
expect(tierCounts.get('XL')).toBe(4);
|
||||
expect(tierCounts.get('L')).toBe(7);
|
||||
expect(tierCounts.get('L')).toBe(6);
|
||||
expect(tierCounts.get('M')).toBe(6);
|
||||
expect(tierCounts.get('XS')).toBe(3);
|
||||
expect(tierCounts.get('S')).toBe(1);
|
||||
@@ -1706,7 +1908,7 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
radiiByVisualKey.set(item.visualKey, radii);
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(21);
|
||||
expect(radiiByVisualKey.size).toBe(20);
|
||||
expect(
|
||||
[...radiiByVisualKey.values()].every((radii) => radii.size === 1),
|
||||
).toBe(true);
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
getMatch3DGeneratedImageViewSources,
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
||||
} from '../../services/match3dGeneratedModelCache';
|
||||
import {
|
||||
buildMatch3DItemSpritesheetViewRegions,
|
||||
loadMatch3DSpritesheetAssetRegions,
|
||||
type Match3DDecodedSpritesheetRegion,
|
||||
} from '../../services/match3dSpritesheetParser';
|
||||
import {
|
||||
buildMatch3DTrayInsertionPlan,
|
||||
resolveMatch3DTrayItemIdToSlotIndexMap,
|
||||
@@ -163,6 +168,12 @@ type Match3DTrayClearAnimation = {
|
||||
centerY: number;
|
||||
};
|
||||
|
||||
type Match3DItemSpritesheetViewGroup = {
|
||||
itemIndex: number;
|
||||
itemName: string;
|
||||
regions: Match3DDecodedSpritesheetRegion[];
|
||||
};
|
||||
|
||||
function resolveTrayPreviewItem(
|
||||
run: Match3DRunSnapshot,
|
||||
slot: Match3DTraySlot,
|
||||
@@ -187,6 +198,19 @@ const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
|
||||
const EMPTY_MATCH3D_GENERATED_ITEM_ASSETS: Match3DGeneratedItemAsset[] = [];
|
||||
const MATCH3D_CONTAINER_REFERENCE_SRC =
|
||||
'/match3d-background-references/pot-fused-reference.png';
|
||||
const MATCH3D_UI_SPRITESHEET_LABELS = [
|
||||
'返回',
|
||||
'设置',
|
||||
'方格',
|
||||
'移出',
|
||||
'凑齐',
|
||||
'打乱',
|
||||
] as const;
|
||||
const MATCH3D_PROP_BUTTONS = [
|
||||
['移出', 'match3d-ui-sprite-prop-remove'],
|
||||
['凑齐', 'match3d-ui-sprite-prop-collect'],
|
||||
['打乱', 'match3d-ui-sprite-prop-shuffle'],
|
||||
] as const;
|
||||
|
||||
function formatTimer(value: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
||||
@@ -228,9 +252,9 @@ function resolveBoardPointFromPointerEvent(
|
||||
}
|
||||
|
||||
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)) {
|
||||
const leftIndex = resolveMatch3DGeneratedItemIndex(left);
|
||||
const rightIndex = resolveMatch3DGeneratedItemIndex(right);
|
||||
if (leftIndex !== null && rightIndex !== null) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
@@ -243,13 +267,17 @@ function resolveMatch3DGeneratedTypeIds(run: Match3DRunSnapshot) {
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedItemIndex(value: string | null | undefined) {
|
||||
const parsed = Number.parseInt(value?.match(/(\d+)$/u)?.[1] ?? '', 10);
|
||||
const parsed = Number.parseInt(
|
||||
value?.match(/^match3d-(?:item|type)-0*(\d+)$/u)?.[1] ?? '',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : null;
|
||||
}
|
||||
|
||||
function buildMatch3DImageSourcesByType(
|
||||
run: Match3DRunSnapshot | null,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
itemSpritesheetViewGroups: readonly Match3DItemSpritesheetViewGroup[] = [],
|
||||
) {
|
||||
if (!run) {
|
||||
return new Map<string, string[]>();
|
||||
@@ -267,6 +295,20 @@ function buildMatch3DImageSourcesByType(
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const parsedAssets = itemSpritesheetViewGroups.flatMap((group) => {
|
||||
const sources = group.regions
|
||||
.map((region) => region.imageSrc.trim())
|
||||
.filter(Boolean);
|
||||
return sources.length > 0
|
||||
? [
|
||||
{
|
||||
fallbackIndex: group.itemIndex,
|
||||
itemIndex: group.itemIndex,
|
||||
sources,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
return new Map(
|
||||
typeIds.flatMap((typeId, index) => {
|
||||
@@ -274,12 +316,99 @@ function buildMatch3DImageSourcesByType(
|
||||
const asset =
|
||||
readyAssets.find(
|
||||
(entry) => directIndex !== null && entry.itemIndex === directIndex,
|
||||
) ?? readyAssets.find((entry) => entry.fallbackIndex === index);
|
||||
) ??
|
||||
readyAssets.find((entry) => entry.fallbackIndex === index) ??
|
||||
parsedAssets.find(
|
||||
(entry) => directIndex !== null && entry.itemIndex === directIndex,
|
||||
) ??
|
||||
parsedAssets.find((entry) => entry.fallbackIndex === index);
|
||||
return asset ? [[typeId, asset.sources] as const] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DUiSpritesheetSource(
|
||||
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return (
|
||||
generatedBackgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
generatedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.uiSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.uiSpritesheetImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DItemSpritesheetSource(
|
||||
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return (
|
||||
generatedBackgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
generatedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.itemSpritesheetImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.itemSpritesheetImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedContainerSource(
|
||||
generatedBackgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
const normalize = (value: string | null | undefined) =>
|
||||
value?.trim().replace(/^\/+/u, '') ?? '';
|
||||
const resolveAssetContainerSource = (
|
||||
asset: Match3DGeneratedBackgroundAsset | null | undefined,
|
||||
) => {
|
||||
const containerSrc = asset?.containerImageSrc?.trim() || '';
|
||||
const containerObjectKey = asset?.containerImageObjectKey?.trim() || '';
|
||||
const uiSrc = asset?.uiSpritesheetImageSrc?.trim() || '';
|
||||
const uiObjectKey = asset?.uiSpritesheetImageObjectKey?.trim() || '';
|
||||
|
||||
if (
|
||||
normalize(containerSrc) &&
|
||||
normalize(containerSrc) !== normalize(uiSrc)
|
||||
) {
|
||||
return containerSrc;
|
||||
}
|
||||
if (
|
||||
normalize(containerObjectKey) &&
|
||||
normalize(containerObjectKey) !== normalize(uiObjectKey)
|
||||
) {
|
||||
return containerObjectKey;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
resolveAssetContainerSource(generatedBackgroundAsset) ||
|
||||
generatedItemAssets
|
||||
.map((asset) => resolveAssetContainerSource(asset.backgroundAsset))
|
||||
.find(Boolean) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function indexMatch3DUiSpritesheetRegions(
|
||||
regions: readonly Match3DDecodedSpritesheetRegion[],
|
||||
) {
|
||||
return new Map(regions.map((region) => [region.label, region]));
|
||||
}
|
||||
|
||||
function resolveMatch3DImageReadUrlCacheKey(
|
||||
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
|
||||
) {
|
||||
@@ -598,6 +727,31 @@ function Match3DToken({
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DSpriteImage({
|
||||
region,
|
||||
testId,
|
||||
className = 'h-full w-full object-contain',
|
||||
}: {
|
||||
region: Match3DDecodedSpritesheetRegion | null | undefined;
|
||||
testId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!region?.imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={region.imageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
data-testid={testId}
|
||||
className={className}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({
|
||||
slot,
|
||||
imageSrc,
|
||||
@@ -883,6 +1037,32 @@ export function Match3DRuntimeShell({
|
||||
() => normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
|
||||
[generatedItemAssets],
|
||||
);
|
||||
const uiSpritesheetSource = useMemo(
|
||||
() =>
|
||||
resolveMatch3DUiSpritesheetSource(
|
||||
generatedBackgroundAsset,
|
||||
runtimeGeneratedItemAssets,
|
||||
),
|
||||
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
|
||||
);
|
||||
const itemSpritesheetSource = useMemo(
|
||||
() =>
|
||||
resolveMatch3DItemSpritesheetSource(
|
||||
generatedBackgroundAsset,
|
||||
runtimeGeneratedItemAssets,
|
||||
),
|
||||
[generatedBackgroundAsset, runtimeGeneratedItemAssets],
|
||||
);
|
||||
const [uiSpritesheetRegions, setUiSpritesheetRegions] = useState<
|
||||
Match3DDecodedSpritesheetRegion[]
|
||||
>([]);
|
||||
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
|
||||
Match3DItemSpritesheetViewGroup[]
|
||||
>([]);
|
||||
const uiSpritesheetRegionByLabel = useMemo(
|
||||
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
|
||||
[uiSpritesheetRegions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeftMs(run?.remainingMs ?? 0);
|
||||
@@ -904,6 +1084,80 @@ export function Match3DRuntimeShell({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [onTimeExpired, run]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiSpritesheetSource) {
|
||||
setUiSpritesheetRegions((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
void loadMatch3DSpritesheetAssetRegions({
|
||||
source: uiSpritesheetSource,
|
||||
labels: MATCH3D_UI_SPRITESHEET_LABELS,
|
||||
maxRegions: MATCH3D_UI_SPRITESHEET_LABELS.length,
|
||||
minArea: 16,
|
||||
alphaThreshold: 8,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((regions) => {
|
||||
if (!cancelled) {
|
||||
setUiSpritesheetRegions(regions);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setUiSpritesheetRegions([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [uiSpritesheetSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemSpritesheetSource) {
|
||||
setItemSpritesheetViewGroups((current) =>
|
||||
current.length > 0 ? [] : current,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
void loadMatch3DSpritesheetAssetRegions({
|
||||
source: itemSpritesheetSource,
|
||||
maxRegions: 100,
|
||||
minArea: 16,
|
||||
alphaThreshold: 8,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((regions) => {
|
||||
if (!cancelled) {
|
||||
setItemSpritesheetViewGroups(
|
||||
buildMatch3DItemSpritesheetViewRegions(
|
||||
regions,
|
||||
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
|
||||
),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setItemSpritesheetViewGroups([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [itemSpritesheetSource, runtimeGeneratedItemAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedbackEvent) {
|
||||
return undefined;
|
||||
@@ -1058,23 +1312,20 @@ export function Match3DRuntimeShell({
|
||||
)
|
||||
.find(Boolean) ||
|
||||
'';
|
||||
const generatedContainerAssetSrc =
|
||||
generatedBackgroundAsset?.containerImageSrc?.trim() ||
|
||||
generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
runtimeGeneratedItemAssets
|
||||
.map(
|
||||
(asset) =>
|
||||
asset.backgroundAsset?.containerImageSrc?.trim() ||
|
||||
asset.backgroundAsset?.containerImageObjectKey?.trim() ||
|
||||
'',
|
||||
)
|
||||
.find(Boolean) ||
|
||||
'';
|
||||
const generatedContainerAssetSrc = resolveMatch3DGeneratedContainerSource(
|
||||
generatedBackgroundAsset,
|
||||
runtimeGeneratedItemAssets,
|
||||
);
|
||||
const containerAssetSrc =
|
||||
generatedContainerAssetSrc || MATCH3D_CONTAINER_REFERENCE_SRC;
|
||||
const imageSourcesByType = useMemo(
|
||||
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
|
||||
[runtimeGeneratedItemAssets, run],
|
||||
() =>
|
||||
buildMatch3DImageSourcesByType(
|
||||
run,
|
||||
runtimeGeneratedItemAssets,
|
||||
itemSpritesheetViewGroups,
|
||||
),
|
||||
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
|
||||
);
|
||||
const itemSizeByType = useMemo(
|
||||
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
|
||||
@@ -1731,7 +1982,14 @@ export function Match3DRuntimeShell({
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<Match3DSpriteImage
|
||||
region={uiSpritesheetRegionByLabel.get('返回')}
|
||||
testId="match3d-ui-sprite-back"
|
||||
className="h-7 w-7 object-contain"
|
||||
/>
|
||||
{!uiSpritesheetRegionByLabel.get('返回') ? (
|
||||
<ArrowLeft size={20} />
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
<div className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
|
||||
@@ -1752,7 +2010,14 @@ export function Match3DRuntimeShell({
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开抓大鹅设置"
|
||||
>
|
||||
<Settings size={18} />
|
||||
<Match3DSpriteImage
|
||||
region={uiSpritesheetRegionByLabel.get('设置')}
|
||||
testId="match3d-ui-sprite-settings"
|
||||
className="h-7 w-7 object-contain"
|
||||
/>
|
||||
{!uiSpritesheetRegionByLabel.get('设置') ? (
|
||||
<Settings size={18} />
|
||||
) : null}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -1849,6 +2114,11 @@ export function Match3DRuntimeShell({
|
||||
traySlotRefs.current[slot.slotIndex] = element;
|
||||
}}
|
||||
>
|
||||
<Match3DSpriteImage
|
||||
region={uiSpritesheetRegionByLabel.get('方格')}
|
||||
testId={`match3d-ui-sprite-grid-${slot.slotIndex}`}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-fill"
|
||||
/>
|
||||
<Match3DTrayToken
|
||||
slot={slot}
|
||||
isArriving={
|
||||
@@ -1889,6 +2159,30 @@ export function Match3DRuntimeShell({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="relative z-10 mt-2 grid grid-cols-3 gap-2"
|
||||
aria-label="抓大鹅道具"
|
||||
>
|
||||
{MATCH3D_PROP_BUTTONS.map(([label, testId]) => {
|
||||
const region = uiSpritesheetRegionByLabel.get(label);
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="flex min-h-12 items-center justify-center overflow-hidden rounded-[1rem] border border-white/58 bg-white/50 px-2 py-2 text-sm font-black text-slate-800 shadow-[0_10px_24px_rgba(15,23,42,0.14)] backdrop-blur-md"
|
||||
aria-label={label}
|
||||
>
|
||||
<Match3DSpriteImage
|
||||
region={region}
|
||||
testId={testId}
|
||||
className="h-10 w-full object-contain"
|
||||
/>
|
||||
{!region ? label : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{feedbackEvent?.kind === 'rejected' ? (
|
||||
|
||||
@@ -1595,10 +1595,23 @@ function parseDraftGenerationStartedAtMs(value: string | null | undefined) {
|
||||
function createMiniGameDraftGenerationStateFromStartedAt(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
startedAtMs: number,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(kind),
|
||||
startedAtMs,
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createPuzzleDraftGenerationStateFromPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null | undefined,
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...createMiniGameDraftGenerationState('puzzle'),
|
||||
metadata: {
|
||||
puzzleAiRedraw: payload?.aiRedraw ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4499,7 +4512,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
markPendingDraftGenerating('puzzle', session.sessionId);
|
||||
selectionStageRef.current = 'puzzle-generating';
|
||||
setSelectionStage('puzzle-generating');
|
||||
const nextGenerationState = createMiniGameDraftGenerationState('puzzle');
|
||||
const nextGenerationState = createPuzzleDraftGenerationStateFromPayload(
|
||||
formPayload ?? buildPuzzleFormPayloadFromSession(session),
|
||||
);
|
||||
setPuzzleGenerationState(nextGenerationState);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
@@ -4515,14 +4530,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (payload.action !== 'compile_puzzle_draft') {
|
||||
return;
|
||||
}
|
||||
const generationState =
|
||||
puzzleBackgroundCompileTasks[session.sessionId]?.generationState ??
|
||||
puzzleGenerationState ??
|
||||
createMiniGameDraftGenerationState('puzzle');
|
||||
const formPayload =
|
||||
buildPuzzleFormPayloadFromAction(payload) ??
|
||||
puzzleBackgroundCompileTasks[session.sessionId]?.payload ??
|
||||
buildPuzzleFormPayloadFromSession(session);
|
||||
const generationState =
|
||||
puzzleBackgroundCompileTasks[session.sessionId]?.generationState ??
|
||||
puzzleGenerationState ??
|
||||
createPuzzleDraftGenerationStateFromPayload(formPayload);
|
||||
const recovered = await recoverCompletedPuzzleDraftGeneration({
|
||||
sessionId: session.sessionId,
|
||||
payload: formPayload,
|
||||
@@ -5017,7 +5032,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const generationState = createMiniGameDraftGenerationState('puzzle');
|
||||
const generationState = createPuzzleDraftGenerationStateFromPayload(payload);
|
||||
setPuzzleBackgroundCompileTasks((current) => ({
|
||||
...current,
|
||||
[nextSession.sessionId]: {
|
||||
@@ -7497,7 +7512,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const startPuzzleTestRunFromDraft = useCallback(
|
||||
async (draft: PuzzleResultDraft) => {
|
||||
async (
|
||||
draft: PuzzleResultDraft,
|
||||
options: { levelId?: string | null } = {},
|
||||
) => {
|
||||
if (isPuzzleBusy) {
|
||||
return false;
|
||||
}
|
||||
@@ -7526,7 +7544,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
coverAssetId: draft.coverAssetId,
|
||||
levels: draft.levels ?? [],
|
||||
});
|
||||
const run = startLocalPuzzleRun(item);
|
||||
const run = startLocalPuzzleRun(item, options.levelId ?? null);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
@@ -9286,6 +9304,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
createMiniGameDraftGenerationStateFromStartedAt(
|
||||
'puzzle',
|
||||
parseDraftGenerationStartedAtMs(item.updatedAt),
|
||||
{
|
||||
puzzleAiRedraw:
|
||||
buildPuzzleFormPayloadFromSession(latestSession).aiRedraw ??
|
||||
true,
|
||||
},
|
||||
),
|
||||
);
|
||||
enterCreateTab();
|
||||
|
||||
@@ -60,9 +60,25 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function stubReferenceImageUpload(dataUrl: string) {
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = dataUrl;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
}
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
@@ -102,6 +118,17 @@ function createSession(
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '雨夜猫街',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
pictureReference: null,
|
||||
uiBackgroundPrompt: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
levelSceneImageSrc: null,
|
||||
levelSceneImageObjectKey: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
levelBackgroundImageObjectKey: null,
|
||||
backgroundMusic: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
@@ -170,7 +197,7 @@ function openPuzzleLevelsTab() {
|
||||
}
|
||||
|
||||
describe('PuzzleResultView', () => {
|
||||
test('renders level list and work info tabs', () => {
|
||||
test('renders level list and work info tabs without asset config tab', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
@@ -182,18 +209,13 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
const workInfoTab = screen.getByRole('button', { name: '作品信息' });
|
||||
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
|
||||
const assetsTab = screen.getByRole('button', { name: '素材配置' });
|
||||
expect(workInfoTab).toBeTruthy();
|
||||
expect(levelsTab).toBeTruthy();
|
||||
expect(assetsTab).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(
|
||||
workInfoTab.compareDocumentPosition(levelsTab) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
levelsTab.compareDocumentPosition(assetsTab) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
|
||||
expect(screen.getByLabelText('作品名称')).toHaveProperty(
|
||||
'value',
|
||||
@@ -236,6 +258,62 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level detail trial keeps the complete draft and only selects the target level', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
const secondLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '钟楼猫街',
|
||||
pictureDescription: '发光钟楼下的猫咪。',
|
||||
candidates: [
|
||||
{
|
||||
...firstLevel.candidates[0]!,
|
||||
candidateId: 'candidate-2',
|
||||
imageSrc: '/puzzle/candidate-2.png',
|
||||
assetId: 'asset-2',
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-2',
|
||||
coverImageSrc: '/puzzle/candidate-2.png',
|
||||
coverAssetId: 'asset-2',
|
||||
};
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, secondLevel],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('钟楼猫街'));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '关卡详情' })).getByRole(
|
||||
'button',
|
||||
{ name: '关卡测试' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levels: [
|
||||
expect.objectContaining({ levelId: 'puzzle-level-1' }),
|
||||
expect.objectContaining({ levelId: 'puzzle-level-2' }),
|
||||
],
|
||||
}),
|
||||
{ levelId: 'puzzle-level-2' },
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves work info and levels through one payload', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
@@ -294,6 +372,8 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(dialog.className).toContain('max-w-[56rem]');
|
||||
expect(dialog.querySelector('.puzzle-level-detail-list')).toBeTruthy();
|
||||
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
@@ -316,6 +396,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -334,7 +415,7 @@ describe('PuzzleResultView', () => {
|
||||
generationStatus: 'generating',
|
||||
}),
|
||||
]);
|
||||
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(within(dialog).getByText('预计剩余 270 秒')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
|
||||
).toBeNull();
|
||||
@@ -343,7 +424,15 @@ describe('PuzzleResultView', () => {
|
||||
|
||||
const levelNameInput = within(dialog).getByLabelText('关卡名称');
|
||||
const formalImageTitle = within(dialog).getByText('画面图');
|
||||
const formalImageCard = formalImageTitle
|
||||
.closest('.creative-image-input-panel__image-field')
|
||||
?.querySelector('.puzzle-image-upload-card');
|
||||
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
|
||||
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
|
||||
expect(pictureDescriptionInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageCard).toBeTruthy();
|
||||
expect(formalImageCard?.className).toContain('min-h-[');
|
||||
expect(
|
||||
levelNameInput.compareDocumentPosition(formalImageTitle) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
@@ -365,6 +454,7 @@ describe('PuzzleResultView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
{ levelId: 'puzzle-level-1' },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -463,6 +553,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: undefined,
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: true,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -552,7 +643,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(
|
||||
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
|
||||
).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
|
||||
expect(within(reopenedDialog).getByText('预计剩余 270 秒')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
|
||||
@@ -586,6 +677,7 @@ describe('PuzzleResultView', () => {
|
||||
expect.objectContaining({
|
||||
levelName: '继续编辑的猫街',
|
||||
}),
|
||||
{ levelId: 'puzzle-level-1' },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('关闭'));
|
||||
@@ -602,7 +694,7 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps level controls enabled while regenerating the UI background', () => {
|
||||
test('asset config tab is removed and cannot trigger the legacy UI background action', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -614,76 +706,20 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '雨夜猫街竖屏拼图UI背景',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /生成中/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
|
||||
expect(addLevelButton).toHaveProperty('disabled', false);
|
||||
fireEvent.click(addLevelButton);
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('restores UI background generate button when background generation fails', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
expect(onExecuteAction).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
error="UI背景生成失败"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
|
||||
expect(generateButton).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
@@ -899,7 +935,8 @@ describe('PuzzleResultView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders UI background tab with saved prompt and runtime preview', () => {
|
||||
test('preserves generated level asset bundle in test run draft', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
@@ -911,133 +948,53 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'雨夜猫街竖屏拼图UI背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
|
||||
const preview = screen.getByRole('dialog', { name: 'UI预览' });
|
||||
expect(
|
||||
within(preview)
|
||||
.getByTestId('puzzle-ui-runtime-preview-background')
|
||||
.getAttribute('src'),
|
||||
).toBe('/generated-puzzle-assets/session/ui/background.png');
|
||||
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI背景只有 objectKey 时草稿页仍显示生成图', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
uiBackgroundPrompt: '雨夜猫街竖屏拼图UI背景',
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background-object-key.png',
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /重新生成/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('does not display local fallback as saved UI background prompt', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByLabelText('拼图UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('generates UI background with edited prompt and current levels snapshot', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新拼图UI背景提示词' },
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /生成UI背景.*2泥点/u })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
const confirmDialog = screen.getByRole('dialog', {
|
||||
name: '确认消耗泥点',
|
||||
});
|
||||
expect(within(confirmDialog).getByText('消耗 2 泥点')).toBeTruthy();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
|
||||
|
||||
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(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新拼图UI背景提示词',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
);
|
||||
});
|
||||
|
||||
test('素材配置隐藏背景音乐入口', () => {
|
||||
test('does not expose music or standalone UI asset controls', () => {
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
@@ -1068,30 +1025,39 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /重新生成音乐/u })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图背景音乐')).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图UI背景提示词')).toBeNull();
|
||||
});
|
||||
|
||||
test('生成完成回包合并历史音乐和UI背景后试玩使用最新资源', () => {
|
||||
test('生成完成回包合并历史音乐和关卡资产后试玩使用最新资源', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const base = createSession();
|
||||
const localLevel = {
|
||||
...base.draft!.levels![0]!,
|
||||
generationStatus: 'generating' as const,
|
||||
uiBackgroundPrompt: '旧的UI背景提示词',
|
||||
uiBackgroundImageSrc: null,
|
||||
levelSceneImageSrc: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
backgroundMusic: null,
|
||||
};
|
||||
const incomingLevel = {
|
||||
...localLevel,
|
||||
generationStatus: 'ready' as const,
|
||||
uiBackgroundPrompt: '水果乐园UI背景',
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
uiBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
levelSceneImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background-fruit.png',
|
||||
levelBackgroundImageObjectKey:
|
||||
'generated-puzzle-assets/session/level-background-fruit.png',
|
||||
backgroundMusic: {
|
||||
taskId: 'music-task-fruit',
|
||||
provider: 'vector-engine-suno',
|
||||
@@ -1135,20 +1101,18 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '背景音乐' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/fruit-background.png',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene-fruit.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet-fruit.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background-fruit.png',
|
||||
backgroundMusic: expect.objectContaining({
|
||||
audioSrc: '/generated-puzzle-assets/session/audio/fruit.mp3',
|
||||
}),
|
||||
@@ -1158,24 +1122,42 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('auto saves UI background prompt edits through levels', async () => {
|
||||
test('auto saves generated level asset bundle through levels', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
|
||||
item: {} as never,
|
||||
});
|
||||
const base = createSession();
|
||||
const level = base.draft!.levels![0]!;
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [
|
||||
{
|
||||
...level,
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
profileId="puzzle-profile-session-1"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '新的自动保存UI背景提示词' },
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
fireEvent.change(screen.getByLabelText('关卡名称'), {
|
||||
target: { value: '雨夜猫街新版' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -1188,7 +1170,13 @@ describe('PuzzleResultView', () => {
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
uiBackgroundPrompt: '新的自动保存UI背景提示词',
|
||||
levelName: '雨夜猫街新版',
|
||||
levelSceneImageSrc:
|
||||
'/generated-puzzle-assets/session/level-scene.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background.png',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -1222,10 +1210,11 @@ describe('PuzzleResultView', () => {
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getByLabelText('上传参考图', {
|
||||
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
|
||||
})[0]!;
|
||||
expect(uploadInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(uploadInput.closest('.puzzle-level-detail-list')).toBeTruthy();
|
||||
const historyButton = within(dialog).getByRole('button', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
@@ -1245,7 +1234,9 @@ describe('PuzzleResultView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
expect(screen.getByText('历史素材 · image.png')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('拼图参考图').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/history/image.png',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
@@ -1261,6 +1252,7 @@ describe('PuzzleResultView', () => {
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
referenceImageSrcs: [],
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
@@ -1461,6 +1453,110 @@ describe('PuzzleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor submits uploaded image directly when AI redraw is off', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,level-upload';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const uploadInput = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
})[0]!;
|
||||
fireEvent.change(uploadInput, {
|
||||
target: {
|
||||
files: [new File(['x'], 'level-upload.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
|
||||
});
|
||||
expect(within(dialog).getByAltText('拼图参考图')).toHaveProperty(
|
||||
'src',
|
||||
uploadedDataUrl,
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('switch', { name: 'AI重绘' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
referenceImageSrc: uploadedDataUrl,
|
||||
referenceImageSrcs: [],
|
||||
aiRedraw: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor uploads prompt reference images from the description box', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,level-reference';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('雨夜猫街'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
const referenceInputs = within(dialog).getAllByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
expect(referenceInputs.length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.change(referenceInputs[referenceInputs.length - 1]!, {
|
||||
target: {
|
||||
files: [new File(['x'], 'prompt-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: /预览参考图 prompt-reference\.png/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1',
|
||||
referenceImageSrc: undefined,
|
||||
referenceImageSrcs: [uploadedDataUrl],
|
||||
aiRedraw: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('level image editor hides AI redraw controls when only the formal image is shown', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -1480,7 +1576,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(within(dialog).getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('UI background generator reuses common image input UI without sharing level image fields', () => {
|
||||
test('standalone UI background generator stays removed from the result page', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -1491,40 +1587,11 @@ describe('PuzzleResultView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
|
||||
expect(screen.getByText('UI背景预览')).toBeTruthy();
|
||||
expect(screen.getByLabelText('UI背景提示词')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('UI背景提示词'), {
|
||||
target: { value: '独立的草稿UI背景提示词' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
);
|
||||
const payload = onExecuteAction.mock.calls[0]![0];
|
||||
expect(payload).not.toHaveProperty('referenceImageSrc');
|
||||
expect(payload).not.toHaveProperty('aiRedraw');
|
||||
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
pictureDescription: '屋檐下的猫与暖灯街角。',
|
||||
uiBackgroundPrompt: '独立的草稿UI背景提示词',
|
||||
}),
|
||||
]);
|
||||
expect(screen.queryByRole('button', { name: '素材配置' })).toBeNull();
|
||||
expect(screen.queryByText('UI背景预览')).toBeNull();
|
||||
expect(screen.queryByLabelText('UI背景提示词')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /生成UI背景/u })).toBeNull();
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows creative agent draft edit bar and submits the current draft', () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Play,
|
||||
@@ -21,7 +19,6 @@ import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
@@ -42,7 +39,10 @@ type PuzzleResultViewProps = {
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
|
||||
onStartTestRun?: (draft: PuzzleResultDraft) => void;
|
||||
onStartTestRun?: (
|
||||
draft: PuzzleResultDraft,
|
||||
options?: { levelId?: string | null },
|
||||
) => void;
|
||||
creativeDraftEdit?: {
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
@@ -54,8 +54,7 @@ type PuzzleResultViewProps = {
|
||||
};
|
||||
|
||||
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type PuzzleResultTab = 'levels' | 'work' | 'assets';
|
||||
type PuzzleAssetConfigTabId = 'ui';
|
||||
type PuzzleResultTab = 'levels' | 'work';
|
||||
|
||||
type DraftEditState = {
|
||||
workTitle: string;
|
||||
@@ -70,20 +69,15 @@ const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
|
||||
const PUZZLE_PUBLISH_POINT_COST = 1;
|
||||
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
|
||||
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
|
||||
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
|
||||
const PUZZLE_LEVEL_ASSET_BUNDLE_ESTIMATE_SECONDS =
|
||||
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS * 3;
|
||||
const PUZZLE_LEVEL_DIRECT_UPLOAD_ESTIMATE_SECONDS =
|
||||
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS * 2;
|
||||
const PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT = 5;
|
||||
|
||||
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
|
||||
{ id: 'work', label: '作品信息' },
|
||||
{ id: 'levels', label: '拼图关卡' },
|
||||
{ id: 'assets', label: '素材配置' },
|
||||
];
|
||||
|
||||
const PUZZLE_ASSET_CONFIG_TABS: Array<{
|
||||
id: PuzzleAssetConfigTabId;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'ui', label: 'UI' },
|
||||
];
|
||||
|
||||
type PuzzleLevelGenerationRuntime = {
|
||||
@@ -91,10 +85,13 @@ type PuzzleLevelGenerationRuntime = {
|
||||
estimateSeconds: number;
|
||||
};
|
||||
|
||||
type PuzzleUiBackgroundGenerationState = {
|
||||
levelId: string;
|
||||
prompt: string;
|
||||
} | null;
|
||||
function resolvePuzzleLevelGenerationEstimateSeconds(options?: {
|
||||
aiRedraw?: boolean | null;
|
||||
}) {
|
||||
return options?.aiRedraw === false
|
||||
? PUZZLE_LEVEL_DIRECT_UPLOAD_ESTIMATE_SECONDS
|
||||
: PUZZLE_LEVEL_ASSET_BUNDLE_ESTIMATE_SECONDS;
|
||||
}
|
||||
|
||||
function resolvePuzzleLevelGenerationProgress(
|
||||
level: PuzzleDraftLevel,
|
||||
@@ -141,26 +138,6 @@ 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(
|
||||
@@ -208,6 +185,12 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
|
||||
uiBackgroundPrompt: level.uiBackgroundPrompt ?? null,
|
||||
uiBackgroundImageSrc: level.uiBackgroundImageSrc ?? null,
|
||||
uiBackgroundImageObjectKey: level.uiBackgroundImageObjectKey ?? null,
|
||||
levelSceneImageSrc: level.levelSceneImageSrc ?? null,
|
||||
levelSceneImageObjectKey: level.levelSceneImageObjectKey ?? null,
|
||||
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc ?? null,
|
||||
uiSpritesheetImageObjectKey: level.uiSpritesheetImageObjectKey ?? null,
|
||||
levelBackgroundImageSrc: level.levelBackgroundImageSrc ?? null,
|
||||
levelBackgroundImageObjectKey: level.levelBackgroundImageObjectKey ?? null,
|
||||
candidates: level.candidates ?? [],
|
||||
selectedCandidateId: level.selectedCandidateId ?? null,
|
||||
coverImageSrc: level.coverImageSrc ?? null,
|
||||
@@ -296,6 +279,21 @@ function mergeDraftEditStateWithIncomingState(
|
||||
uiBackgroundImageObjectKey:
|
||||
incomingLevel.uiBackgroundImageObjectKey ??
|
||||
level.uiBackgroundImageObjectKey,
|
||||
levelSceneImageSrc:
|
||||
incomingLevel.levelSceneImageSrc ?? level.levelSceneImageSrc,
|
||||
levelSceneImageObjectKey:
|
||||
incomingLevel.levelSceneImageObjectKey ??
|
||||
level.levelSceneImageObjectKey,
|
||||
uiSpritesheetImageSrc:
|
||||
incomingLevel.uiSpritesheetImageSrc ?? level.uiSpritesheetImageSrc,
|
||||
uiSpritesheetImageObjectKey:
|
||||
incomingLevel.uiSpritesheetImageObjectKey ??
|
||||
level.uiSpritesheetImageObjectKey,
|
||||
levelBackgroundImageSrc:
|
||||
incomingLevel.levelBackgroundImageSrc ?? level.levelBackgroundImageSrc,
|
||||
levelBackgroundImageObjectKey:
|
||||
incomingLevel.levelBackgroundImageObjectKey ??
|
||||
level.levelBackgroundImageObjectKey,
|
||||
backgroundMusic: incomingLevel.backgroundMusic ?? level.backgroundMusic,
|
||||
generationStatus: incomingLevel.generationStatus || 'ready',
|
||||
};
|
||||
@@ -323,6 +321,12 @@ function createBlankPuzzleLevel(
|
||||
uiBackgroundPrompt: null,
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
levelSceneImageSrc: null,
|
||||
levelSceneImageObjectKey: null,
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
levelBackgroundImageObjectKey: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
@@ -431,7 +435,7 @@ 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-2 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
|
||||
{PUZZLE_RESULT_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -451,34 +455,6 @@ function PuzzleResultTabs({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleAssetConfigTabs({
|
||||
activeTab,
|
||||
onChange,
|
||||
}: {
|
||||
activeTab: PuzzleAssetConfigTabId;
|
||||
onChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
|
||||
{PUZZLE_ASSET_CONFIG_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
|
||||
: 'text-[var(--platform-text-base)] hover:bg-white/60'
|
||||
}`}
|
||||
aria-pressed={activeTab === tab.id}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleThemeTagEditor({
|
||||
editState,
|
||||
isBusy,
|
||||
@@ -656,11 +632,13 @@ function PuzzleLevelDetailDialog({
|
||||
level: PuzzleDraftLevel,
|
||||
promptText?: string | null,
|
||||
referenceImageSrc?: string | null,
|
||||
referenceImageSrcs?: string[],
|
||||
imageModel?: PuzzleImageModelId | null,
|
||||
aiRedraw?: boolean | null,
|
||||
estimateSeconds?: number,
|
||||
) => void;
|
||||
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
|
||||
onStartTestRun?: (level: PuzzleDraftLevel) => void;
|
||||
onStartTestRun?: (levelId: string) => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
@@ -668,6 +646,9 @@ function PuzzleLevelDetailDialog({
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [promptReferenceImages, setPromptReferenceImages] = useState<
|
||||
Array<{ id: string; label: string; imageSrc: string }>
|
||||
>([]);
|
||||
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
|
||||
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
|
||||
@@ -676,12 +657,23 @@ function PuzzleLevelDetailDialog({
|
||||
const [aiRedraw, setAiRedraw] = useState(true);
|
||||
const formalImageSrc = resolveLevelFormalImageSrc(level);
|
||||
const hasFormalImage = Boolean(formalImageSrc);
|
||||
const explicitReferenceImageSrc = referenceImageSrc.trim();
|
||||
const savedReferenceImageSrc = level.pictureReference?.trim() || '';
|
||||
const effectiveReferenceImageSrc =
|
||||
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
|
||||
const displayImageSrc = formalImageSrc || effectiveReferenceImageSrc;
|
||||
const displayImageAlt = formalImageSrc
|
||||
? level.levelName || draft.workTitle || '拼图关卡'
|
||||
: '拼图参考图';
|
||||
explicitReferenceImageSrc || savedReferenceImageSrc;
|
||||
const promptReferenceImageSrcs = effectiveReferenceImageSrc
|
||||
? []
|
||||
: promptReferenceImages.map((image) => image.imageSrc);
|
||||
const displayImageSrc =
|
||||
explicitReferenceImageSrc || formalImageSrc || savedReferenceImageSrc;
|
||||
const displayImageAlt = explicitReferenceImageSrc
|
||||
? '拼图参考图'
|
||||
: formalImageSrc
|
||||
? level.levelName || draft.workTitle || '拼图关卡'
|
||||
: '拼图参考图';
|
||||
const shouldShowReferenceMeta = Boolean(
|
||||
effectiveReferenceImageSrc && displayImageSrc !== effectiveReferenceImageSrc,
|
||||
);
|
||||
const generationProgress = resolvePuzzleLevelGenerationProgress(
|
||||
level,
|
||||
generationRuntime,
|
||||
@@ -706,6 +698,7 @@ function PuzzleLevelDetailDialog({
|
||||
const executeGeneration = () => {
|
||||
const nextLevel = {
|
||||
...level,
|
||||
pictureReference: effectiveReferenceImageSrc || null,
|
||||
generationStatus: 'generating' as const,
|
||||
};
|
||||
setIsCostConfirmOpen(false);
|
||||
@@ -713,11 +706,46 @@ function PuzzleLevelDetailDialog({
|
||||
nextLevel,
|
||||
nextLevel.pictureDescription.trim() || undefined,
|
||||
effectiveReferenceImageSrc || undefined,
|
||||
promptReferenceImageSrcs,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
resolvePuzzleLevelGenerationEstimateSeconds({ aiRedraw }),
|
||||
);
|
||||
};
|
||||
|
||||
const handlePromptReferenceImageFiles = async (files: File[]) => {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingSlots =
|
||||
PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT - promptReferenceImages.length;
|
||||
if (remainingSlots <= 0) {
|
||||
setReferenceImageError('参考图最多上传 5 张。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
files.slice(0, remainingSlots).map(async (file, index) => ({
|
||||
id: `level-prompt-upload:${Date.now()}:${index}:${file.name}`,
|
||||
label: file.name.trim() || `参考图 ${index + 1}`,
|
||||
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
|
||||
})),
|
||||
);
|
||||
setPromptReferenceImages((current) => [...current, ...images].slice(0, 5));
|
||||
setReferenceImageError(
|
||||
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
|
||||
);
|
||||
} catch (uploadError) {
|
||||
setReferenceImageError(
|
||||
uploadError instanceof Error
|
||||
? uploadError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
@@ -735,7 +763,7 @@ function PuzzleLevelDetailDialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="关卡详情"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-2xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-[56rem] 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">
|
||||
@@ -753,24 +781,29 @@ function PuzzleLevelDetailDialog({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="space-y-4">
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
<div className="puzzle-level-detail-list divide-y divide-[var(--platform-subpanel-border)]">
|
||||
<section className="grid gap-2 pb-4 sm:grid-cols-[7.5rem_minmax(0,1fr)] sm:items-center">
|
||||
<label
|
||||
htmlFor={`puzzle-level-name-${level.levelId}`}
|
||||
className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]"
|
||||
>
|
||||
关卡名称
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id={`puzzle-level-name-${level.levelId}`}
|
||||
value={level.levelName}
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
onLevelChange({ ...level, levelName: event.target.value })
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="关卡名称"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.35rem] p-4">
|
||||
<section className="pt-4">
|
||||
<CreativeImageInputPanel
|
||||
className="puzzle-level-detail-image-editor"
|
||||
disabled={isBusy || generationProgress.isGenerating}
|
||||
isSubmitting={generationProgress.isGenerating}
|
||||
uploadedImageSrc={displayImageSrc}
|
||||
@@ -778,8 +811,9 @@ function PuzzleLevelDetailDialog({
|
||||
uploadedImageRefreshKey={`${imageRefreshKey}:${level.levelId}`}
|
||||
canRemoveMainImage={Boolean(effectiveReferenceImageSrc)}
|
||||
canToggleAiRedraw={Boolean(effectiveReferenceImageSrc)}
|
||||
canUploadPromptReferences={!effectiveReferenceImageSrc}
|
||||
mainImageMeta={
|
||||
effectiveReferenceImageSrc ? (
|
||||
shouldShowReferenceMeta ? (
|
||||
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[0.85rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
@@ -804,7 +838,8 @@ function PuzzleLevelDetailDialog({
|
||||
}
|
||||
promptRows={7}
|
||||
aiRedraw={aiRedraw}
|
||||
promptReferenceImages={[]}
|
||||
promptReferenceImages={promptReferenceImages}
|
||||
promptReferenceLimit={PUZZLE_LEVEL_PROMPT_REFERENCE_LIMIT}
|
||||
imageModelPicker={
|
||||
<PuzzleImageModelPicker
|
||||
value={imageModel}
|
||||
@@ -850,6 +885,15 @@ function PuzzleLevelDetailDialog({
|
||||
pictureDescription: value,
|
||||
})
|
||||
}
|
||||
onPromptReferenceFilesSelect={(files) => {
|
||||
void handlePromptReferenceImageFiles(files);
|
||||
}}
|
||||
onPromptReferenceRemove={(referenceId) => {
|
||||
setPromptReferenceImages((current) =>
|
||||
current.filter((image) => image.id !== referenceId),
|
||||
);
|
||||
setReferenceImageError(null);
|
||||
}}
|
||||
onHistoryClick={() => setIsHistoryPickerOpen(true)}
|
||||
onSubmit={() => setIsCostConfirmOpen(true)}
|
||||
/>
|
||||
@@ -862,7 +906,7 @@ function PuzzleLevelDetailDialog({
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => onStartTestRun(level)}
|
||||
onClick={() => onStartTestRun(level.levelId)}
|
||||
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
@@ -946,6 +990,7 @@ function PuzzleLevelDetailDialog({
|
||||
setReferenceImageLabel(
|
||||
getPuzzleHistoryAssetReferenceLabel(asset.imageSrc),
|
||||
);
|
||||
setAiRedraw(true);
|
||||
setReferenceImageError(null);
|
||||
setIsHistoryPickerOpen(false);
|
||||
}}
|
||||
@@ -1342,344 +1387,6 @@ function PuzzleWorkInfoTab({
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleUiAssetsTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onChange,
|
||||
onGenerate,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
const isGeneratingUiBackground = Boolean(
|
||||
firstLevel &&
|
||||
uiBackgroundGeneration?.levelId === firstLevel.levelId,
|
||||
);
|
||||
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
|
||||
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState,
|
||||
firstLevel,
|
||||
);
|
||||
const prompt = firstLevel?.uiBackgroundPrompt ?? '';
|
||||
const normalizedPrompt = prompt.trim() || defaultPrompt.trim();
|
||||
const backgroundPreviewSrc =
|
||||
resolvePuzzleUiBackgroundSource(firstLevel) || PUZZLE_UI_BACKGROUND_REFERENCE_SRC;
|
||||
const hasGeneratedUiBackground = Boolean(resolvePuzzleUiBackgroundSource(firstLevel));
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isCostConfirmOpen, setIsCostConfirmOpen] = 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">
|
||||
<CreativeImageInputPanel
|
||||
mainImageMode="preview"
|
||||
disabled={isBusy || !firstLevel || isGeneratingUiBackground}
|
||||
isSubmitting={isGeneratingUiBackground}
|
||||
uploadedImageSrc={backgroundPreviewSrc}
|
||||
uploadedImageAlt="拼图UI背景图"
|
||||
uploadedImageRefreshKey={`${imageRefreshKey}:ui-background`}
|
||||
mainImageInputId="puzzle-ui-background-preview"
|
||||
promptTextareaId="puzzle-ui-background-prompt-input"
|
||||
prompt={prompt}
|
||||
promptLabel="UI背景提示词"
|
||||
promptAriaLabel="拼图UI背景提示词"
|
||||
promptRows={8}
|
||||
aiRedraw={false}
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel={
|
||||
isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'
|
||||
}
|
||||
submitCostLabel={`· ${PUZZLE_IMAGE_GENERATION_POINT_COST}泥点`}
|
||||
submitDisabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
labels={{
|
||||
imageField: 'UI背景预览',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={(value) => {
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: value,
|
||||
});
|
||||
}}
|
||||
onSubmit={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPreviewOpen(true)}
|
||||
className="platform-button platform-button--ghost mt-3 min-h-11 w-full justify-center gap-2 px-4 py-3"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
预览UI
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isPreviewOpen ? (
|
||||
<PuzzleUiRuntimePreviewPanel
|
||||
backgroundPreviewSrc={backgroundPreviewSrc}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
puzzleImageSrc={formalImageSrc}
|
||||
title={editState.workTitle || firstLevel?.levelName || '拼图'}
|
||||
onClose={() => setIsPreviewOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{isCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-ui-point-cost-confirm-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div
|
||||
id="puzzle-ui-point-cost-confirm-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
确认消耗泥点
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 泥点
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
updateFirstLevel({
|
||||
...firstLevel,
|
||||
uiBackgroundPrompt: normalizedPrompt,
|
||||
});
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleUiRuntimePreviewPanel({
|
||||
backgroundPreviewSrc,
|
||||
imageRefreshKey,
|
||||
puzzleImageSrc,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
backgroundPreviewSrc: string;
|
||||
imageRefreshKey: string;
|
||||
puzzleImageSrc: string;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[139] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="UI预览"
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,48rem)] w-full max-w-sm flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<div className="min-w-0 truncate text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
UI预览
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
className="platform-icon-button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="mx-auto aspect-[9/16] max-h-[min(78dvh,42rem)] w-full max-w-[22rem] overflow-hidden rounded-[1.4rem] border border-white/22 bg-[#16211f] shadow-[0_18px_55px_rgba(15,23,42,0.24)]">
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden px-3 pb-4 pt-3 text-white">
|
||||
<ResolvedAssetImage
|
||||
src={backgroundPreviewSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-runtime-preview`}
|
||||
alt=""
|
||||
data-testid="puzzle-ui-runtime-preview-background"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18)_0%,rgba(15,23,42,0.05)_45%,rgba(15,23,42,0.24)_100%)]" />
|
||||
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center gap-2">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
|
||||
<ArrowLeft size={20} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate rounded-full border border-white/18 bg-black/26 px-3 py-2 text-center text-sm font-black backdrop-blur">
|
||||
{title}
|
||||
</span>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-black/28 backdrop-blur">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="relative z-10 mt-4 flex min-h-0 flex-1 items-center justify-center">
|
||||
<div
|
||||
className="relative aspect-square max-w-full overflow-hidden rounded-[1.25rem] border-[8px] border-white/88 bg-white/92 shadow-[0_20px_44px_rgba(15,23,42,0.32),inset_0_0_0_2px_rgba(15,23,42,0.12)]"
|
||||
style={{ width: 'min(88%, 52dvh, 100%)' }}
|
||||
aria-label="拼图区边界"
|
||||
>
|
||||
{puzzleImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={puzzleImageSrc}
|
||||
refreshKey={`${imageRefreshKey}:ui-runtime-board`}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full grid-cols-3 grid-rows-3 gap-1 bg-slate-100 p-2">
|
||||
{Array.from({ length: 9 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="rounded-[0.45rem] bg-slate-300/70"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[0.82rem] border-2 border-black/18" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="relative z-10 mt-3 rounded-[1.35rem] border border-white/16 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="h-12 rounded-xl bg-white/14 sm:h-14"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleAssetConfigTab({
|
||||
activeAssetConfigTab,
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onAssetConfigTabChange,
|
||||
onChange,
|
||||
onGenerateUiBackground,
|
||||
}: {
|
||||
activeAssetConfigTab: PuzzleAssetConfigTabId;
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateUiBackground: (prompt: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-0">
|
||||
<PuzzleAssetConfigTabs
|
||||
activeTab={activeAssetConfigTab}
|
||||
onChange={onAssetConfigTabChange}
|
||||
/>
|
||||
{activeAssetConfigTab === 'ui' ? (
|
||||
<PuzzleUiAssetsTab
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onChange={onChange}
|
||||
onGenerate={onGenerateUiBackground}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PuzzleResultActionBar({
|
||||
actionError,
|
||||
editState,
|
||||
@@ -1770,8 +1477,6 @@ export function PuzzleResultView({
|
||||
}: PuzzleResultViewProps) {
|
||||
const draft = session.draft;
|
||||
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('work');
|
||||
const [activeAssetConfigTab, setActiveAssetConfigTab] =
|
||||
useState<PuzzleAssetConfigTabId>('ui');
|
||||
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
|
||||
const [editState, setEditState] = useState<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
@@ -1782,8 +1487,6 @@ export function PuzzleResultView({
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uiBackgroundGeneration, setUiBackgroundGeneration] =
|
||||
useState<PuzzleUiBackgroundGenerationState>(null);
|
||||
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
@@ -1799,18 +1502,11 @@ export function PuzzleResultView({
|
||||
latestEditStateRef.current = editState;
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setUiBackgroundGeneration(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
latestEditStateRef.current = null;
|
||||
setActiveLevelId(null);
|
||||
setUiBackgroundGeneration(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
@@ -1831,7 +1527,7 @@ export function PuzzleResultView({
|
||||
nextRuntimes[level.levelId] =
|
||||
current[level.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
|
||||
estimateSeconds: resolvePuzzleLevelGenerationEstimateSeconds(),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1846,19 +1542,6 @@ export function PuzzleResultView({
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
setUiBackgroundGeneration((current) => {
|
||||
if (
|
||||
current &&
|
||||
mergedState.levels.some(
|
||||
(level) =>
|
||||
level.levelId === current.levelId &&
|
||||
resolvePuzzleUiBackgroundSource(level),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [draft]);
|
||||
|
||||
const syncedDraft = useMemo(() => {
|
||||
@@ -1933,6 +1616,16 @@ export function PuzzleResultView({
|
||||
uiBackgroundImageSrc: level.uiBackgroundImageSrc?.trim() || null,
|
||||
uiBackgroundImageObjectKey:
|
||||
level.uiBackgroundImageObjectKey?.trim() || null,
|
||||
levelSceneImageSrc: level.levelSceneImageSrc?.trim() || null,
|
||||
levelSceneImageObjectKey:
|
||||
level.levelSceneImageObjectKey?.trim() || null,
|
||||
uiSpritesheetImageSrc: level.uiSpritesheetImageSrc?.trim() || null,
|
||||
uiSpritesheetImageObjectKey:
|
||||
level.uiSpritesheetImageObjectKey?.trim() || null,
|
||||
levelBackgroundImageSrc:
|
||||
level.levelBackgroundImageSrc?.trim() || null,
|
||||
levelBackgroundImageObjectKey:
|
||||
level.levelBackgroundImageObjectKey?.trim() || null,
|
||||
generationStatus: level.generationStatus || 'idle',
|
||||
})),
|
||||
};
|
||||
@@ -2006,16 +1699,18 @@ export function PuzzleResultView({
|
||||
);
|
||||
}
|
||||
|
||||
const updateLevel = (nextLevel: PuzzleDraftLevel) => {
|
||||
const updateLevel = (
|
||||
nextLevel: PuzzleDraftLevel,
|
||||
estimateSeconds = resolvePuzzleLevelGenerationEstimateSeconds(),
|
||||
) => {
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
if (nextLevel.generationStatus === 'generating') {
|
||||
return {
|
||||
...current,
|
||||
[nextLevel.levelId]:
|
||||
current[nextLevel.levelId] ?? {
|
||||
startedAtMs: Date.now(),
|
||||
estimateSeconds: PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS,
|
||||
},
|
||||
[nextLevel.levelId]: {
|
||||
startedAtMs: current[nextLevel.levelId]?.startedAtMs ?? Date.now(),
|
||||
estimateSeconds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2039,17 +1734,6 @@ export function PuzzleResultView({
|
||||
);
|
||||
};
|
||||
|
||||
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
|
||||
...syncedDraft,
|
||||
levelName: level.levelName,
|
||||
summary: editState.workDescription.trim(),
|
||||
candidates: level.candidates,
|
||||
selectedCandidateId: level.selectedCandidateId,
|
||||
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
|
||||
coverAssetId: level.coverAssetId,
|
||||
generationStatus: level.generationStatus,
|
||||
levels: [level],
|
||||
});
|
||||
const canStartTestRun = Boolean(onStartTestRun && primaryImageSrc);
|
||||
|
||||
return (
|
||||
@@ -2132,46 +1816,6 @@ export function PuzzleResultView({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'assets' ? (
|
||||
<PuzzleAssetConfigTab
|
||||
activeAssetConfigTab={activeAssetConfigTab}
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onAssetConfigTabChange={setActiveAssetConfigTab}
|
||||
onChange={setEditState}
|
||||
onGenerateUiBackground={(prompt) => {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
setUiBackgroundGeneration({
|
||||
levelId: firstLevel.levelId,
|
||||
prompt,
|
||||
});
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: firstLevel.levelId,
|
||||
promptText: prompt,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(
|
||||
editState.levels.map((level, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...level,
|
||||
uiBackgroundPrompt: prompt,
|
||||
}
|
||||
: level,
|
||||
),
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
@@ -2230,15 +1874,18 @@ export function PuzzleResultView({
|
||||
nextLevel,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
referenceImageSrcs,
|
||||
imageModel,
|
||||
aiRedraw,
|
||||
estimateSeconds,
|
||||
) => {
|
||||
updateLevel(nextLevel);
|
||||
updateLevel(nextLevel, estimateSeconds);
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: nextLevel.levelId,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
referenceImageSrcs,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
@@ -2257,7 +1904,7 @@ export function PuzzleResultView({
|
||||
onLevelChange={updateLevel}
|
||||
onStartTestRun={
|
||||
onStartTestRun
|
||||
? (level) => onStartTestRun(buildLevelDraft(level))
|
||||
? (levelId) => onStartTestRun(syncedDraft, { levelId })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -21,6 +28,31 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-runtime/puzzleUiSpritesheetParser', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/puzzle-runtime/puzzleUiSpritesheetParser')
|
||||
>('../../services/puzzle-runtime/puzzleUiSpritesheetParser');
|
||||
return {
|
||||
...actual,
|
||||
loadPuzzleUiSpritesheetLayout: vi.fn(async () => ({
|
||||
width: 32,
|
||||
height: 24,
|
||||
regions: {
|
||||
back: { x: 3, y: 2, width: 4, height: 4 },
|
||||
settings: { x: 24, y: 3, width: 5, height: 5 },
|
||||
next: { x: 11, y: 11, width: 10, height: 5 },
|
||||
hint: { x: 2, y: 20, width: 5, height: 3 },
|
||||
reference: { x: 13, y: 19, width: 6, height: 4 },
|
||||
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
|
||||
},
|
||||
hitRegions: {
|
||||
back: { x: 4, y: 3, width: 2, height: 2 },
|
||||
hint: { x: 3, y: 21, width: 3, height: 1 },
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
@@ -183,7 +215,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
allTilesResolved: false,
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
piece.pieceId === 'piece-0'
|
||||
? {...piece, currentRow: 0, currentCol: 0}
|
||||
? { ...piece, currentRow: 0, currentCol: 0 }
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
@@ -200,11 +232,15 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const piece = container.querySelector('[data-piece-id="piece-0"]') as HTMLElement | null;
|
||||
const piece = container.querySelector(
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
const board = container.querySelector('[data-testid="puzzle-board"]') as HTMLElement | null;
|
||||
const board = container.querySelector(
|
||||
'[data-testid="puzzle-board"]',
|
||||
) as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
@@ -213,17 +249,18 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
configurable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
board.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
@@ -259,7 +296,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
|
||||
expect(onDragPiece).toHaveBeenCalledTimes(1);
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
expect.objectContaining({ pieceId: 'piece-0' }),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
@@ -468,9 +505,7 @@ test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('puzzle-runtime-piece--merged');
|
||||
expect(basePiece?.className).not.toContain(
|
||||
'puzzle-runtime-piece--selected',
|
||||
);
|
||||
expect(basePiece?.className).not.toContain('puzzle-runtime-piece--selected');
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
@@ -657,7 +692,7 @@ test('首次退出引导的作品改造按钮进入改造流程', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
|
||||
test('顶部不显示作者,关卡标题和倒计时使用游戏铭牌结构', () => {
|
||||
const runWithoutNext: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: null,
|
||||
@@ -681,12 +716,26 @@ test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
|
||||
expect(screen.queryByText('测试作者')).toBeNull();
|
||||
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('潮雾拼图')).toBeTruthy();
|
||||
const levelLogo = screen.getByTestId(
|
||||
'puzzle-runtime-level-logo',
|
||||
) as HTMLImageElement;
|
||||
expect(levelLogo.getAttribute('src')).toContain('logo.png');
|
||||
expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy();
|
||||
expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull();
|
||||
expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('潮雾拼图').closest('.puzzle-runtime-level-title-card'),
|
||||
).toBeTruthy();
|
||||
expect(timer.className).toContain('puzzle-runtime-timer');
|
||||
expect(timer.className).toContain('text-lg');
|
||||
expect(timer.className).not.toContain('text-2xl');
|
||||
expect(hintButton.className).toContain('h-16');
|
||||
expect(referenceButton.className).toContain('h-16');
|
||||
expect(freezeButton.className).toContain('h-16');
|
||||
expect(hintButton.textContent).not.toContain('提示');
|
||||
expect(referenceButton.textContent).not.toContain('原图');
|
||||
expect(freezeButton.textContent).not.toContain('冻结');
|
||||
expect(hintButton.parentElement?.className).toContain('grid-cols-3');
|
||||
expect(hintButton.parentElement?.className).not.toContain(
|
||||
'puzzle-runtime-toolbar',
|
||||
);
|
||||
expect(screen.queryByText('等待下一关候选')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -720,6 +769,167 @@ test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
|
||||
expect(backgroundImage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('运行态优先把关卡背景图渲染为舞台背景', async () => {
|
||||
const runWithLevelBackground: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
uiBackgroundImageSrc: '/generated-puzzle-assets/session/ui/legacy.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background/background.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithLevelBackground}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(
|
||||
'img[src="/generated-puzzle-assets/session/level-background/background.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'img[src="/generated-puzzle-assets/session/ui/legacy.png"]',
|
||||
),
|
||||
).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关和道具按钮', async () => {
|
||||
const runWithSpritesheet: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'cleared',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
remainingMs: 120_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: 'profile-1',
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithSpritesheet}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: '下一关' });
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '返回上一页' })
|
||||
.querySelector('[data-puzzle-ui-sprite="back"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--sprite');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--precise-hit');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '返回上一页' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="back"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).not.toContain('rounded-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '打开拼图设置' })
|
||||
.querySelector('[data-puzzle-ui-sprite="settings"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--sprite');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--precise-hit');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).not.toContain('rounded-full');
|
||||
const nextSprite = screen
|
||||
.getByRole('button', { name: '下一关' })
|
||||
.querySelector('[data-puzzle-ui-sprite="next"]') as HTMLElement | null;
|
||||
expect(nextSprite).toBeTruthy();
|
||||
expect(nextSprite?.style.backgroundSize).toBe('320% 480%');
|
||||
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]'),
|
||||
).toBeTruthy();
|
||||
const hintHitZone = screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]') as HTMLElement | null;
|
||||
expect(hintHitZone).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '提示' }).className).toContain(
|
||||
'puzzle-runtime-sprite-tool-button--precise-hit',
|
||||
);
|
||||
expect(hintHitZone?.className).toContain(
|
||||
'puzzle-runtime-ui-sprite-hit-zone',
|
||||
);
|
||||
expect(hintHitZone?.style.left).toBe('20%');
|
||||
expect(hintHitZone?.style.top).toBe('33.33333333333333%');
|
||||
expect(hintHitZone?.style.width).toBe('60%');
|
||||
expect(hintHitZone?.style.height).toBe('33.33333333333333%');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
|
||||
).toContain('puzzle-runtime-bottom-ui-sprite');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
|
||||
).not.toContain('w-full');
|
||||
expect(screen.getByRole('button', { name: '提示' }).textContent).toBe('');
|
||||
const referenceSprite = screen
|
||||
.getByRole('button', { name: '原图' })
|
||||
.querySelector('[data-puzzle-ui-sprite="reference"]') as HTMLElement | null;
|
||||
expect(referenceSprite).toBeTruthy();
|
||||
expect(referenceSprite?.style.backgroundSize).toBe('533.3333333333333% 600%');
|
||||
expect(referenceSprite?.style.backgroundPosition).toBe('50% 95%');
|
||||
expect(referenceSprite?.style.aspectRatio).toBe('6 / 4');
|
||||
expect(referenceSprite?.className).toContain('puzzle-runtime-bottom-ui-sprite');
|
||||
expect(referenceSprite?.className).not.toContain('w-full');
|
||||
expect(screen.getByRole('button', { name: '原图' }).textContent).toBe('');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '冻结' })
|
||||
.querySelector('[data-puzzle-ui-sprite="freezeTime"]'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe('');
|
||||
});
|
||||
|
||||
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
|
||||
const runWithUiBackground: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -994,6 +1204,36 @@ test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
|
||||
expect(within(dialog).queryByText('保存并退出')).toBeNull();
|
||||
});
|
||||
|
||||
test('推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<div className="platform-recommend-runtime-viewport">
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
embedded
|
||||
hideExitControls
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
const overlay = dialog.closest('.puzzle-runtime-modal-overlay');
|
||||
|
||||
expect(overlay?.className).toContain('puzzle-runtime-modal-overlay--fixed');
|
||||
expect(overlay?.closest('.platform-recommend-runtime-viewport')).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
@@ -1066,11 +1306,11 @@ test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
@@ -1531,7 +1771,9 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
|
||||
);
|
||||
|
||||
const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
|
||||
fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' }));
|
||||
fireEvent.click(
|
||||
within(failedDialog).getByRole('button', { name: '重新开始' }),
|
||||
);
|
||||
expect(onRestartLevel).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(
|
||||
|
||||
@@ -2,17 +2,23 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Settings,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import puzzleLevelLogo from '../../../media/logo.png';
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
@@ -34,6 +40,13 @@ import {
|
||||
type RuntimeInputPoint,
|
||||
} from '../../services/input-devices';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import {
|
||||
buildPuzzleUiSpriteBackgroundStyle,
|
||||
buildPuzzleUiSpriteHitZoneStyle,
|
||||
loadPuzzleUiSpritesheetLayout,
|
||||
type PuzzleUiSpriteKind,
|
||||
type PuzzleUiSpritesheetLayout,
|
||||
} from '../../services/puzzle-runtime/puzzleUiSpritesheetParser';
|
||||
import {
|
||||
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
||||
playRuntimeClickSound,
|
||||
@@ -113,6 +126,42 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
|
||||
}));
|
||||
}
|
||||
|
||||
function PuzzleUiSprite({
|
||||
src,
|
||||
kind,
|
||||
layout,
|
||||
className = '',
|
||||
withHitZone = false,
|
||||
}: {
|
||||
src: string | null;
|
||||
kind: PuzzleUiSpriteKind;
|
||||
layout: PuzzleUiSpritesheetLayout | null;
|
||||
className?: string;
|
||||
withHitZone?: boolean;
|
||||
}) {
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-puzzle-ui-sprite={kind}
|
||||
className={`relative inline-block shrink-0 bg-no-repeat ${className}`}
|
||||
style={buildPuzzleUiSpriteBackgroundStyle({ src, kind, layout })}
|
||||
>
|
||||
{withHitZone ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-puzzle-ui-sprite-hit-zone={kind}
|
||||
className="puzzle-runtime-ui-sprite-hit-zone absolute"
|
||||
style={buildPuzzleUiSpriteHitZoneStyle({ kind, layout })}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildMergedGroupViewModels(
|
||||
groups: PuzzleMergedGroupState[],
|
||||
pieces: PuzzleBoardPieceViewModel[],
|
||||
@@ -225,7 +274,9 @@ function resolveRuntimeElapsedMs(
|
||||
) {
|
||||
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
|
||||
if (level.status !== 'playing') {
|
||||
return level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs);
|
||||
return (
|
||||
level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs)
|
||||
);
|
||||
}
|
||||
|
||||
const timeLimitMs = level.timeLimitMs || level.remainingMs;
|
||||
@@ -369,8 +420,7 @@ export function PuzzleRuntimeShell({
|
||||
const selectedPieceIdRef = useRef<string | null>(null);
|
||||
const selectedPieceBeforeInputRef = useRef<string | null>(null);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
|
||||
useState(false);
|
||||
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = useState(false);
|
||||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||||
null,
|
||||
);
|
||||
@@ -414,6 +464,8 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const [uiSpritesheetLayout, setUiSpritesheetLayout] =
|
||||
useState<PuzzleUiSpritesheetLayout | null>(null);
|
||||
const runtimeDragInputControllerRef = useRef(
|
||||
createRuntimeDragInputController<string>(),
|
||||
);
|
||||
@@ -461,16 +513,27 @@ export function PuzzleRuntimeShell({
|
||||
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 backgroundMusicSrc =
|
||||
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
|
||||
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
|
||||
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
||||
const { resolvedUrl: resolvedBackgroundMusicSrc } = useResolvedAssetReadUrl(backgroundMusicSrc);
|
||||
const { resolvedUrl: resolvedBackgroundMusicSrc } =
|
||||
useResolvedAssetReadUrl(backgroundMusicSrc);
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
|
||||
resolvePuzzleUiBackgroundSource(currentLevel) ?? null,
|
||||
);
|
||||
const rawUiSpritesheetImage =
|
||||
currentLevel?.uiSpritesheetImageSrc?.trim() ||
|
||||
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
|
||||
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
|
||||
: null);
|
||||
const { resolvedUrl: resolvedUiSpritesheetImage } = useResolvedAssetReadUrl(
|
||||
rawUiSpritesheetImage,
|
||||
);
|
||||
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
|
||||
const tryPlayBackgroundMusic = useCallback(() => {
|
||||
const audio = backgroundAudioRef.current;
|
||||
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
|
||||
@@ -483,6 +546,34 @@ export function PuzzleRuntimeShell({
|
||||
void audio.play().catch(() => {});
|
||||
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawUiSpritesheetImage) {
|
||||
setUiSpritesheetLayout(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setUiSpritesheetLayout(null);
|
||||
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((layout) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setUiSpritesheetLayout(layout);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
|
||||
setUiSpritesheetLayout(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [rawUiSpritesheetImage]);
|
||||
|
||||
useEffect(() => {
|
||||
currentLevelRef.current = currentLevel;
|
||||
}, [currentLevel]);
|
||||
@@ -608,16 +699,16 @@ export function PuzzleRuntimeShell({
|
||||
}, PUZZLE_MERGE_FLASH_DURATION_MS);
|
||||
}, [board, currentLevel?.status, mergedGroups]);
|
||||
|
||||
const resolvePieceCellElement = (pieceId: string) => {
|
||||
const resolvePieceCellElement = useCallback((pieceId: string) => {
|
||||
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
|
||||
const pieceCellElement =
|
||||
(pieceElement?.parentElement as HTMLDivElement | null) ??
|
||||
pieceCellElementRefMap.current.get(pieceId) ??
|
||||
null;
|
||||
return pieceCellElement;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetDragVisualTarget = () => {
|
||||
const resetDragVisualTarget = useCallback(() => {
|
||||
const dragVisualTarget = dragVisualTargetRef.current;
|
||||
setDragRenderTarget(null);
|
||||
if (!dragVisualTarget) {
|
||||
@@ -653,7 +744,7 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
|
||||
dragVisualTargetRef.current = null;
|
||||
};
|
||||
}, [resolvePieceCellElement]);
|
||||
|
||||
const resetDragInteractionState = () => {
|
||||
dragSessionRef.current = null;
|
||||
@@ -728,7 +819,7 @@ export function PuzzleRuntimeShell({
|
||||
() => () => {
|
||||
resetDragVisualTarget();
|
||||
},
|
||||
[],
|
||||
[resetDragVisualTarget],
|
||||
);
|
||||
|
||||
const clearPresentationTimeouts = () => {
|
||||
@@ -773,7 +864,7 @@ export function PuzzleRuntimeShell({
|
||||
}, [isUiPauseActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
if (currentLevelStatus !== 'playing') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -782,28 +873,33 @@ export function PuzzleRuntimeShell({
|
||||
}, 250);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
|
||||
}, [currentLevelIndex, currentLevelStatus, runtimeRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || !currentLevel || currentLevel.status === 'cleared') {
|
||||
if (
|
||||
!runtimeRunId ||
|
||||
currentLevelIndex === null ||
|
||||
currentLevelStartedAtMs === null ||
|
||||
currentLevelStatus === 'cleared'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (displayRemainingMs > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
|
||||
const syncKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}`;
|
||||
if (timeExpiredSyncKeyRef.current === syncKey) {
|
||||
return;
|
||||
}
|
||||
timeExpiredSyncKeyRef.current = syncKey;
|
||||
void onTimeExpiredRef.current?.();
|
||||
}, [
|
||||
currentLevel?.levelIndex,
|
||||
currentLevel?.startedAtMs,
|
||||
currentLevel?.status,
|
||||
currentLevelIndex,
|
||||
currentLevelStartedAtMs,
|
||||
currentLevelStatus,
|
||||
displayRemainingMs,
|
||||
run?.runId,
|
||||
runtimeRunId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -945,9 +1041,7 @@ export function PuzzleRuntimeShell({
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCell = board
|
||||
? resolveRuntimeInputGridCell(point, board)
|
||||
: null;
|
||||
const targetCell = board ? resolveRuntimeInputGridCell(point, board) : null;
|
||||
if (!targetCell) {
|
||||
return;
|
||||
}
|
||||
@@ -959,10 +1053,7 @@ export function PuzzleRuntimeShell({
|
||||
});
|
||||
};
|
||||
|
||||
const resolveBoardInputPointFromClient = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) =>
|
||||
const resolveBoardInputPointFromClient = (clientX: number, clientY: number) =>
|
||||
createRuntimeInputPointFromClient(
|
||||
clientX,
|
||||
clientY,
|
||||
@@ -976,7 +1067,9 @@ export function PuzzleRuntimeShell({
|
||||
return;
|
||||
}
|
||||
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
dragSessionRef.current = {
|
||||
pieceId: session.targetId,
|
||||
inputId: session.inputId,
|
||||
@@ -995,14 +1088,18 @@ export function PuzzleRuntimeShell({
|
||||
runtimeDragInputControllerRef.current.setOptions({
|
||||
dragThresholdPx: 8,
|
||||
onPress: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
syncRuntimeDragFromController(session);
|
||||
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
|
||||
commitSelectedPieceId(session.targetId);
|
||||
triggerPuzzlePiecePressFeedback(musicVolume);
|
||||
},
|
||||
onDragStart: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
syncRuntimeDragFromController(session);
|
||||
setDragRenderTarget({
|
||||
pieceId: session.targetId,
|
||||
@@ -1014,7 +1111,9 @@ export function PuzzleRuntimeShell({
|
||||
syncRuntimeDragFromController(session);
|
||||
},
|
||||
onDrop: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
syncRuntimeDragFromController(session);
|
||||
commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint);
|
||||
commitSelectedPieceId(null);
|
||||
@@ -1073,7 +1172,9 @@ export function PuzzleRuntimeShell({
|
||||
});
|
||||
};
|
||||
|
||||
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const handlePiecePointerMove = (
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
runtimeDragInputControllerRef.current.move({
|
||||
inputId: `pointer:${event.pointerId}`,
|
||||
@@ -1094,8 +1195,7 @@ export function PuzzleRuntimeShell({
|
||||
: runtimeStatus === 'failed'
|
||||
? '失败'
|
||||
: '进行中';
|
||||
const nextLevelMode =
|
||||
run.nextLevelMode ?? 'none';
|
||||
const nextLevelMode = run.nextLevelMode ?? 'none';
|
||||
const recommendedNextWorks = run.recommendedNextWorks ?? [];
|
||||
const hasSimilarWorkChoices =
|
||||
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
|
||||
@@ -1106,8 +1206,7 @@ export function PuzzleRuntimeShell({
|
||||
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
|
||||
!hasSimilarWorkChoices
|
||||
: Boolean(run.recommendedNextProfileId)));
|
||||
const canShowNextAction =
|
||||
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
||||
const canShowNextAction = canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||
const exitPromptProfileId = currentLevel.profileId.trim();
|
||||
const shouldHideBackButton = hideBackButton || hideExitControls;
|
||||
@@ -1119,6 +1218,9 @@ export function PuzzleRuntimeShell({
|
||||
currentLevel.status === 'cleared' &&
|
||||
dismissedClearKey !== clearResultKey &&
|
||||
isClearResultReady;
|
||||
const clearResultOverlayClassName = embedded
|
||||
? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm`
|
||||
: 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm';
|
||||
const handleBackRequest = () => {
|
||||
if (hideExitControls) {
|
||||
return;
|
||||
@@ -1230,6 +1332,157 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
};
|
||||
|
||||
const clearResultDialog = isClearResultOpen ? (
|
||||
<div className={clearResultOverlayClassName}>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-clear-result-title"
|
||||
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-clear-result-title"
|
||||
className="truncate text-lg font-black"
|
||||
>
|
||||
通关完成
|
||||
</h2>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭通关弹窗"
|
||||
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
||||
onClick={() => {
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Clock className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
|
||||
通关时间
|
||||
</span>
|
||||
</div>
|
||||
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-bold">排行榜</div>
|
||||
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
|
||||
<span>名次</span>
|
||||
<span>昵称</span>
|
||||
<span className="text-right">通关时间</span>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{leaderboardEntries.length > 0 ? (
|
||||
leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'puzzle-runtime-leaderboard-row--active'
|
||||
: 'puzzle-runtime-leaderboard-row border-t'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-black">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold leading-tight">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
{entry.visibleTags?.length ? (
|
||||
<span className="puzzle-runtime-leaderboard-tags">
|
||||
{entry.visibleTags.map((tag) => (
|
||||
<span
|
||||
className="puzzle-runtime-leaderboard-tag"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
|
||||
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSimilarWorkChoices ? (
|
||||
<div className="mt-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{recommendedNextWorks.slice(0, 3).map((item) => (
|
||||
<PuzzleNextWorkCard
|
||||
key={item.profileId}
|
||||
item={item}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({ profileId: item.profileId });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canAdvanceDefaultNextLevel ? (
|
||||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({
|
||||
profileId: run.nextLevelProfileId ?? undefined,
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null;
|
||||
const clearResultLayer =
|
||||
embedded && clearResultDialog && typeof document !== 'undefined'
|
||||
? createPortal(clearResultDialog, document.body)
|
||||
: clearResultDialog;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
@@ -1274,26 +1527,50 @@ export function PuzzleRuntimeShell({
|
||||
onClick={handleBackRequest}
|
||||
aria-label="返回上一页"
|
||||
disabled={shouldHideBackButton}
|
||||
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
|
||||
shouldHideBackButton
|
||||
? 'invisible pointer-events-none'
|
||||
: 'inline-flex'
|
||||
}`}
|
||||
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center sm:h-11 sm:w-11 ${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-icon-button--sprite'
|
||||
: 'rounded-full'
|
||||
} ${
|
||||
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
|
||||
} ${shouldHideBackButton ? 'invisible pointer-events-none' : 'inline-flex'}`}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="back"
|
||||
layout={uiSpritesheetLayout}
|
||||
className={`${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-top-ui-sprite'
|
||||
: 'h-7 w-7 rounded-full'
|
||||
}`}
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] px-3 py-2 text-center sm:max-w-[18rem] sm:px-4">
|
||||
<div className="flex max-w-full items-center justify-center gap-1.5">
|
||||
<span className="puzzle-runtime-level-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold sm:text-[11px]">
|
||||
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
|
||||
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
|
||||
<span aria-hidden="true" className="puzzle-runtime-level-logo">
|
||||
<img
|
||||
src={puzzleLevelLogo}
|
||||
alt=""
|
||||
data-testid="puzzle-runtime-level-logo"
|
||||
className="puzzle-runtime-level-logo__image"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
|
||||
{levelLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||||
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
|
||||
{currentLevel.levelName}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 font-mono text-lg font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.2)] sm:text-xl ${
|
||||
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none sm:text-xl ${
|
||||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||||
? 'puzzle-runtime-timer--urgent'
|
||||
: 'puzzle-runtime-timer'
|
||||
@@ -1309,9 +1586,28 @@ export function PuzzleRuntimeShell({
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开拼图设置"
|
||||
title="打开拼图设置"
|
||||
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
|
||||
className={`puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11 ${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-icon-button--sprite'
|
||||
: 'rounded-full'
|
||||
} ${
|
||||
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
|
||||
}`}
|
||||
>
|
||||
<Settings className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.25)] sm:h-[1.4rem] sm:w-[1.4rem]" />
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="settings"
|
||||
layout={uiSpritesheetLayout}
|
||||
className={`${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-top-ui-sprite'
|
||||
: 'h-7 w-7 rounded-full'
|
||||
}`}
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<Settings className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1408,9 +1704,7 @@ export function PuzzleRuntimeShell({
|
||||
}`}
|
||||
style={{
|
||||
clipPath: isMerged ? undefined : singlePieceClipUrl,
|
||||
WebkitClipPath: isMerged
|
||||
? undefined
|
||||
: singlePieceClipUrl,
|
||||
WebkitClipPath: isMerged ? undefined : singlePieceClipUrl,
|
||||
zIndex: resolveDraggedPieceLayer(
|
||||
piece?.pieceId,
|
||||
draggingPieceId,
|
||||
@@ -1526,10 +1820,7 @@ export function PuzzleRuntimeShell({
|
||||
</defs>
|
||||
<g clipPath={`url(#${mergedGroupClipId})`}>
|
||||
{group.pieces.map((piece) => (
|
||||
<g
|
||||
key={piece.pieceId}
|
||||
data-merged-piece-visual="true"
|
||||
>
|
||||
<g key={piece.pieceId} data-merged-piece-visual="true">
|
||||
<clipPath
|
||||
id={sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
||||
@@ -1656,23 +1947,47 @@ export function PuzzleRuntimeShell({
|
||||
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||||
>
|
||||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="next"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="h-8 w-12 rounded-full"
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="puzzle-runtime-toolbar flex items-center justify-center gap-2 rounded-full p-2 sm:gap-3">
|
||||
<div className="grid w-full max-w-[23rem] grid-cols-3 items-center justify-items-center gap-3 px-1 sm:max-w-[26rem] sm:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
aria-label="提示"
|
||||
onClick={() => openPropDialog('hint', '使用提示')}
|
||||
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
|
||||
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
||||
resolvedUiSpritesheetImage
|
||||
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<Lightbulb className="puzzle-runtime-tool-button__warm h-6 w-6" />
|
||||
提示
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="hint"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="puzzle-runtime-bottom-ui-sprite"
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<span className="puzzle-runtime-tool-button__warm text-lg font-black">
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
|
||||
aria-label="原图"
|
||||
aria-pressed={isOriginalImageViewerVisible}
|
||||
onClick={() => {
|
||||
if (isOriginalImageViewerVisible) {
|
||||
@@ -1681,23 +1996,50 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
openPropDialog('reference', '查看原图');
|
||||
}}
|
||||
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45 ${
|
||||
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
||||
isOriginalImageViewerVisible
|
||||
? 'puzzle-runtime-tool-button--active'
|
||||
: 'puzzle-runtime-tool-button'
|
||||
} ${
|
||||
resolvedUiSpritesheetImage
|
||||
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-6 w-6" />
|
||||
原图
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="reference"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="puzzle-runtime-bottom-ui-sprite"
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<span className="text-lg font-black">□</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
aria-label="冻结"
|
||||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||||
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
|
||||
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
||||
resolvedUiSpritesheetImage
|
||||
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<Snowflake className="puzzle-runtime-tool-button__cool h-6 w-6" />
|
||||
冻结
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="freezeTime"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="puzzle-runtime-bottom-ui-sprite"
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<span className="puzzle-runtime-tool-button__cool text-lg font-black">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1863,9 +2205,7 @@ export function PuzzleRuntimeShell({
|
||||
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
|
||||
音频
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold">
|
||||
音乐音量
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold">音乐音量</div>
|
||||
</div>
|
||||
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
|
||||
{Math.round(musicVolume * 100)}%
|
||||
@@ -1897,24 +2237,26 @@ export function PuzzleRuntimeShell({
|
||||
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">关卡</span>
|
||||
<span className="font-semibold">
|
||||
{levelLabel}
|
||||
</span>
|
||||
<span className="font-semibold">{levelLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">已完成关卡</span>
|
||||
<span className="puzzle-runtime-dialog__soft">
|
||||
已完成关卡
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{run.clearedLevelCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">当前状态</span>
|
||||
<span className="font-semibold">
|
||||
{statusLabel}
|
||||
<span className="puzzle-runtime-dialog__soft">
|
||||
当前状态
|
||||
</span>
|
||||
<span className="font-semibold">{statusLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">当前用时</span>
|
||||
<span className="puzzle-runtime-dialog__soft">
|
||||
当前用时
|
||||
</span>
|
||||
<span className="font-mono font-semibold">
|
||||
{formatElapsedMs(displayElapsedMs)}
|
||||
</span>
|
||||
@@ -1951,9 +2293,7 @@ export function PuzzleRuntimeShell({
|
||||
) : null}
|
||||
|
||||
{isExitRemodelPromptOpen && !hideExitControls ? (
|
||||
<div
|
||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
|
||||
>
|
||||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -2011,10 +2351,7 @@ export function PuzzleRuntimeShell({
|
||||
className="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
|
||||
<h2
|
||||
id="puzzle-failed-title"
|
||||
className="text-lg font-black"
|
||||
>
|
||||
<h2 id="puzzle-failed-title" className="text-lg font-black">
|
||||
关卡失败
|
||||
</h2>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
|
||||
@@ -2045,156 +2382,7 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isClearResultOpen ? (
|
||||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-clear-result-title"
|
||||
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-clear-result-title"
|
||||
className="truncate text-lg font-black"
|
||||
>
|
||||
通关完成
|
||||
</h2>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭通关弹窗"
|
||||
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
||||
onClick={() => {
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Clock className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
|
||||
通关时间
|
||||
</span>
|
||||
</div>
|
||||
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-bold">
|
||||
排行榜
|
||||
</div>
|
||||
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
|
||||
<span>名次</span>
|
||||
<span>昵称</span>
|
||||
<span className="text-right">通关时间</span>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{leaderboardEntries.length > 0 ? (
|
||||
leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'puzzle-runtime-leaderboard-row--active'
|
||||
: 'puzzle-runtime-leaderboard-row border-t'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-black">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold leading-tight">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
{entry.visibleTags?.length ? (
|
||||
<span className="puzzle-runtime-leaderboard-tags">
|
||||
{entry.visibleTags.map((tag) => (
|
||||
<span
|
||||
className="puzzle-runtime-leaderboard-tag"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
|
||||
{isBusy
|
||||
? '正在同步真实排行榜…'
|
||||
: '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSimilarWorkChoices ? (
|
||||
<div className="mt-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{recommendedNextWorks.slice(0, 3).map((item) => (
|
||||
<PuzzleNextWorkCard
|
||||
key={item.profileId}
|
||||
item={item}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({ profileId: item.profileId });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canAdvanceDefaultNextLevel ? (
|
||||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({
|
||||
profileId: run.nextLevelProfileId ?? undefined,
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
{clearResultLayer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2229,9 +2417,7 @@ function PuzzleNextWorkCard({
|
||||
<div className="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||||
</div>
|
||||
<div className="min-w-0 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-black">
|
||||
{item.levelName}
|
||||
</div>
|
||||
<div className="truncate text-sm font-black">{item.levelName}</div>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 truncate text-xs font-semibold">
|
||||
{item.authorDisplayName}
|
||||
</div>
|
||||
|
||||
@@ -2100,6 +2100,7 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
|
||||
|
||||
const nav = container.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(nav?.classList.contains('platform-bottom-nav')).toBe(true);
|
||||
const buttons = within(nav as HTMLElement).getAllByRole('button');
|
||||
|
||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||
@@ -2110,6 +2111,12 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
|
||||
expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
expect(
|
||||
buttons[1]?.querySelector('.platform-bottom-nav__primary-action'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
buttons[0]?.querySelector('.platform-bottom-nav__active-mark'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged in draft bottom tab shows unread marker', () => {
|
||||
|
||||
@@ -1166,13 +1166,21 @@ function PlatformTabButton({
|
||||
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="platform-bottom-nav__button-content">
|
||||
<span className="platform-bottom-nav__icon-shell">
|
||||
<span
|
||||
className={`platform-bottom-nav__icon-shell ${emphasized ? 'platform-bottom-nav__primary-action' : ''}`}
|
||||
>
|
||||
<Icon className="platform-bottom-nav__icon" />
|
||||
{showDot ? (
|
||||
<span aria-hidden="true" className="platform-nav-unread-dot" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="platform-bottom-nav__label">{label}</span>
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="platform-bottom-nav__active-mark"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
552
src/index.css
552
src/index.css
@@ -250,26 +250,22 @@ body {
|
||||
|
||||
62% {
|
||||
opacity: 1;
|
||||
transform:
|
||||
translate3d(
|
||||
transform: translate3d(
|
||||
calc(-50% + var(--match3d-fly-dx, 0px) * 0.82),
|
||||
calc(-50% + var(--match3d-fly-dy, 0px) * 0.82 - 10px),
|
||||
0
|
||||
)
|
||||
scale(calc(var(--match3d-fly-scale, 0.68) * 1.06))
|
||||
rotate(4deg);
|
||||
scale(calc(var(--match3d-fly-scale, 0.68) * 1.06)) rotate(4deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform:
|
||||
translate3d(
|
||||
transform: translate3d(
|
||||
calc(-50% + var(--match3d-fly-dx, 0px)),
|
||||
calc(-50% + var(--match3d-fly-dy, 0px)),
|
||||
0
|
||||
)
|
||||
scale(var(--match3d-fly-scale, 0.68))
|
||||
rotate(0deg);
|
||||
scale(var(--match3d-fly-scale, 0.68)) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +292,7 @@ body {
|
||||
}
|
||||
|
||||
.match3d-tray-token-shift {
|
||||
animation: match3d-tray-token-shift 0.24s cubic-bezier(0.2, 0.8, 0.2, 1)
|
||||
both;
|
||||
animation: match3d-tray-token-shift 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@@ -309,8 +304,7 @@ body {
|
||||
|
||||
62% {
|
||||
opacity: 1;
|
||||
transform:
|
||||
translate3d(
|
||||
transform: translate3d(
|
||||
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
|
||||
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
|
||||
0
|
||||
@@ -320,8 +314,7 @@ body {
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform:
|
||||
translate3d(
|
||||
transform: translate3d(
|
||||
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
|
||||
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
|
||||
0
|
||||
@@ -359,8 +352,11 @@ body {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 9999px;
|
||||
background:
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.95) 0 10%, transparent 12%),
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 255, 255, 0.95) 0 10%,
|
||||
transparent 12%
|
||||
),
|
||||
radial-gradient(circle, rgba(255, 236, 157, 0.48) 0 42%, transparent 64%);
|
||||
box-shadow:
|
||||
0 0 22px rgba(255, 255, 255, 0.62),
|
||||
@@ -388,8 +384,11 @@ body {
|
||||
|
||||
.match3d-merge-feedback-pulse {
|
||||
animation: match3d-merge-feedback-pulse 0.52s ease-out both;
|
||||
background:
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.56) 0 18%, transparent 20%),
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 255, 255, 0.56) 0 18%,
|
||||
transparent 20%
|
||||
),
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.28) 0 45%, transparent 68%);
|
||||
}
|
||||
|
||||
@@ -640,6 +639,15 @@ body {
|
||||
--platform-nav-item-icon-active-fill: rgba(255, 254, 252, 0.98);
|
||||
--platform-nav-item-icon-active-text: #c7653d;
|
||||
--platform-nav-icon-active-shadow: 0 12px 24px rgba(182, 98, 63, 0.16);
|
||||
--platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef);
|
||||
--platform-bottom-nav-border: rgba(224, 181, 139, 0.58);
|
||||
--platform-bottom-nav-divider: rgba(225, 185, 145, 0.52);
|
||||
--platform-bottom-nav-text: #5b2b19;
|
||||
--platform-bottom-nav-muted-text: #7a4b37;
|
||||
--platform-bottom-nav-active-text: #f15a17;
|
||||
--platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12);
|
||||
--platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.28);
|
||||
--platform-bottom-nav-primary-text: #fffaf2;
|
||||
--platform-profile-hero-fill: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 254, 252, 0.96),
|
||||
@@ -897,6 +905,15 @@ body {
|
||||
);
|
||||
--platform-nav-item-icon-active-text: rgb(238 248 255);
|
||||
--platform-nav-icon-active-shadow: 0 12px 24px rgba(8, 14, 42, 0.42);
|
||||
--platform-bottom-nav-fill: linear-gradient(180deg, #fffefa, #fff9ef);
|
||||
--platform-bottom-nav-border: rgba(224, 181, 139, 0.64);
|
||||
--platform-bottom-nav-divider: rgba(225, 185, 145, 0.56);
|
||||
--platform-bottom-nav-text: #5b2b19;
|
||||
--platform-bottom-nav-muted-text: #7a4b37;
|
||||
--platform-bottom-nav-active-text: #f15a17;
|
||||
--platform-bottom-nav-primary-fill: linear-gradient(180deg, #ff7a23, #f04d12);
|
||||
--platform-bottom-nav-primary-shadow: 0 12px 24px rgba(240, 77, 18, 0.3);
|
||||
--platform-bottom-nav-primary-text: #fffaf2;
|
||||
--platform-profile-hero-fill: linear-gradient(
|
||||
180deg,
|
||||
rgba(20, 24, 58, 0.96),
|
||||
@@ -1832,7 +1849,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.creation-work-card.platform-interactive-card:hover {
|
||||
transform: translateX(var(--creation-work-card-swipe-offset, 0)) translateY(-2px);
|
||||
transform: translateX(var(--creation-work-card-swipe-offset, 0))
|
||||
translateY(-2px);
|
||||
border-color: var(--platform-surface-hover-border);
|
||||
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
@@ -1849,7 +1867,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(244, 63, 94, 0.12) 62%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(244, 63, 94, 0.12) 62%
|
||||
);
|
||||
opacity: var(--creation-work-card-action-opacity, 0);
|
||||
pointer-events: none;
|
||||
transition: opacity 160ms ease;
|
||||
@@ -1915,7 +1937,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.creation-work-card__side-cover .custom-world-cover-artwork > div:first-of-type {
|
||||
.creation-work-card__side-cover
|
||||
.custom-world-cover-artwork
|
||||
> div:first-of-type {
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
@@ -2451,22 +2475,24 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-bottom-nav {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--platform-bottom-nav-height);
|
||||
gap: var(--platform-bottom-nav-gap);
|
||||
border: 1px solid var(--platform-desktop-panel-border);
|
||||
border: 1px solid var(--platform-bottom-nav-border);
|
||||
border-radius: var(--platform-bottom-nav-radius);
|
||||
background: var(--platform-nav-fill);
|
||||
background: var(--platform-bottom-nav-fill);
|
||||
padding: var(--platform-bottom-nav-padding);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
0 16px 40px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||
0 10px 24px rgba(119, 63, 25, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
@@ -2488,7 +2514,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:not(:first-child)::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(var(--platform-bottom-nav-gap) * -0.5 - 0.5px);
|
||||
width: 1px;
|
||||
height: 48%;
|
||||
content: '';
|
||||
background: var(--platform-bottom-nav-divider);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
min-width: 0;
|
||||
@@ -2500,37 +2538,40 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover {
|
||||
color: var(--platform-text-strong);
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active {
|
||||
border: 1px solid var(--platform-nav-active-border);
|
||||
background: var(--platform-nav-active-fill);
|
||||
color: var(--platform-text-strong);
|
||||
box-shadow: var(--platform-bottom-nav-active-shadow);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary {
|
||||
transform: translateY(-0.18rem);
|
||||
color: var(--platform-text-strong);
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__button-content {
|
||||
transform: translateY(-0.32rem);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__icon-shell {
|
||||
width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
|
||||
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
|
||||
background: var(--platform-nav-active-fill);
|
||||
box-shadow: var(--platform-nav-icon-active-shadow);
|
||||
background: var(--platform-bottom-nav-primary-fill);
|
||||
box-shadow: var(--platform-bottom-nav-primary-shadow);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__icon {
|
||||
width: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
|
||||
height: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
|
||||
color: var(--platform-nav-item-icon-active-text);
|
||||
color: var(--platform-bottom-nav-primary-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__label {
|
||||
color: var(--platform-nav-item-text-active);
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -2550,6 +2591,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__icon-shell {
|
||||
background: transparent;
|
||||
color: var(--platform-bottom-nav-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__icon-shell {
|
||||
width: var(--platform-bottom-nav-icon-shell-size);
|
||||
height: var(--platform-bottom-nav-icon-shell-size);
|
||||
@@ -2568,6 +2614,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__icon {
|
||||
color: var(--platform-bottom-nav-text);
|
||||
stroke-width: 2.1;
|
||||
}
|
||||
|
||||
.platform-nav-unread-dot {
|
||||
position: absolute;
|
||||
right: 0.16rem;
|
||||
@@ -2591,11 +2642,32 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__label {
|
||||
color: var(--platform-bottom-nav-muted-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__active-mark {
|
||||
position: absolute;
|
||||
bottom: -0.32rem;
|
||||
left: 50%;
|
||||
width: 2rem;
|
||||
height: 0.16rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--platform-bottom-nav-active-text);
|
||||
transform: translateX(-50%);
|
||||
box-shadow: 0 5px 10px rgba(241, 90, 23, 0.22);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__icon-shell,
|
||||
.platform-desktop-rail__button:hover .platform-desktop-rail__icon-shell {
|
||||
background: var(--platform-nav-item-hover-fill);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__icon-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__icon,
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__label,
|
||||
.platform-desktop-rail__button:hover .platform-desktop-rail__icon,
|
||||
@@ -2603,22 +2675,52 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__icon,
|
||||
.platform-bottom-nav__button:hover .platform-bottom-nav__label {
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__icon-shell,
|
||||
.platform-desktop-rail__button--active .platform-desktop-rail__icon-shell {
|
||||
background: var(--platform-nav-item-icon-active-fill);
|
||||
box-shadow: var(--platform-nav-icon-active-shadow);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__icon-shell {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__icon,
|
||||
.platform-desktop-rail__button--active .platform-desktop-rail__icon {
|
||||
color: var(--platform-nav-item-icon-active-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__icon {
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__label,
|
||||
.platform-desktop-rail__button--active .platform-desktop-rail__label {
|
||||
color: var(--platform-nav-item-text-active);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--active .platform-bottom-nav__label {
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary.platform-bottom-nav__button--active
|
||||
.platform-bottom-nav__icon-shell {
|
||||
background: var(--platform-bottom-nav-primary-fill);
|
||||
box-shadow: var(--platform-bottom-nav-primary-shadow);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary.platform-bottom-nav__button--active
|
||||
.platform-bottom-nav__icon {
|
||||
color: var(--platform-bottom-nav-primary-text);
|
||||
}
|
||||
|
||||
.platform-modal-shell {
|
||||
border: 1px solid var(--platform-modal-border);
|
||||
background: var(--platform-modal-fill);
|
||||
@@ -2691,23 +2793,107 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: var(--puzzle-runtime-text-strong);
|
||||
}
|
||||
|
||||
.puzzle-runtime-icon-button--sprite {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.puzzle-runtime-icon-button--sprite:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.puzzle-runtime-icon-button--precise-hit {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.puzzle-runtime-top-ui-sprite {
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.puzzle-runtime-header-card {
|
||||
background: var(--puzzle-runtime-surface-fill-strong);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff7ed;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.puzzle-runtime-level-title-card {
|
||||
position: relative;
|
||||
min-width: min(14rem, calc(100vw - 7.75rem));
|
||||
min-height: 2.9rem;
|
||||
border: 2px solid rgba(255, 216, 173, 0.64);
|
||||
border-radius: 0.62rem 1.35rem 1.35rem 0.62rem;
|
||||
background: linear-gradient(180deg, rgba(255, 164, 92, 0.18), transparent 46%),
|
||||
linear-gradient(135deg, #c86a34 0%, #b54f25 56%, #a84622 100%);
|
||||
}
|
||||
|
||||
.puzzle-runtime-level-title-card::before {
|
||||
position: absolute;
|
||||
inset: 0.2rem 0.58rem auto 3.05rem;
|
||||
height: 1px;
|
||||
content: '';
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 232, 199, 0.36);
|
||||
}
|
||||
|
||||
.puzzle-runtime-level-logo {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 2.95rem;
|
||||
height: 2.95rem;
|
||||
flex: 0 0 auto;
|
||||
margin: -0.62rem 0 -0.62rem -1.18rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.puzzle-runtime-level-logo__image {
|
||||
position: absolute;
|
||||
left: -1.1rem;
|
||||
top: -1.24rem;
|
||||
display: block;
|
||||
width: 5.3rem;
|
||||
height: 5.3rem;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.puzzle-runtime-level-badge {
|
||||
background: var(--puzzle-runtime-control-fill);
|
||||
color: var(--puzzle-runtime-accent-text);
|
||||
color: #fffaf2;
|
||||
text-shadow: 0 1px 0 rgba(84, 33, 15, 0.3);
|
||||
}
|
||||
|
||||
.puzzle-runtime-timer {
|
||||
background: var(--puzzle-runtime-control-fill);
|
||||
color: var(--puzzle-runtime-text-strong);
|
||||
min-width: 6.15rem;
|
||||
min-height: 2.05rem;
|
||||
border: 1px solid rgba(223, 185, 145, 0.62);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 0.82rem 0.82rem;
|
||||
background: linear-gradient(180deg, #fff8ed, #fffdf7);
|
||||
color: #4e2a1d;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9),
|
||||
0 8px 18px rgba(86, 43, 18, 0.14);
|
||||
}
|
||||
|
||||
.puzzle-runtime-timer--urgent {
|
||||
background: var(--puzzle-runtime-danger-fill);
|
||||
color: var(--puzzle-runtime-danger-text);
|
||||
min-width: 6.15rem;
|
||||
min-height: 2.05rem;
|
||||
border: 1px solid rgba(242, 101, 33, 0.36);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 0.82rem 0.82rem;
|
||||
background: linear-gradient(180deg, #fff2e6, #fffaf1);
|
||||
color: #d84012;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.88),
|
||||
0 8px 18px rgba(211, 64, 18, 0.16);
|
||||
}
|
||||
|
||||
.puzzle-runtime-board {
|
||||
@@ -2783,6 +2969,25 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: var(--puzzle-runtime-text-base);
|
||||
}
|
||||
|
||||
.puzzle-runtime-sprite-tool-button {
|
||||
max-width: 7rem;
|
||||
color: var(--puzzle-runtime-text-base);
|
||||
}
|
||||
|
||||
.puzzle-runtime-sprite-tool-button--precise-hit {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.puzzle-runtime-bottom-ui-sprite {
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.puzzle-runtime-ui-sprite-hit-zone {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.puzzle-runtime-tool-button:hover {
|
||||
background: var(--puzzle-runtime-control-hover-fill);
|
||||
}
|
||||
@@ -2815,6 +3020,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
background: var(--puzzle-runtime-backdrop-fill);
|
||||
}
|
||||
|
||||
.puzzle-runtime-modal-overlay--fixed {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 130;
|
||||
}
|
||||
|
||||
.puzzle-runtime-dialog {
|
||||
position: relative;
|
||||
border: 1px solid var(--puzzle-runtime-dialog-border);
|
||||
@@ -2832,8 +3043,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.16),
|
||||
transparent 36%
|
||||
@@ -2910,11 +3120,27 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.baby-object-runtime {
|
||||
--baby-object-background-image: linear-gradient(180deg, transparent, transparent);
|
||||
--baby-object-ui-frame-image: linear-gradient(180deg, transparent, transparent);
|
||||
--baby-object-gift-box-image: linear-gradient(180deg, transparent, transparent);
|
||||
--baby-object-background-image: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
transparent
|
||||
);
|
||||
--baby-object-ui-frame-image: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
transparent
|
||||
);
|
||||
--baby-object-gift-box-image: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
transparent
|
||||
);
|
||||
--baby-object-basket-image: linear-gradient(180deg, transparent, transparent);
|
||||
--baby-object-smoke-image: radial-gradient(circle, rgba(255, 255, 255, 0.9), transparent 68%);
|
||||
--baby-object-smoke-image: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 255, 255, 0.9),
|
||||
transparent 68%
|
||||
);
|
||||
--baby-object-left-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png');
|
||||
--baby-object-right-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png');
|
||||
--baby-object-sky: #cfefff;
|
||||
@@ -2930,10 +3156,23 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
min-height: 100dvh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.9) 0 6%, transparent 6.4%),
|
||||
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.78) 0 7%, transparent 7.4%),
|
||||
linear-gradient(180deg, #f8fcff 0%, var(--baby-object-sky) 56%, #dff2cf 57%, #b8df9d 100%);
|
||||
background: radial-gradient(
|
||||
circle at 18% 16%,
|
||||
rgba(255, 255, 255, 0.9) 0 6%,
|
||||
transparent 6.4%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 10%,
|
||||
rgba(255, 255, 255, 0.78) 0 7%,
|
||||
transparent 7.4%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
#f8fcff 0%,
|
||||
var(--baby-object-sky) 56%,
|
||||
#dff2cf 57%,
|
||||
#b8df9d 100%
|
||||
);
|
||||
color: var(--baby-object-text);
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -2977,9 +3216,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
inset: auto -10% 0;
|
||||
height: 39%;
|
||||
border-radius: 50% 50% 0 0 / 24% 24% 0 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 12%, rgba(255, 255, 255, 0.3) 0 7%, transparent 7.4%),
|
||||
linear-gradient(180deg, var(--baby-object-ground), var(--baby-object-ground-deep));
|
||||
background: radial-gradient(
|
||||
ellipse at 30% 12%,
|
||||
rgba(255, 255, 255, 0.3) 0 7%,
|
||||
transparent 7.4%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--baby-object-ground),
|
||||
var(--baby-object-ground-deep)
|
||||
);
|
||||
box-shadow: inset 0 24px 42px rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
|
||||
@@ -3053,8 +3299,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
transform: translateX(-50%);
|
||||
border: 0.45rem solid #ffe7a8;
|
||||
border-radius: 1.35rem;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 42%, rgba(255, 255, 255, 0.35) 42% 58%, transparent 58%),
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 42%,
|
||||
rgba(255, 255, 255, 0.35) 42% 58%,
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(180deg, #ff8f70, #ff5d78);
|
||||
color: #fff7d7;
|
||||
box-shadow:
|
||||
@@ -3156,12 +3406,31 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%) scale(0.68);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 24% 62%, rgba(255, 255, 255, 0.92) 0 12%, transparent 12.8%),
|
||||
radial-gradient(circle at 40% 36%, rgba(255, 255, 255, 0.95) 0 16%, transparent 16.8%),
|
||||
radial-gradient(circle at 62% 38%, rgba(255, 255, 255, 0.9) 0 14%, transparent 14.8%),
|
||||
radial-gradient(circle at 74% 62%, rgba(255, 255, 255, 0.86) 0 12%, transparent 12.8%),
|
||||
radial-gradient(circle at 50% 54%, rgba(255, 242, 202, 0.72) 0 28%, transparent 29%);
|
||||
background: radial-gradient(
|
||||
circle at 24% 62%,
|
||||
rgba(255, 255, 255, 0.92) 0 12%,
|
||||
transparent 12.8%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 40% 36%,
|
||||
rgba(255, 255, 255, 0.95) 0 16%,
|
||||
transparent 16.8%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 62% 38%,
|
||||
rgba(255, 255, 255, 0.9) 0 14%,
|
||||
transparent 14.8%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 74% 62%,
|
||||
rgba(255, 255, 255, 0.86) 0 12%,
|
||||
transparent 12.8%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 54%,
|
||||
rgba(255, 242, 202, 0.72) 0 28%,
|
||||
transparent 29%
|
||||
);
|
||||
filter: drop-shadow(0 18px 28px rgba(98, 83, 52, 0.12));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -3172,7 +3441,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
border-radius: 0;
|
||||
background:
|
||||
var(--baby-object-smoke-image) center / contain no-repeat,
|
||||
radial-gradient(circle at 50% 54%, rgba(255, 255, 255, 0.48), transparent 66%);
|
||||
radial-gradient(
|
||||
circle at 50% 54%,
|
||||
rgba(255, 255, 255, 0.48),
|
||||
transparent 66%
|
||||
);
|
||||
}
|
||||
|
||||
.baby-object-runtime__smoke--releasing {
|
||||
@@ -3337,7 +3610,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(var(--baby-object-intro-target-x), var(--baby-object-intro-target-y))
|
||||
transform: translate(
|
||||
var(--baby-object-intro-target-x),
|
||||
var(--baby-object-intro-target-y)
|
||||
)
|
||||
scale(0.68);
|
||||
}
|
||||
}
|
||||
@@ -3463,15 +3739,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.baby-object-runtime__hand--holding {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.08);
|
||||
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg))
|
||||
scale(1.08);
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand--holding-left-corner {
|
||||
transform: translate(-112%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
|
||||
transform: translate(-112%, -6%) rotate(var(--baby-object-hand-rotate, 0deg))
|
||||
scale(1.02);
|
||||
}
|
||||
|
||||
.baby-object-runtime__hand--holding-right-corner {
|
||||
transform: translate(12%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
|
||||
transform: translate(12%, -6%) rotate(var(--baby-object-hand-rotate, 0deg))
|
||||
scale(1.02);
|
||||
}
|
||||
|
||||
.baby-object-runtime__feedback {
|
||||
@@ -3529,7 +3808,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
padding: 0 max(3vw, env(safe-area-inset-right)) 0 max(3vw, env(safe-area-inset-left));
|
||||
padding: 0 max(3vw, env(safe-area-inset-right)) 0
|
||||
max(3vw, env(safe-area-inset-left));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -3549,11 +3829,26 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
z-index: 4;
|
||||
inset: -18% -16% 6%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 42%, rgba(255, 247, 181, 0.55) 0 20%, transparent 21%),
|
||||
radial-gradient(circle at 20% 22%, rgba(255, 255, 255, 0.95) 0 5%, transparent 5.5%),
|
||||
radial-gradient(circle at 82% 26%, rgba(255, 255, 255, 0.86) 0 4%, transparent 4.5%),
|
||||
radial-gradient(circle at 68% 76%, rgba(255, 226, 103, 0.9) 0 5%, transparent 5.5%);
|
||||
background: radial-gradient(
|
||||
circle at 50% 42%,
|
||||
rgba(255, 247, 181, 0.55) 0 20%,
|
||||
transparent 21%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 20% 22%,
|
||||
rgba(255, 255, 255, 0.95) 0 5%,
|
||||
transparent 5.5%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 82% 26%,
|
||||
rgba(255, 255, 255, 0.86) 0 4%,
|
||||
transparent 4.5%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 68% 76%,
|
||||
rgba(255, 226, 103, 0.9) 0 5%,
|
||||
transparent 5.5%
|
||||
);
|
||||
pointer-events: none;
|
||||
animation: baby-object-basket-spark 1.08s ease-out;
|
||||
}
|
||||
@@ -3665,8 +3960,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
border: 0.28rem solid rgba(139, 84, 40, 0.72);
|
||||
border-top-width: 0.42rem;
|
||||
border-radius: 0.8rem 0.8rem 2rem 2rem;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, rgba(139, 84, 40, 0.18) 0 0.7rem, transparent 0.7rem 1.4rem),
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(139, 84, 40, 0.18) 0 0.7rem,
|
||||
transparent 0.7rem 1.4rem
|
||||
),
|
||||
linear-gradient(180deg, #ffd980, #d99845);
|
||||
box-shadow: 0 18px 28px rgba(95, 84, 54, 0.2);
|
||||
}
|
||||
@@ -3742,10 +4040,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(72, 118, 72, 0.18);
|
||||
border-radius: 1.25rem;
|
||||
background:
|
||||
radial-gradient(circle at 16% 18%, rgba(255, 244, 184, 0.72) 0 12%, transparent 12.6%),
|
||||
radial-gradient(circle at 84% 20%, rgba(130, 212, 255, 0.58) 0 11%, transparent 11.8%),
|
||||
linear-gradient(135deg, rgba(255, 253, 244, 0.96), rgba(217, 247, 229, 0.92));
|
||||
background: radial-gradient(
|
||||
circle at 16% 18%,
|
||||
rgba(255, 244, 184, 0.72) 0 12%,
|
||||
transparent 12.6%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 84% 20%,
|
||||
rgba(130, 212, 255, 0.58) 0 11%,
|
||||
transparent 11.8%
|
||||
),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 253, 244, 0.96),
|
||||
rgba(217, 247, 229, 0.92)
|
||||
);
|
||||
padding: 1.05rem;
|
||||
text-align: left;
|
||||
color: #24422b;
|
||||
@@ -3805,14 +4114,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: clamp(0.65rem, 1.6vw, 1.1rem);
|
||||
overflow: hidden;
|
||||
padding:
|
||||
max(0.85rem, env(safe-area-inset-top))
|
||||
padding: max(0.85rem, env(safe-area-inset-top))
|
||||
max(0.85rem, env(safe-area-inset-right))
|
||||
max(0.85rem, env(safe-area-inset-bottom))
|
||||
max(0.85rem, env(safe-area-inset-left));
|
||||
background:
|
||||
radial-gradient(circle at 14% 18%, rgba(255, 244, 184, 0.72) 0 9%, transparent 9.5%),
|
||||
radial-gradient(circle at 88% 16%, rgba(151, 224, 255, 0.58) 0 8%, transparent 8.6%),
|
||||
background: radial-gradient(
|
||||
circle at 14% 18%,
|
||||
rgba(255, 244, 184, 0.72) 0 9%,
|
||||
transparent 9.5%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 88% 16%,
|
||||
rgba(151, 224, 255, 0.58) 0 8%,
|
||||
transparent 8.6%
|
||||
),
|
||||
linear-gradient(180deg, #f5fbff 0%, #d8f0e8 52%, #f7edc7 100%);
|
||||
color: var(--baby-drawing-ink);
|
||||
touch-action: none;
|
||||
@@ -3902,8 +4217,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
overflow: hidden;
|
||||
border: clamp(0.35rem, 1vw, 0.7rem) solid rgba(255, 255, 255, 0.82);
|
||||
border-radius: 1.35rem;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(245, 216, 145, 0.16) 1px, transparent 1px),
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(245, 216, 145, 0.16) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(180deg, rgba(245, 216, 145, 0.16) 1px, transparent 1px),
|
||||
var(--baby-drawing-paper);
|
||||
background-size: 2.4rem 2.4rem;
|
||||
@@ -4323,15 +4641,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.platform-mobile-bottom-dock .platform-bottom-nav {
|
||||
width: min(100%, 24rem);
|
||||
pointer-events: auto;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--platform-desktop-panel-border) 76%,
|
||||
transparent
|
||||
);
|
||||
background: var(--platform-nav-fill);
|
||||
border-color: var(--platform-bottom-nav-border);
|
||||
background: var(--platform-bottom-nav-fill);
|
||||
box-shadow:
|
||||
0 20px 48px rgba(0, 0, 0, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
0 16px 34px rgba(101, 52, 19, 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.platform-mobile-bottom-dock .platform-bottom-nav__button {
|
||||
@@ -4340,7 +4654,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
);
|
||||
}
|
||||
|
||||
.platform-mobile-bottom-dock .platform-bottom-nav__button--primary {
|
||||
.platform-mobile-bottom-dock
|
||||
.platform-bottom-nav__button--primary
|
||||
.platform-bottom-nav__button-content {
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
|
||||
@@ -4349,23 +4665,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.platform-bottom-nav__icon-shell {
|
||||
width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.72rem);
|
||||
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.72rem);
|
||||
background: var(--platform-button-primary-fill);
|
||||
color: var(--platform-button-primary-text);
|
||||
box-shadow:
|
||||
0 14px 28px rgba(0, 0, 0, 0.18),
|
||||
var(--platform-profile-action-shadow);
|
||||
background: var(--platform-bottom-nav-primary-fill);
|
||||
color: var(--platform-bottom-nav-primary-text);
|
||||
box-shadow: var(--platform-bottom-nav-primary-shadow);
|
||||
}
|
||||
|
||||
.platform-mobile-bottom-dock
|
||||
.platform-bottom-nav__button--primary
|
||||
.platform-bottom-nav__icon {
|
||||
color: var(--platform-button-primary-text);
|
||||
color: var(--platform-bottom-nav-primary-text);
|
||||
}
|
||||
|
||||
.platform-mobile-bottom-dock
|
||||
.platform-bottom-nav__button--primary
|
||||
.platform-bottom-nav__label {
|
||||
color: var(--platform-nav-item-text-active);
|
||||
color: var(--platform-bottom-nav-active-text);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@@ -4916,7 +5230,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
min-height: min(58vh, 28rem);
|
||||
}
|
||||
|
||||
.platform-public-work-card--immersive .platform-public-work-card__cover::before {
|
||||
.platform-public-work-card--immersive
|
||||
.platform-public-work-card__cover::before {
|
||||
padding-top: 122%;
|
||||
}
|
||||
|
||||
@@ -4972,7 +5287,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.creation-work-card__side-cover {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (min-width: 1180px) {
|
||||
@@ -7998,7 +8312,8 @@ button {
|
||||
z-index: 2;
|
||||
height: 30%;
|
||||
border-radius: 0;
|
||||
background: var(--child-motion-asset-floor) center bottom / 100% auto no-repeat;
|
||||
background: var(--child-motion-asset-floor) center bottom / 100% auto
|
||||
no-repeat;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@@ -8339,7 +8654,8 @@ button {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-track {
|
||||
.child-motion-gesture-guide__arm-swing--right
|
||||
.child-motion-gesture-guide__arm-swing-track {
|
||||
border-inline-start-color: transparent;
|
||||
border-inline-end-color: rgba(255, 221, 124, 0.78);
|
||||
}
|
||||
@@ -8391,11 +8707,13 @@ button {
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing--left .child-motion-gesture-guide__arm-swing-paw-asset {
|
||||
.child-motion-gesture-guide__arm-swing--left
|
||||
.child-motion-gesture-guide__arm-swing-paw-asset {
|
||||
background-image: var(--child-motion-asset-wave-cat-paw-left);
|
||||
}
|
||||
|
||||
.child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-paw-asset {
|
||||
.child-motion-gesture-guide__arm-swing--right
|
||||
.child-motion-gesture-guide__arm-swing-paw-asset {
|
||||
background-image: var(--child-motion-asset-wave-cat-paw-right);
|
||||
}
|
||||
|
||||
@@ -8421,7 +8739,9 @@ button {
|
||||
|
||||
@keyframes child-motion-wave-cat-arm {
|
||||
from {
|
||||
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * -15deg));
|
||||
transform: rotate(
|
||||
calc(var(--child-motion-wave-hand-direction, 1) * -15deg)
|
||||
);
|
||||
}
|
||||
|
||||
to {
|
||||
@@ -8486,7 +8806,8 @@ button {
|
||||
height: clamp(3.1rem, 7.6%, 4.55rem);
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--child-motion-asset-calibration) center center / cover no-repeat;
|
||||
background: var(--child-motion-asset-calibration) center center / cover
|
||||
no-repeat;
|
||||
padding: clamp(0.4rem, 1.1vw, 0.56rem) clamp(0.66rem, 1.5vw, 0.9rem);
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
@@ -8536,7 +8857,8 @@ button {
|
||||
gap: 0.85rem;
|
||||
border: 0;
|
||||
border-radius: 1.4rem;
|
||||
background: var(--child-motion-asset-start-panel) center center / cover no-repeat;
|
||||
background: var(--child-motion-asset-start-panel) center center / cover
|
||||
no-repeat;
|
||||
padding: clamp(0.45rem, 1.2vw, 0.7rem);
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
|
||||
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
||||
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
||||
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
|
||||
const MATCH3D_MAX_ITEM_TYPE_COUNT = 20;
|
||||
const MATCH3D_ITEMS_PER_CLEAR = 3;
|
||||
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
|
||||
const MATCH3D_LOCAL_BOARD_CENTER = 0.5;
|
||||
@@ -50,7 +50,7 @@ const MATCH3D_SIZE_TIER_RULES: Array<{
|
||||
];
|
||||
|
||||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
// 中文注释:默认 25 类使用参考图中的积木件,形状、尺寸和颜色都要能区分。
|
||||
// 中文注释:默认 20 类对齐 10*10 物品 Sprite,可由生成素材替换显示。
|
||||
{
|
||||
itemTypeId: 'block-red-2x4',
|
||||
visualKey: 'block-red-2x4',
|
||||
@@ -99,12 +99,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
colorClassName: 'from-amber-100 to-yellow-600',
|
||||
label: '米色二乘三',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-lime-1x2',
|
||||
visualKey: 'block-lime-1x2',
|
||||
colorClassName: 'from-lime-300 to-lime-700',
|
||||
label: '青柠一乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-darkred-2x2',
|
||||
visualKey: 'block-darkred-2x2',
|
||||
@@ -141,18 +135,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
colorClassName: 'from-teal-300 to-teal-700',
|
||||
label: '青色长光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-mint-tile-1x4',
|
||||
visualKey: 'block-mint-tile-1x4',
|
||||
colorClassName: 'from-emerald-100 to-emerald-400',
|
||||
label: '薄荷长光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-magenta-tile-2x2',
|
||||
visualKey: 'block-magenta-tile-2x2',
|
||||
colorClassName: 'from-fuchsia-500 to-pink-800',
|
||||
label: '洋红光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-orange-tile-2x2-stud',
|
||||
visualKey: 'block-orange-tile-2x2-stud',
|
||||
@@ -165,18 +147,6 @@ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
colorClassName: 'from-violet-400 to-violet-900',
|
||||
label: '紫色斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-brown-slope-1x2',
|
||||
visualKey: 'block-brown-slope-1x2',
|
||||
colorClassName: 'from-orange-900 to-stone-700',
|
||||
label: '棕色斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-sky-slope-2x2',
|
||||
visualKey: 'block-sky-slope-2x2',
|
||||
colorClassName: 'from-sky-300 to-sky-600',
|
||||
label: '天蓝斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-green-cylinder',
|
||||
visualKey: 'block-green-cylinder',
|
||||
@@ -236,13 +206,14 @@ export function resolveLocalMatch3DItemTypeCount(clearCount: number) {
|
||||
if (normalizedClearCount === 8) return 3;
|
||||
if (normalizedClearCount === 12) return 9;
|
||||
if (normalizedClearCount === 16) return 15;
|
||||
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 21;
|
||||
if (normalizedClearCount === 20 || normalizedClearCount === 21) return 20;
|
||||
return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount);
|
||||
}
|
||||
|
||||
export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩按新硬核 21 组三消执行。
|
||||
// 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩保留硬核 21 组三消节奏,
|
||||
// 但物品类型池最多加载 20 种,避免超过 10*10 Sprite 解析素材上限。
|
||||
return normalizedClearCount === 20 ? 21 : normalizedClearCount;
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ describe('match3dGeneratedModelCache', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('运行态预加载同时解析背景和容器 UI 资产', async () => {
|
||||
test('运行态预加载同时解析背景和spritesheet资产', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
@@ -309,6 +309,14 @@ describe('match3dGeneratedModelCache', () => {
|
||||
imageSrc: null,
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/task/background.png',
|
||||
uiSpritesheetPrompt: '果园 UI',
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
|
||||
itemSpritesheetPrompt: '果园物品',
|
||||
itemSpritesheetImageSrc: null,
|
||||
itemSpritesheetImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
|
||||
containerPrompt: '果园浅盘',
|
||||
containerImageSrc: null,
|
||||
containerImageObjectKey:
|
||||
@@ -321,13 +329,15 @@ describe('match3dGeneratedModelCache', () => {
|
||||
|
||||
expect(getMatch3DGeneratedRuntimeUiAssetSources(assets)).toEqual([
|
||||
'generated-match3d-assets/session/profile/background/task/background.png',
|
||||
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
|
||||
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
|
||||
'generated-match3d-assets/session/profile/ui-container/task/container.png',
|
||||
]);
|
||||
await preloadMatch3DGeneratedRuntimeAssets(assets, null, {
|
||||
expireSeconds: 300,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(5);
|
||||
expect(
|
||||
vi
|
||||
.mocked(globalThis.fetch)
|
||||
@@ -336,6 +346,8 @@ describe('match3dGeneratedModelCache', () => {
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('/items/item-1/views/view-01.png'),
|
||||
expect.stringContaining('/background/task/background.png'),
|
||||
expect.stringContaining('/ui-spritesheet/task/ui.png'),
|
||||
expect.stringContaining('/item-spritesheet/task/items.png'),
|
||||
expect.stringContaining('/ui-container/task/container.png'),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -129,11 +129,19 @@ export function getMatch3DGeneratedRuntimeUiAssetSources(
|
||||
[
|
||||
backgroundAsset?.imageObjectKey,
|
||||
backgroundAsset?.imageSrc,
|
||||
backgroundAsset?.uiSpritesheetImageObjectKey,
|
||||
backgroundAsset?.uiSpritesheetImageSrc,
|
||||
backgroundAsset?.itemSpritesheetImageObjectKey,
|
||||
backgroundAsset?.itemSpritesheetImageSrc,
|
||||
backgroundAsset?.containerImageObjectKey,
|
||||
backgroundAsset?.containerImageSrc,
|
||||
...assets.flatMap((asset) => [
|
||||
asset.backgroundAsset?.imageObjectKey,
|
||||
asset.backgroundAsset?.imageSrc,
|
||||
asset.backgroundAsset?.uiSpritesheetImageObjectKey,
|
||||
asset.backgroundAsset?.uiSpritesheetImageSrc,
|
||||
asset.backgroundAsset?.itemSpritesheetImageObjectKey,
|
||||
asset.backgroundAsset?.itemSpritesheetImageSrc,
|
||||
asset.backgroundAsset?.containerImageObjectKey,
|
||||
asset.backgroundAsset?.containerImageSrc,
|
||||
]),
|
||||
|
||||
98
src/services/match3dSpritesheetParser.test.ts
Normal file
98
src/services/match3dSpritesheetParser.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildMatch3DItemSpritesheetViewRegions,
|
||||
detectMatch3DSpritesheetRegions,
|
||||
} from './match3dSpritesheetParser';
|
||||
|
||||
describe('match3dSpritesheetParser', () => {
|
||||
test('按透明像素连通域检测素材并按从上到下从左到右排序', () => {
|
||||
const width = 12;
|
||||
const height = 10;
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
const paint = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
for (let y = y0; y <= y1; y += 1) {
|
||||
for (let x = x0; x <= x1; x += 1) {
|
||||
alpha[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
paint(8, 1, 10, 3);
|
||||
paint(1, 1, 3, 2);
|
||||
paint(5, 6, 7, 8);
|
||||
|
||||
const regions = detectMatch3DSpritesheetRegions({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
labels: ['返回', '设置', '移出'],
|
||||
});
|
||||
|
||||
expect(regions).toEqual([
|
||||
{ height: 2, label: '返回', width: 3, x: 1, y: 1 },
|
||||
{ height: 3, label: '设置', width: 3, x: 8, y: 1 },
|
||||
{ height: 3, label: '移出', width: 3, x: 5, y: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('忽略小噪点,只返回可用矩形素材', () => {
|
||||
const width = 8;
|
||||
const height = 8;
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
alpha[0] = 255;
|
||||
for (let y = 2; y <= 5; y += 1) {
|
||||
for (let x = 2; x <= 5; x += 1) {
|
||||
alpha[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
const regions = detectMatch3DSpritesheetRegions({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
labels: ['方格'],
|
||||
minArea: 4,
|
||||
});
|
||||
|
||||
expect(regions).toEqual([
|
||||
{ height: 4, label: '方格', width: 4, x: 2, y: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('按10行10列图集顺序把每行两种物品拆成五视角', () => {
|
||||
const regions = Array.from({ length: 100 }, (_, index) => ({
|
||||
label: `素材${index + 1}`,
|
||||
x: (index % 10) * 10,
|
||||
y: Math.floor(index / 10) * 10,
|
||||
width: 8,
|
||||
height: 8,
|
||||
}));
|
||||
|
||||
const grouped = buildMatch3DItemSpritesheetViewRegions(regions, [
|
||||
'草莓',
|
||||
'苹果',
|
||||
'毛肚',
|
||||
]);
|
||||
|
||||
expect(grouped).toHaveLength(20);
|
||||
expect(grouped[0]).toEqual({
|
||||
itemIndex: 0,
|
||||
itemName: '草莓',
|
||||
regions: regions.slice(0, 5).map((region, index) => ({
|
||||
...region,
|
||||
label: `草莓-形态${index + 1}`,
|
||||
})),
|
||||
});
|
||||
expect(grouped[1]).toEqual({
|
||||
itemIndex: 1,
|
||||
itemName: '苹果',
|
||||
regions: regions.slice(5, 10).map((region, index) => ({
|
||||
...region,
|
||||
label: `苹果-形态${index + 1}`,
|
||||
})),
|
||||
});
|
||||
expect(grouped[2]?.itemName).toBe('毛肚');
|
||||
expect(grouped[19]?.regions).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
352
src/services/match3dSpritesheetParser.ts
Normal file
352
src/services/match3dSpritesheetParser.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { readAssetBytes } from './assetReadUrlService';
|
||||
|
||||
export type Match3DSpritesheetRegion = {
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type Match3DItemSpritesheetViewRegion = Match3DSpritesheetRegion & {
|
||||
itemIndex: number;
|
||||
itemName: string;
|
||||
viewIndex: number;
|
||||
};
|
||||
|
||||
export type DetectMatch3DSpritesheetRegionsInput = {
|
||||
alpha: ArrayLike<number>;
|
||||
width: number;
|
||||
height: number;
|
||||
labels?: readonly string[];
|
||||
minArea?: number;
|
||||
alphaThreshold?: number;
|
||||
};
|
||||
|
||||
export type Match3DDecodedSpritesheetRegion = Match3DSpritesheetRegion & {
|
||||
imageSrc: string;
|
||||
sheetWidth: number;
|
||||
sheetHeight: number;
|
||||
};
|
||||
|
||||
export type LoadMatch3DSpritesheetAssetRegionsInput = {
|
||||
source: string;
|
||||
labels?: readonly string[];
|
||||
minArea?: number;
|
||||
alphaThreshold?: number;
|
||||
maxRegions?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type Match3DDetectedComponent = Omit<Match3DSpritesheetRegion, 'label'> & {
|
||||
area: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 中文注释:AI spritesheet 只保证透明背景,不保证固定坐标;运行态和编辑器统一按 alpha 连通域识别独立素材矩形。
|
||||
*/
|
||||
export function detectMatch3DSpritesheetRegions({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
labels = [],
|
||||
minArea = 1,
|
||||
alphaThreshold = 0,
|
||||
}: DetectMatch3DSpritesheetRegionsInput): Match3DSpritesheetRegion[] {
|
||||
const pixelCount = width * height;
|
||||
if (width <= 0 || height <= 0 || alpha.length < pixelCount) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visited = new Uint8Array(pixelCount);
|
||||
const components: Match3DDetectedComponent[] = [];
|
||||
|
||||
for (let start = 0; start < pixelCount; start += 1) {
|
||||
if (visited[start] || (alpha[start] ?? 0) <= alphaThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = floodFillMatch3DSpritesheetComponent({
|
||||
alpha,
|
||||
visited,
|
||||
width,
|
||||
height,
|
||||
start,
|
||||
alphaThreshold,
|
||||
});
|
||||
if (component.area >= minArea) {
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
|
||||
return components
|
||||
.sort((left, right) => left.y - right.y || left.x - right.x)
|
||||
.map((component, index) => ({
|
||||
label: labels[index] ?? `素材${index + 1}`,
|
||||
x: component.x,
|
||||
y: component.y,
|
||||
width: component.width,
|
||||
height: component.height,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildMatch3DItemSpritesheetViewRegions<
|
||||
Region extends Match3DSpritesheetRegion,
|
||||
>(
|
||||
regions: readonly Region[],
|
||||
itemNames: readonly string[],
|
||||
): Array<{
|
||||
itemIndex: number;
|
||||
itemName: string;
|
||||
regions: Region[];
|
||||
}> {
|
||||
if (regions.length <= 0 || itemNames.length <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemCount = Math.min(20, Math.floor(regions.length / 5));
|
||||
return Array.from({ length: itemCount }, (_, itemIndex) => {
|
||||
const itemName = itemNames[itemIndex]?.trim() || `物品${itemIndex + 1}`;
|
||||
return {
|
||||
itemIndex,
|
||||
itemName,
|
||||
regions: regions.slice(itemIndex * 5, itemIndex * 5 + 5).map(
|
||||
(region, viewIndex): Region => ({
|
||||
...region,
|
||||
label: `${itemName}-形态${viewIndex + 1}`,
|
||||
}) as Region,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadMatch3DSpritesheetAssetRegions({
|
||||
source,
|
||||
labels = [],
|
||||
minArea = 16,
|
||||
alphaThreshold = 8,
|
||||
maxRegions,
|
||||
signal,
|
||||
}: LoadMatch3DSpritesheetAssetRegionsInput): Promise<
|
||||
Match3DDecodedSpritesheetRegion[]
|
||||
> {
|
||||
const decoded = await decodeMatch3DSpritesheetImage(source, signal);
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('spritesheet 读取已取消', 'AbortError');
|
||||
}
|
||||
|
||||
const regions = detectMatch3DSpritesheetRegions({
|
||||
alpha: decoded.alpha,
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
labels,
|
||||
minArea,
|
||||
alphaThreshold,
|
||||
}).slice(0, maxRegions ?? Number.POSITIVE_INFINITY);
|
||||
|
||||
return regions.map((region) => ({
|
||||
...region,
|
||||
imageSrc: cropMatch3DSpritesheetRegionToDataUrl(decoded.image, region),
|
||||
sheetWidth: decoded.width,
|
||||
sheetHeight: decoded.height,
|
||||
}));
|
||||
}
|
||||
|
||||
function loadMatch3DSpritesheetImage(source: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
async function readMatch3DSpritesheetImageSource(
|
||||
source: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const response = await readAssetBytes(source, {
|
||||
signal,
|
||||
expireSeconds: 300,
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const canCreateObjectUrl =
|
||||
typeof URL.createObjectURL === 'function' &&
|
||||
typeof URL.revokeObjectURL === 'function';
|
||||
if (canCreateObjectUrl) {
|
||||
return {
|
||||
imageSource: URL.createObjectURL(blob),
|
||||
revoke: (imageSource: string) => URL.revokeObjectURL(imageSource),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
imageSource: await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.onerror = () => reject(new Error('读取抓大鹅 spritesheet 失败'));
|
||||
reader.readAsDataURL(blob);
|
||||
}),
|
||||
revoke: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
async function decodeMatch3DSpritesheetImage(
|
||||
source: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const { imageSource, revoke } = await readMatch3DSpritesheetImageSource(
|
||||
source,
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
const image = await loadMatch3DSpritesheetImage(imageSource);
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('spritesheet 读取已取消', 'AbortError');
|
||||
}
|
||||
const width = Math.max(1, image.naturalWidth || image.width || 1);
|
||||
const height = Math.max(1, image.naturalHeight || image.height || 1);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!context) {
|
||||
throw new Error('浏览器不支持解析抓大鹅 spritesheet');
|
||||
}
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const pixels = context.getImageData(0, 0, width, height).data;
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
for (let index = 0; index < alpha.length; index += 1) {
|
||||
alpha[index] = pixels[index * 4 + 3] ?? 0;
|
||||
}
|
||||
return {
|
||||
alpha,
|
||||
height,
|
||||
image,
|
||||
width,
|
||||
};
|
||||
} finally {
|
||||
revoke(imageSource);
|
||||
}
|
||||
}
|
||||
|
||||
function cropMatch3DSpritesheetRegionToDataUrl(
|
||||
image: HTMLImageElement,
|
||||
region: Match3DSpritesheetRegion,
|
||||
) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(region.width));
|
||||
canvas.height = Math.max(1, Math.round(region.height));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('浏览器不支持裁切抓大鹅 spritesheet');
|
||||
}
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(
|
||||
image,
|
||||
region.x,
|
||||
region.y,
|
||||
region.width,
|
||||
region.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function floodFillMatch3DSpritesheetComponent({
|
||||
alpha,
|
||||
visited,
|
||||
width,
|
||||
height,
|
||||
start,
|
||||
alphaThreshold,
|
||||
}: {
|
||||
alpha: ArrayLike<number>;
|
||||
visited: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
start: number;
|
||||
alphaThreshold: number;
|
||||
}): Match3DDetectedComponent {
|
||||
const stack = [start];
|
||||
visited[start] = 1;
|
||||
|
||||
let minX = start % width;
|
||||
let maxX = minX;
|
||||
let minY = Math.floor(start / width);
|
||||
let maxY = minY;
|
||||
let area = 0;
|
||||
|
||||
while (stack.length > 0) {
|
||||
const index = stack.pop()!;
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
area += 1;
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
|
||||
visitMatch3DSpritesheetNeighbor(
|
||||
index - 1,
|
||||
x > 0,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
visitMatch3DSpritesheetNeighbor(
|
||||
index + 1,
|
||||
x + 1 < width,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
visitMatch3DSpritesheetNeighbor(
|
||||
index - width,
|
||||
y > 0,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
visitMatch3DSpritesheetNeighbor(
|
||||
index + width,
|
||||
y + 1 < height,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
area,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX + 1,
|
||||
height: maxY - minY + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function visitMatch3DSpritesheetNeighbor(
|
||||
index: number,
|
||||
inBounds: boolean,
|
||||
alpha: ArrayLike<number>,
|
||||
visited: Uint8Array,
|
||||
stack: number[],
|
||||
alphaThreshold: number,
|
||||
) {
|
||||
if (!inBounds || visited[index] || (alpha[index] ?? 0) <= alphaThreshold) {
|
||||
return;
|
||||
}
|
||||
visited[index] = 1;
|
||||
stack.push(index);
|
||||
}
|
||||
@@ -26,15 +26,16 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.steps.map((step) => step.label)).toEqual([
|
||||
'编译首关草稿',
|
||||
'生成关卡名称',
|
||||
'并行生成素材',
|
||||
'校验背景资源',
|
||||
'生成拼图首图',
|
||||
'生成关卡画面',
|
||||
'生成UI与背景',
|
||||
'写入正式草稿',
|
||||
]);
|
||||
expect(progress?.phaseLabel).toBe('编译首关草稿');
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'读取画面描述,建立可编辑草稿与首关结构。',
|
||||
'建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
|
||||
);
|
||||
expect(progress?.estimatedRemainingMs).toBe(298_500);
|
||||
expect(progress?.estimatedRemainingMs).toBe(296_500);
|
||||
expect(progress?.overallProgress).toBeGreaterThan(0);
|
||||
expect(progress?.steps[0]?.completed).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -50,22 +51,52 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
};
|
||||
|
||||
const imageProgress = buildMiniGameDraftGenerationProgress(state, 26_000);
|
||||
const uiProgress = buildMiniGameDraftGenerationProgress(state, 282_000);
|
||||
const uiProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
296_000,
|
||||
);
|
||||
|
||||
expect(imageProgress?.phaseId).toBe('puzzle-images');
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(275_000);
|
||||
expect(imageProgress?.phaseId).toBe('puzzle-cover-image');
|
||||
expect(imageProgress?.estimatedRemainingMs).toBe(273_000);
|
||||
expect(imageProgress?.steps[1]?.status).toBe('completed');
|
||||
expect(imageProgress?.steps[2]?.status).toBe('active');
|
||||
expect(imageProgress?.steps[2]?.completed).toBeGreaterThan(0);
|
||||
expect(uiProgress?.phaseId).toBe('puzzle-ui-background');
|
||||
expect(uiProgress?.phaseId).toBe('puzzle-ui-assets');
|
||||
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(5_000);
|
||||
expect(writeBackProgress?.steps[3]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[4]?.status).toBe('active');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
|
||||
expect(writeBackProgress?.steps[4]?.status).toBe('completed');
|
||||
expect(writeBackProgress?.steps[5]?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('puzzle direct upload generation skips the first image generation step', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: 1_000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
metadata: {
|
||||
puzzleAiRedraw: false,
|
||||
},
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 20_000);
|
||||
const writeBackProgress = buildMiniGameDraftGenerationProgress(state, 206_000);
|
||||
|
||||
expect(progress?.steps.map((step) => step.label)).toEqual([
|
||||
'编译首关草稿',
|
||||
'生成关卡名称',
|
||||
'生成关卡画面',
|
||||
'生成UI与背景',
|
||||
'写入正式草稿',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('puzzle-level-scene');
|
||||
expect(progress?.steps[2]?.detail).toContain('直接使用上传图作为参考');
|
||||
expect(progress?.estimatedRemainingMs).toBe(189_000);
|
||||
expect(writeBackProgress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(writeBackProgress?.estimatedRemainingMs).toBe(3_000);
|
||||
});
|
||||
|
||||
test('puzzle draft generation keeps moving without claiming completion before response', () => {
|
||||
@@ -78,12 +109,12 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 360_000);
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 480_000);
|
||||
|
||||
expect(progress?.phaseId).toBe('puzzle-select-image');
|
||||
expect(progress?.overallProgress).toBe(98);
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps[4]?.completed).toBe(1);
|
||||
expect(progress?.steps[5]?.completed).toBe(1);
|
||||
});
|
||||
|
||||
test('puzzle ready copy points to result page work info completion', () => {
|
||||
@@ -111,7 +142,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
finishedAtMs: 151_000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: 'VectorEngine 图片编辑请求超时',
|
||||
error: 'VectorEngine 图片生成请求超时',
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 500_000);
|
||||
@@ -177,7 +208,7 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d draft generation exposes item sheet and image asset steps', () => {
|
||||
test('match3d draft generation exposes level scene derived asset steps', () => {
|
||||
const state = createMiniGameDraftGenerationState('match3d');
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(
|
||||
@@ -188,17 +219,14 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'match3d-work-title',
|
||||
'match3d-item-names',
|
||||
'match3d-background-prompt',
|
||||
'match3d-material-sheet',
|
||||
'match3d-slice-images',
|
||||
'match3d-upload-images',
|
||||
'match3d-generate-views',
|
||||
'match3d-background-image',
|
||||
'match3d-level-scene',
|
||||
'match3d-derived-assets',
|
||||
'match3d-parse-spritesheet',
|
||||
'match3d-write-draft',
|
||||
]);
|
||||
expect(progress?.phaseId).toBe('match3d-material-sheet');
|
||||
expect(progress?.phaseLabel).toBe('分批生成素材图');
|
||||
expect(progress?.estimatedRemainingMs).toBe(480_000);
|
||||
expect(progress?.phaseId).toBe('match3d-level-scene');
|
||||
expect(progress?.phaseLabel).toBe('生成关卡整图');
|
||||
expect(progress?.estimatedRemainingMs).toBe(430_000);
|
||||
});
|
||||
|
||||
test('match3d draft generation starts from title generation', () => {
|
||||
@@ -229,26 +257,26 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
state.startedAtMs + 20_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('match3d-generate-views');
|
||||
expect(progress?.steps[6]?.detail).toContain('五视角图片');
|
||||
expect(progress?.steps[6]?.completed).toBe(1);
|
||||
expect(progress?.steps[6]?.total).toBe(3);
|
||||
expect(progress?.phaseId).toBe('match3d-parse-spritesheet');
|
||||
expect(progress?.steps[4]?.detail).toContain('解析 20 个物品');
|
||||
expect(progress?.steps[4]?.completed).toBe(1);
|
||||
expect(progress?.steps[4]?.total).toBe(3);
|
||||
});
|
||||
|
||||
test('match3d draft generation reaches background image and writeback phases', () => {
|
||||
const state = createMiniGameDraftGenerationState('match3d');
|
||||
|
||||
const backgroundProgress = buildMiniGameDraftGenerationProgress(
|
||||
const derivedProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 400_000,
|
||||
state.startedAtMs + 150_000,
|
||||
);
|
||||
const writeProgress = buildMiniGameDraftGenerationProgress(
|
||||
state,
|
||||
state.startedAtMs + 500_000,
|
||||
state.startedAtMs + 460_000,
|
||||
);
|
||||
|
||||
expect(backgroundProgress?.phaseId).toBe('match3d-background-image');
|
||||
expect(backgroundProgress?.phaseLabel).toBe('生成UI背景');
|
||||
expect(derivedProgress?.phaseId).toBe('match3d-derived-assets');
|
||||
expect(derivedProgress?.phaseLabel).toBe('生成三张派生图');
|
||||
expect(writeProgress?.phaseId).toBe('match3d-write-draft');
|
||||
expect(writeProgress?.phaseLabel).toBe('写入草稿页');
|
||||
});
|
||||
@@ -269,8 +297,8 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
},
|
||||
{
|
||||
id: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: '25 件',
|
||||
label: '素材数量',
|
||||
value: '20 种素材',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,9 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'match3d-upload-images'
|
||||
| 'match3d-generate-views'
|
||||
| 'match3d-background-image'
|
||||
| 'match3d-level-scene'
|
||||
| 'match3d-derived-assets'
|
||||
| 'match3d-parse-spritesheet'
|
||||
| 'match3d-write-draft'
|
||||
| 'match3d-ready'
|
||||
| 'baby-object-draft'
|
||||
@@ -59,6 +62,9 @@ export type MiniGameDraftGenerationPhase =
|
||||
| 'jump-hop-tile-atlas'
|
||||
| 'jump-hop-slice-tiles'
|
||||
| 'jump-hop-write-draft'
|
||||
| 'puzzle-cover-image'
|
||||
| 'puzzle-level-scene'
|
||||
| 'puzzle-ui-assets'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-select-image'
|
||||
@@ -73,6 +79,9 @@ export type MiniGameDraftGenerationState = {
|
||||
completedAssetCount: number;
|
||||
totalAssetCount: number;
|
||||
error: string | null;
|
||||
metadata?: {
|
||||
puzzleAiRedraw?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type MiniGameStepDefinition = {
|
||||
@@ -82,65 +91,134 @@ type MiniGameStepDefinition = {
|
||||
weight: number;
|
||||
};
|
||||
|
||||
type TimedMiniGameStepDefinition = Omit<MiniGameStepDefinition, 'weight'> & {
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
type MiniGameAnchorSource = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const PUZZLE_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译首关草稿',
|
||||
detail: '读取画面描述,建立可编辑草稿与首关结构。',
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-level-name',
|
||||
label: '生成关卡名称',
|
||||
detail: '根据画面描述和图像语义整理首关题目。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-images',
|
||||
label: '并行生成素材',
|
||||
detail: '同时生成首关画面与 9:16 纯背景。',
|
||||
weight: 74,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-ui-background',
|
||||
label: '校验背景资源',
|
||||
detail: '确认首关图和 UI 背景都已写入资产库。',
|
||||
weight: 0,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '写入正式草稿',
|
||||
detail: '写入首图、UI背景和首关数据。',
|
||||
weight: 8,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
const PUZZLE_IMAGE_GENERATION_EXPECTED_MS = 90_000;
|
||||
const PUZZLE_COMPILE_EXPECTED_MS = 8_000;
|
||||
const PUZZLE_LEVEL_NAME_EXPECTED_MS = 10_000;
|
||||
const PUZZLE_WRITE_DRAFT_EXPECTED_MS = 10_000;
|
||||
|
||||
function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) {
|
||||
return state.metadata?.puzzleAiRedraw === false;
|
||||
}
|
||||
|
||||
function buildWeightedPuzzleSteps(
|
||||
steps: ReadonlyArray<TimedMiniGameStepDefinition>,
|
||||
) {
|
||||
const totalDuration = steps.reduce((sum, step) => sum + step.durationMs, 0);
|
||||
let usedWeight = 0;
|
||||
return steps.map((step, index) => {
|
||||
const weight =
|
||||
index === steps.length - 1
|
||||
? Math.max(1, 100 - usedWeight)
|
||||
: Math.max(1, Math.round((step.durationMs / totalDuration) * 100));
|
||||
usedWeight += weight;
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
weight,
|
||||
} satisfies MiniGameStepDefinition;
|
||||
});
|
||||
}
|
||||
|
||||
function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) {
|
||||
const steps: TimedMiniGameStepDefinition[] = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译首关草稿',
|
||||
detail: '建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。',
|
||||
durationMs: PUZZLE_COMPILE_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-level-name',
|
||||
label: '生成关卡名称',
|
||||
detail: '根据描述生成关卡名、作品描述和标签,约 10 秒。',
|
||||
durationMs: PUZZLE_LEVEL_NAME_EXPECTED_MS,
|
||||
},
|
||||
];
|
||||
|
||||
if (!shouldSkipPuzzleCoverGeneration(state)) {
|
||||
steps.push({
|
||||
id: 'puzzle-cover-image',
|
||||
label: '生成拼图首图',
|
||||
detail: '调用 gpt-image-2 生成 1:1 拼图首图,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
});
|
||||
}
|
||||
|
||||
steps.push(
|
||||
{
|
||||
id: 'puzzle-level-scene',
|
||||
label: '生成关卡画面',
|
||||
detail: shouldSkipPuzzleCoverGeneration(state)
|
||||
? '直接使用上传图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。'
|
||||
: '使用拼图首图作为参考,调用 gpt-image-2 生成 9:16 完整关卡画面,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-ui-assets',
|
||||
label: '生成UI与背景',
|
||||
detail:
|
||||
'用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景;两次 gpt-image-2 并发,预计 90 秒。',
|
||||
durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '写入正式草稿',
|
||||
detail: '校验资产并写入正式首关、作品摘要和草稿投影,约 10 秒。',
|
||||
durationMs: PUZZLE_WRITE_DRAFT_EXPECTED_MS,
|
||||
},
|
||||
);
|
||||
return steps;
|
||||
}
|
||||
|
||||
function buildPuzzleSteps(state: MiniGameDraftGenerationState) {
|
||||
return buildWeightedPuzzleSteps(buildPuzzleTimedSteps(state));
|
||||
}
|
||||
|
||||
function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) {
|
||||
return buildPuzzleTimedSteps(state).reduce(
|
||||
(sum, step) => sum + step.durationMs,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const PUZZLE_ESTIMATED_WAIT_MS = 5 * 60_000;
|
||||
const PUZZLE_NON_READY_MAX_PROGRESS = 98;
|
||||
const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000;
|
||||
const PUZZLE_PHASE_TIMELINE: Array<{
|
||||
function buildPuzzlePhaseTimeline(state: MiniGameDraftGenerationState): Array<{
|
||||
phase: Extract<
|
||||
MiniGameDraftGenerationPhase,
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-ui-background'
|
||||
| 'puzzle-cover-image'
|
||||
| 'puzzle-level-scene'
|
||||
| 'puzzle-ui-assets'
|
||||
| 'puzzle-select-image'
|
||||
>;
|
||||
durationMs: number;
|
||||
}> = [
|
||||
{ phase: 'compile', durationMs: 12_000 },
|
||||
{ phase: 'puzzle-level-name', durationMs: 8_000 },
|
||||
{ phase: 'puzzle-images', durationMs: 260_000 },
|
||||
{ phase: 'puzzle-ui-background', durationMs: 10_000 },
|
||||
{ phase: 'puzzle-select-image', durationMs: 10_000 },
|
||||
];
|
||||
}> {
|
||||
return buildPuzzleTimedSteps(state).map((step) => ({
|
||||
phase: step.id as Extract<
|
||||
MiniGameDraftGenerationPhase,
|
||||
| 'compile'
|
||||
| 'puzzle-level-name'
|
||||
| 'puzzle-cover-image'
|
||||
| 'puzzle-level-scene'
|
||||
| 'puzzle-ui-assets'
|
||||
| 'puzzle-select-image'
|
||||
>,
|
||||
durationMs: step.durationMs,
|
||||
}));
|
||||
}
|
||||
|
||||
const BIG_FISH_STEPS = [
|
||||
{
|
||||
@@ -198,65 +276,69 @@ const MATCH3D_STEPS = [
|
||||
weight: 10,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-prompt',
|
||||
label: '生成背景提示词',
|
||||
detail: '整理纯背景图与容器 UI 图提示词。',
|
||||
weight: 6,
|
||||
id: 'match3d-level-scene',
|
||||
label: '生成关卡整图',
|
||||
detail: '调用 gpt-image-2 生成 9:16 完整抓大鹅关卡画面。',
|
||||
weight: 28,
|
||||
},
|
||||
{
|
||||
id: 'match3d-material-sheet',
|
||||
label: '分批生成素材图',
|
||||
detail: '按 1K 参数分批生成 5x5 多视角素材图。',
|
||||
weight: 24,
|
||||
id: 'match3d-derived-assets',
|
||||
label: '生成三张派生图',
|
||||
detail: '以关卡整图为参考,并发生成 UI、背景和 10x10 物品 Sprite。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'match3d-slice-images',
|
||||
label: '切割独立图片',
|
||||
detail: '把素材图切成每个物品的五个视角。',
|
||||
weight: 12,
|
||||
},
|
||||
{
|
||||
id: 'match3d-upload-images',
|
||||
label: '上传图片资产',
|
||||
detail: '上传每个物品的 2D 五视角素材。',
|
||||
weight: 14,
|
||||
},
|
||||
{
|
||||
id: 'match3d-generate-views',
|
||||
label: '校验素材结构',
|
||||
detail: '确认物品顺序和五视角图片。',
|
||||
weight: 8,
|
||||
},
|
||||
{
|
||||
id: 'match3d-background-image',
|
||||
label: '生成UI背景',
|
||||
detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。',
|
||||
weight: 16,
|
||||
id: 'match3d-parse-spritesheet',
|
||||
label: '解析物品Sprite',
|
||||
detail: '解析 20 个物品和每个物品的 5 个形态,并上传透明 PNG。',
|
||||
weight: 18,
|
||||
},
|
||||
{
|
||||
id: 'match3d-write-draft',
|
||||
label: '写入草稿页',
|
||||
detail: '保存素材、背景、容器和作品草稿。',
|
||||
detail: '保存关卡整图、派生图集、20 种物品素材和作品草稿。',
|
||||
weight: 2,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const MATCH3D_ESTIMATED_WAIT_MS = 510_000;
|
||||
const MATCH3D_ESTIMATED_WAIT_MS = 460_000;
|
||||
|
||||
const MATCH3D_PHASE_ORDER: Partial<
|
||||
Record<MiniGameDraftGenerationPhase, number>
|
||||
> = {
|
||||
'match3d-work-title': 0,
|
||||
'match3d-item-names': 1,
|
||||
'match3d-background-prompt': 2,
|
||||
'match3d-level-scene': 2,
|
||||
'match3d-derived-assets': 3,
|
||||
'match3d-parse-spritesheet': 4,
|
||||
'match3d-write-draft': 5,
|
||||
// 中文注释:旧生成页阶段在恢复生成中草稿时归并到新流程对应阶段。
|
||||
'match3d-background-prompt': 1,
|
||||
'match3d-material-sheet': 3,
|
||||
'match3d-slice-images': 4,
|
||||
'match3d-upload-images': 5,
|
||||
'match3d-generate-views': 6,
|
||||
'match3d-background-image': 7,
|
||||
'match3d-write-draft': 8,
|
||||
'match3d-upload-images': 4,
|
||||
'match3d-generate-views': 4,
|
||||
'match3d-background-image': 3,
|
||||
};
|
||||
|
||||
function normalizeMatch3DGenerationPhase(
|
||||
phase: MiniGameDraftGenerationPhase,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
switch (phase) {
|
||||
case 'match3d-background-prompt':
|
||||
return 'match3d-item-names';
|
||||
case 'match3d-material-sheet':
|
||||
case 'match3d-background-image':
|
||||
return 'match3d-derived-assets';
|
||||
case 'match3d-slice-images':
|
||||
case 'match3d-upload-images':
|
||||
case 'match3d-generate-views':
|
||||
return 'match3d-parse-spritesheet';
|
||||
default:
|
||||
return phase;
|
||||
}
|
||||
}
|
||||
|
||||
const BABY_OBJECT_MATCH_STEPS = [
|
||||
{
|
||||
id: 'baby-object-draft',
|
||||
@@ -319,7 +401,7 @@ function clampProgress(value: number) {
|
||||
|
||||
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'puzzle') {
|
||||
return PUZZLE_STEPS;
|
||||
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
|
||||
}
|
||||
if (kind === 'square-hole') {
|
||||
return SQUARE_HOLE_STEPS;
|
||||
@@ -429,26 +511,21 @@ function resolveMatch3DPhaseByElapsedMs(
|
||||
currentPhase: MiniGameDraftGenerationPhase,
|
||||
): MiniGameDraftGenerationPhase {
|
||||
const elapsedPhase =
|
||||
elapsedMs >= 492_000
|
||||
elapsedMs >= 450_000
|
||||
? 'match3d-write-draft'
|
||||
: elapsedMs >= 370_000
|
||||
? 'match3d-background-image'
|
||||
: elapsedMs >= 340_000
|
||||
? 'match3d-generate-views'
|
||||
: elapsedMs >= 260_000
|
||||
? 'match3d-upload-images'
|
||||
: elapsedMs >= 210_000
|
||||
? 'match3d-slice-images'
|
||||
: elapsedMs >= 28_000
|
||||
? 'match3d-material-sheet'
|
||||
: elapsedMs >= 12_000
|
||||
? 'match3d-background-prompt'
|
||||
: elapsedMs >= 4_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
: elapsedMs >= 360_000
|
||||
? 'match3d-parse-spritesheet'
|
||||
: elapsedMs >= 118_000
|
||||
? 'match3d-derived-assets'
|
||||
: elapsedMs >= 28_000
|
||||
? 'match3d-level-scene'
|
||||
: elapsedMs >= 8_000
|
||||
? 'match3d-item-names'
|
||||
: 'match3d-work-title';
|
||||
const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0;
|
||||
const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1;
|
||||
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
|
||||
const normalizedCurrentPhase = normalizeMatch3DGenerationPhase(currentPhase);
|
||||
const currentOrder = MATCH3D_PHASE_ORDER[normalizedCurrentPhase] ?? -1;
|
||||
return currentOrder > elapsedOrder ? normalizedCurrentPhase : elapsedPhase;
|
||||
}
|
||||
|
||||
function resolveBabyObjectMatchPhaseByElapsedMs(
|
||||
@@ -481,10 +558,13 @@ function resolveJumpHopPhaseByElapsedMs(
|
||||
return 'jump-hop-draft';
|
||||
}
|
||||
|
||||
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
|
||||
function resolvePuzzleTimelineByElapsedMs(
|
||||
elapsedMs: number,
|
||||
state: MiniGameDraftGenerationState,
|
||||
) {
|
||||
let elapsedBeforePhase = 0;
|
||||
|
||||
for (const item of PUZZLE_PHASE_TIMELINE) {
|
||||
for (const item of buildPuzzlePhaseTimeline(state)) {
|
||||
const elapsedInPhase = elapsedMs - elapsedBeforePhase;
|
||||
|
||||
if (elapsedInPhase < item.durationMs) {
|
||||
@@ -523,7 +603,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
state.kind === 'puzzle' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? resolvePuzzleTimelineByElapsedMs(elapsedMs)
|
||||
? resolvePuzzleTimelineByElapsedMs(elapsedMs, state)
|
||||
: null;
|
||||
const normalizedState =
|
||||
puzzleTimeline != null
|
||||
@@ -568,7 +648,10 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const steps =
|
||||
normalizedState.kind === 'puzzle'
|
||||
? buildPuzzleSteps(normalizedState)
|
||||
: getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
const completedWeight = steps
|
||||
.slice(
|
||||
@@ -641,7 +724,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
normalizedState.phase === 'ready'
|
||||
? 0
|
||||
: normalizedState.kind === 'puzzle'
|
||||
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
|
||||
? Math.max(0, resolvePuzzleEstimatedWaitMs(normalizedState) - elapsedMs)
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: normalizedState.kind === 'square-hole'
|
||||
@@ -812,9 +895,6 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
}
|
||||
|
||||
const config = session?.config;
|
||||
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',
|
||||
@@ -827,8 +907,8 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
},
|
||||
{
|
||||
key: 'match3d-items',
|
||||
label: '物品数量',
|
||||
value: `${itemCount} 件`,
|
||||
label: '素材数量',
|
||||
value: `${resolveMatch3DGeneratedItemCount()} 种素材`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -843,22 +923,10 @@ export function buildMatch3DGenerationAnchorEntries(
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedItemCount(
|
||||
clearCount: number | null | undefined,
|
||||
difficulty: number | null | undefined,
|
||||
_clearCount: number | null | undefined = null,
|
||||
_difficulty: number | null | undefined = null,
|
||||
) {
|
||||
const roundToSheet = (count: number) => Math.ceil(count / 5) * 5;
|
||||
if (clearCount === 8) return roundToSheet(3);
|
||||
if (clearCount === 12) return roundToSheet(9);
|
||||
if (clearCount === 16) return roundToSheet(15);
|
||||
if (clearCount === 20 || clearCount === 21) return roundToSheet(21);
|
||||
const normalizedDifficulty =
|
||||
typeof difficulty === 'number' && Number.isFinite(difficulty)
|
||||
? Math.max(1, Math.min(10, Math.round(difficulty)))
|
||||
: 4;
|
||||
if (normalizedDifficulty <= 2) return roundToSheet(3);
|
||||
if (normalizedDifficulty <= 4) return roundToSheet(9);
|
||||
if (normalizedDifficulty <= 6) return roundToSheet(15);
|
||||
return roundToSheet(21);
|
||||
return 20;
|
||||
}
|
||||
|
||||
export function buildBabyObjectMatchGenerationAnchorEntries(
|
||||
|
||||
33
src/services/puzzle-agent/puzzleAgentClient.test.ts
Normal file
33
src/services/puzzle-agent/puzzleAgentClient.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const { createCreationAgentClientMock, executeActionMock } = vi.hoisted(() => ({
|
||||
createCreationAgentClientMock: vi.fn(),
|
||||
executeActionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../creation-agent', () => ({
|
||||
createCreationAgentClient: createCreationAgentClientMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
executeActionMock.mockReset();
|
||||
createCreationAgentClientMock.mockReset();
|
||||
createCreationAgentClientMock.mockReturnValue({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
streamMessage: vi.fn(),
|
||||
executeAction: executeActionMock,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle compile action keeps the draft generation request alive for 30 minutes', async () => {
|
||||
await import('./puzzleAgentClient');
|
||||
|
||||
expect(createCreationAgentClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
executeActionTimeoutMs: 30 * 60 * 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import type { TextStreamOptions } from '../aiTypes';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
|
||||
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
|
||||
// 拼图草稿生成会串起多段图片生成,请求层保持 30 分钟等待窗口。
|
||||
const PUZZLE_DRAFT_ACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const puzzleAgentHttpClient = createCreationAgentClient<
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
CreatePuzzleAgentSessionResponse,
|
||||
@@ -30,6 +32,7 @@ const puzzleAgentHttpClient = createCreationAgentClient<
|
||||
streamIncomplete: '拼图共创消息流式结果不完整',
|
||||
executeAction: '执行拼图共创操作失败',
|
||||
},
|
||||
executeActionTimeoutMs: PUZZLE_DRAFT_ACTION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,14 +4,14 @@ import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/pu
|
||||
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
applyLocalPuzzleFreezeTime,
|
||||
advanceLocalPuzzleLevel,
|
||||
applyLocalPuzzleFreezeTime,
|
||||
dragLocalPuzzlePiece,
|
||||
extendLocalPuzzleTime,
|
||||
isLocalPuzzleRun,
|
||||
refreshLocalPuzzleTimer,
|
||||
restartLocalPuzzleLevel,
|
||||
resolvePuzzleRestartLevelId,
|
||||
restartLocalPuzzleLevel,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
submitLocalPuzzleLeaderboard,
|
||||
@@ -575,6 +575,43 @@ describe('puzzleLocalRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('本地试玩优先把关卡背景和 UI spritesheet 带入运行态', () => {
|
||||
const workWithRuntimeAssets: PuzzleWorkSummary = {
|
||||
...baseWork,
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '第一关',
|
||||
pictureDescription: '第一关画面',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: '/level-1.png',
|
||||
coverAssetId: null,
|
||||
uiBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/ui/legacy-background.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background/background.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
backgroundMusic: null,
|
||||
generationStatus: 'ready',
|
||||
} as PuzzleDraftLevel,
|
||||
],
|
||||
};
|
||||
|
||||
const run = startLocalPuzzleRun(workWithRuntimeAssets);
|
||||
|
||||
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
|
||||
'/generated-puzzle-assets/session/level-background/background.png',
|
||||
);
|
||||
expect(run.currentLevel?.levelBackgroundImageSrc).toBe(
|
||||
'/generated-puzzle-assets/session/level-background/background.png',
|
||||
);
|
||||
expect(run.currentLevel?.uiSpritesheetImageSrc).toBe(
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('本地试玩在只有 UI 背景 objectKey 时也能继承生成图', () => {
|
||||
const workWithRuntimeAssets: PuzzleWorkSummary = {
|
||||
...baseWork,
|
||||
@@ -641,6 +678,10 @@ describe('puzzleLocalRuntime', () => {
|
||||
const run = startLocalPuzzleRun(workWithLevels, 'puzzle-level-2');
|
||||
|
||||
expect(run.currentLevel?.levelId).toBe('puzzle-level-2');
|
||||
expect(run.currentLevelIndex).toBe(2);
|
||||
expect(run.currentLevel?.levelIndex).toBe(2);
|
||||
expect(run.currentLevel?.gridSize).toBe(4);
|
||||
expect(run.currentGridSize).toBe(4);
|
||||
expect(run.currentLevel?.coverImageSrc).toBe('/level-2.png');
|
||||
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
@@ -10,7 +11,6 @@ import type {
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
resolvePuzzleUiBackgroundFields,
|
||||
@@ -773,6 +773,51 @@ function resolvePuzzleWorkUiBackgroundCarrier(
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleWorkUiSpritesheetCarrier(
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
) {
|
||||
return (
|
||||
work?.levels?.find(
|
||||
(level) =>
|
||||
level.uiSpritesheetImageSrc?.trim() ||
|
||||
level.uiSpritesheetImageObjectKey?.trim(),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleUiSpritesheetFields(
|
||||
...sources: Array<
|
||||
| Pick<
|
||||
PuzzleDraftLevel,
|
||||
'uiSpritesheetImageSrc' | 'uiSpritesheetImageObjectKey'
|
||||
>
|
||||
| Pick<
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
'uiSpritesheetImageSrc' | 'uiSpritesheetImageObjectKey'
|
||||
>
|
||||
| null
|
||||
| undefined
|
||||
>
|
||||
) {
|
||||
for (const source of sources) {
|
||||
const imageSrc = source?.uiSpritesheetImageSrc?.trim();
|
||||
const objectKey = source?.uiSpritesheetImageObjectKey
|
||||
?.trim()
|
||||
.replace(/^\/+/u, '');
|
||||
if (imageSrc || objectKey) {
|
||||
return {
|
||||
uiSpritesheetImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
|
||||
uiSpritesheetImageObjectKey: objectKey || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiSpritesheetImageSrc: null,
|
||||
uiSpritesheetImageObjectKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyLocalNextLevelHandoff(
|
||||
run: PuzzleRunSnapshot,
|
||||
work: PuzzleWorkSummary | null | undefined,
|
||||
@@ -820,6 +865,11 @@ function buildFallbackLocalLevel(
|
||||
resolvePuzzleWorkUiBackgroundCarrier(work),
|
||||
currentLevel,
|
||||
);
|
||||
const nextUiSpritesheet = resolvePuzzleUiSpritesheetFields(
|
||||
nextLevel,
|
||||
resolvePuzzleWorkUiSpritesheetCarrier(work),
|
||||
currentLevel,
|
||||
);
|
||||
const nextBackgroundMusic =
|
||||
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
|
||||
|
||||
@@ -852,6 +902,12 @@ function buildFallbackLocalLevel(
|
||||
coverImageSrc: nextCoverImageSrc,
|
||||
uiBackgroundImageSrc: nextUiBackground.uiBackgroundImageSrc,
|
||||
uiBackgroundImageObjectKey: nextUiBackground.uiBackgroundImageObjectKey,
|
||||
levelBackgroundImageSrc: nextUiBackground.levelBackgroundImageSrc,
|
||||
levelBackgroundImageObjectKey:
|
||||
nextUiBackground.levelBackgroundImageObjectKey,
|
||||
uiSpritesheetImageSrc: nextUiSpritesheet.uiSpritesheetImageSrc,
|
||||
uiSpritesheetImageObjectKey:
|
||||
nextUiSpritesheet.uiSpritesheetImageObjectKey,
|
||||
backgroundMusic: nextBackgroundMusic,
|
||||
...buildLevelTimerFields(nextLevelIndex),
|
||||
leaderboardEntries: [],
|
||||
@@ -869,11 +925,12 @@ export function startLocalPuzzleRun(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleLevelConfig(1).gridSize;
|
||||
const runId = buildLocalPuzzleRunId(item.profileId);
|
||||
const startedAtMs = Date.now();
|
||||
const requestedLevelIndex = resolveWorkLevelIndexById(item.levels, levelId);
|
||||
const currentLevelIndex = requestedLevelIndex >= 0 ? requestedLevelIndex : 0;
|
||||
const currentLevelNumber = currentLevelIndex + 1;
|
||||
const { gridSize } = resolvePuzzleLevelConfig(currentLevelNumber);
|
||||
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
|
||||
const firstLevelName = firstLevel?.levelName || item.levelName;
|
||||
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
|
||||
@@ -881,19 +938,23 @@ export function startLocalPuzzleRun(
|
||||
firstLevel,
|
||||
resolvePuzzleWorkUiBackgroundCarrier(item),
|
||||
);
|
||||
const firstUiSpritesheet = resolvePuzzleUiSpritesheetFields(
|
||||
firstLevel,
|
||||
resolvePuzzleWorkUiSpritesheetCarrier(item),
|
||||
);
|
||||
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
|
||||
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
|
||||
return {
|
||||
runId,
|
||||
entryProfileId: item.profileId,
|
||||
clearedLevelCount: 0,
|
||||
currentLevelIndex: 1,
|
||||
clearedLevelCount: Math.max(0, currentLevelIndex),
|
||||
currentLevelIndex: currentLevelNumber,
|
||||
currentGridSize: gridSize,
|
||||
playedProfileIds: [item.profileId],
|
||||
previousLevelTags: item.themeTags,
|
||||
currentLevel: {
|
||||
runId,
|
||||
levelIndex: 1,
|
||||
levelIndex: currentLevelNumber,
|
||||
levelId: firstLevel?.levelId ?? null,
|
||||
gridSize,
|
||||
profileId: item.profileId,
|
||||
@@ -903,13 +964,19 @@ export function startLocalPuzzleRun(
|
||||
coverImageSrc: firstCoverImageSrc,
|
||||
uiBackgroundImageSrc: firstUiBackground.uiBackgroundImageSrc,
|
||||
uiBackgroundImageObjectKey: firstUiBackground.uiBackgroundImageObjectKey,
|
||||
levelBackgroundImageSrc: firstUiBackground.levelBackgroundImageSrc,
|
||||
levelBackgroundImageObjectKey:
|
||||
firstUiBackground.levelBackgroundImageObjectKey,
|
||||
uiSpritesheetImageSrc: firstUiSpritesheet.uiSpritesheetImageSrc,
|
||||
uiSpritesheetImageObjectKey:
|
||||
firstUiSpritesheet.uiSpritesheetImageObjectKey,
|
||||
backgroundMusic: firstBackgroundMusic,
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, currentLevelNumber),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
...buildLevelTimerFields(1),
|
||||
...buildLevelTimerFields(currentLevelNumber),
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: nextSameWorkLevel ? item.profileId : null,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
type PuzzleUiBackgroundFields = {
|
||||
uiBackgroundImageSrc?: string | null;
|
||||
uiBackgroundImageObjectKey?: string | null;
|
||||
levelBackgroundImageSrc?: string | null;
|
||||
levelBackgroundImageObjectKey?: string | null;
|
||||
};
|
||||
|
||||
export function resolvePuzzleUiBackgroundSource(
|
||||
@@ -13,14 +15,21 @@ export function resolvePuzzleUiBackgroundFields(
|
||||
...sources: Array<PuzzleUiBackgroundFields | null | undefined>
|
||||
) {
|
||||
for (const source of sources) {
|
||||
const imageSrc = source?.uiBackgroundImageSrc?.trim();
|
||||
const objectKey = source?.uiBackgroundImageObjectKey
|
||||
?.trim()
|
||||
.replace(/^\/+/u, '');
|
||||
const imageSrc =
|
||||
source?.levelBackgroundImageSrc?.trim() ||
|
||||
source?.uiBackgroundImageSrc?.trim();
|
||||
const objectKey = (
|
||||
source?.levelBackgroundImageObjectKey?.trim() ||
|
||||
source?.uiBackgroundImageObjectKey?.trim() ||
|
||||
''
|
||||
).replace(/^\/+/u, '');
|
||||
if (imageSrc || objectKey) {
|
||||
return {
|
||||
uiBackgroundImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
|
||||
uiBackgroundImageObjectKey: objectKey || null,
|
||||
levelBackgroundImageSrc:
|
||||
imageSrc || (objectKey ? `/${objectKey}` : null),
|
||||
levelBackgroundImageObjectKey: objectKey || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,5 +37,7 @@ export function resolvePuzzleUiBackgroundFields(
|
||||
return {
|
||||
uiBackgroundImageSrc: null,
|
||||
uiBackgroundImageObjectKey: null,
|
||||
levelBackgroundImageSrc: null,
|
||||
levelBackgroundImageObjectKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
166
src/services/puzzle-runtime/puzzleUiSpritesheetParser.test.ts
Normal file
166
src/services/puzzle-runtime/puzzleUiSpritesheetParser.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPuzzleUiSpriteBackgroundStyle,
|
||||
buildPuzzleUiSpriteHitZoneStyle,
|
||||
detectPuzzleUiSpritesheetLayout,
|
||||
} from './puzzleUiSpritesheetParser';
|
||||
|
||||
describe('puzzleUiSpritesheetParser', () => {
|
||||
test('按透明像素边界检测 UI 按钮矩形并按从左到右从上到下映射', () => {
|
||||
const width = 32;
|
||||
const height = 24;
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
const paint = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
for (let y = y0; y <= y1; y += 1) {
|
||||
for (let x = x0; x <= x1; x += 1) {
|
||||
alpha[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
paint(3, 2, 6, 5);
|
||||
paint(24, 3, 28, 7);
|
||||
paint(11, 11, 20, 15);
|
||||
paint(2, 20, 6, 22);
|
||||
paint(13, 19, 18, 22);
|
||||
paint(25, 20, 29, 23);
|
||||
alpha[0] = 255;
|
||||
|
||||
const layout = detectPuzzleUiSpritesheetLayout({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
minArea: 4,
|
||||
});
|
||||
|
||||
expect(layout).toEqual({
|
||||
width,
|
||||
height,
|
||||
regions: {
|
||||
back: { x: 3, y: 2, width: 4, height: 4 },
|
||||
settings: { x: 24, y: 3, width: 5, height: 5 },
|
||||
next: { x: 11, y: 11, width: 10, height: 5 },
|
||||
hint: { x: 2, y: 20, width: 5, height: 3 },
|
||||
reference: { x: 13, y: 19, width: 6, height: 4 },
|
||||
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
|
||||
},
|
||||
hitRegions: {
|
||||
back: { x: 3, y: 2, width: 4, height: 4 },
|
||||
settings: { x: 24, y: 3, width: 5, height: 5 },
|
||||
next: { x: 11, y: 11, width: 10, height: 5 },
|
||||
hint: { x: 2, y: 20, width: 5, height: 3 },
|
||||
reference: { x: 13, y: 19, width: 6, height: 4 },
|
||||
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('检测不到完整六个按钮矩形时返回 null 交给固定六宫格兜底', () => {
|
||||
const width = 20;
|
||||
const height = 12;
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
const paint = (x0: number, y0: number, x1: number, y1: number) => {
|
||||
for (let y = y0; y <= y1; y += 1) {
|
||||
for (let x = x0; x <= x1; x += 1) {
|
||||
alpha[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
paint(1, 1, 4, 4);
|
||||
paint(8, 1, 11, 4);
|
||||
paint(14, 1, 17, 4);
|
||||
|
||||
expect(
|
||||
detectPuzzleUiSpritesheetLayout({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
minArea: 4,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('按检测到的原图矩形生成 background 裁切样式', () => {
|
||||
const style = buildPuzzleUiSpriteBackgroundStyle({
|
||||
src: '/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
kind: 'reference',
|
||||
layout: {
|
||||
width: 32,
|
||||
height: 24,
|
||||
regions: {
|
||||
reference: { x: 13, y: 19, width: 6, height: 4 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(style).toEqual({
|
||||
backgroundImage:
|
||||
'url("/generated-puzzle-assets/session/ui-spritesheet/sheet.png")',
|
||||
backgroundSize: '533.3333333333333% 600%',
|
||||
backgroundPosition: '50% 95%',
|
||||
aspectRatio: '6 / 4',
|
||||
});
|
||||
});
|
||||
|
||||
test('点击热区优先使用高 alpha 像素的紧致矩形,减少透明边缘误触', () => {
|
||||
const width = 48;
|
||||
const height = 32;
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
const paint = (
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
value: number,
|
||||
) => {
|
||||
for (let y = y0; y <= y1; y += 1) {
|
||||
for (let x = x0; x <= x1; x += 1) {
|
||||
alpha[y * width + x] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
paint(2, 2, 12, 12, 48);
|
||||
paint(5, 5, 9, 9, 255);
|
||||
paint(20, 2, 26, 8, 255);
|
||||
paint(34, 2, 42, 8, 255);
|
||||
paint(2, 20, 8, 26, 255);
|
||||
paint(19, 20, 27, 26, 255);
|
||||
paint(34, 20, 42, 26, 255);
|
||||
|
||||
const layout = detectPuzzleUiSpritesheetLayout({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
minArea: 4,
|
||||
alphaThreshold: 16,
|
||||
hitAlphaThreshold: 192,
|
||||
});
|
||||
|
||||
expect(layout?.regions.back).toEqual({
|
||||
x: 2,
|
||||
y: 2,
|
||||
width: 11,
|
||||
height: 11,
|
||||
});
|
||||
expect(layout?.hitRegions?.back).toEqual({
|
||||
x: 5,
|
||||
y: 5,
|
||||
width: 5,
|
||||
height: 5,
|
||||
});
|
||||
expect(
|
||||
buildPuzzleUiSpriteHitZoneStyle({
|
||||
kind: 'back',
|
||||
layout,
|
||||
}),
|
||||
).toEqual({
|
||||
left: '27.27272727272727%',
|
||||
top: '27.27272727272727%',
|
||||
width: '45.45454545454545%',
|
||||
height: '45.45454545454545%',
|
||||
});
|
||||
});
|
||||
});
|
||||
471
src/services/puzzle-runtime/puzzleUiSpritesheetParser.ts
Normal file
471
src/services/puzzle-runtime/puzzleUiSpritesheetParser.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { readAssetBytes } from '../assetReadUrlService';
|
||||
|
||||
export type PuzzleUiSpriteKind =
|
||||
| 'back'
|
||||
| 'settings'
|
||||
| 'next'
|
||||
| 'hint'
|
||||
| 'reference'
|
||||
| 'freezeTime';
|
||||
|
||||
export type PuzzleUiSpriteRegion = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type PuzzleUiSpritesheetLayout = {
|
||||
width: number;
|
||||
height: number;
|
||||
regions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>>;
|
||||
hitRegions?: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>>;
|
||||
};
|
||||
|
||||
export type DetectPuzzleUiSpritesheetLayoutInput = {
|
||||
alpha: ArrayLike<number>;
|
||||
width: number;
|
||||
height: number;
|
||||
minArea?: number;
|
||||
alphaThreshold?: number;
|
||||
hitAlphaThreshold?: number;
|
||||
};
|
||||
|
||||
export type BuildPuzzleUiSpriteBackgroundStyleInput = {
|
||||
src: string;
|
||||
kind: PuzzleUiSpriteKind;
|
||||
layout: PuzzleUiSpritesheetLayout | null;
|
||||
};
|
||||
|
||||
export type BuildPuzzleUiSpriteHitZoneStyleInput = {
|
||||
kind: PuzzleUiSpriteKind;
|
||||
layout: PuzzleUiSpritesheetLayout | null;
|
||||
};
|
||||
|
||||
export type LoadPuzzleUiSpritesheetLayoutOptions = {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
minArea?: number;
|
||||
alphaThreshold?: number;
|
||||
hitAlphaThreshold?: number;
|
||||
};
|
||||
|
||||
type PuzzleUiDetectedComponent = PuzzleUiSpriteRegion & {
|
||||
area: number;
|
||||
hitRegion?: PuzzleUiSpriteRegion;
|
||||
};
|
||||
|
||||
const PUZZLE_UI_SPRITE_ORDER = [
|
||||
'back',
|
||||
'settings',
|
||||
'next',
|
||||
'hint',
|
||||
'reference',
|
||||
'freezeTime',
|
||||
] as const satisfies readonly PuzzleUiSpriteKind[];
|
||||
|
||||
const PUZZLE_UI_FIXED_GRID_INDEX: Record<PuzzleUiSpriteKind, number> = {
|
||||
back: 0,
|
||||
settings: 1,
|
||||
next: 2,
|
||||
hint: 3,
|
||||
reference: 4,
|
||||
freezeTime: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* 中文注释:AI 生成的拼图 UI spritesheet 不稳定落在固定六宫格内,
|
||||
* 因此这里以 alpha 连通域检测真实按钮矩形,再按原图位置映射到按钮语义。
|
||||
*/
|
||||
export function detectPuzzleUiSpritesheetLayout({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
minArea = 1,
|
||||
alphaThreshold = 0,
|
||||
hitAlphaThreshold = Math.max(192, alphaThreshold),
|
||||
}: DetectPuzzleUiSpritesheetLayoutInput): PuzzleUiSpritesheetLayout | null {
|
||||
const pixelCount = width * height;
|
||||
if (width <= 0 || height <= 0 || alpha.length < pixelCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visited = new Uint8Array(pixelCount);
|
||||
const components: PuzzleUiDetectedComponent[] = [];
|
||||
|
||||
for (let start = 0; start < pixelCount; start += 1) {
|
||||
const alphaValue = alpha[start];
|
||||
if (
|
||||
visited[start] ||
|
||||
alphaValue === undefined ||
|
||||
alphaValue <= alphaThreshold
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = floodFillPuzzleUiSpriteComponent({
|
||||
alpha,
|
||||
visited,
|
||||
width,
|
||||
height,
|
||||
start,
|
||||
alphaThreshold,
|
||||
hitAlphaThreshold,
|
||||
});
|
||||
if (component.area >= minArea) {
|
||||
components.push(component);
|
||||
}
|
||||
}
|
||||
|
||||
if (components.length < PUZZLE_UI_SPRITE_ORDER.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedComponents = sortPuzzleUiSpriteComponentsByOriginalPosition(
|
||||
components,
|
||||
).slice(0, PUZZLE_UI_SPRITE_ORDER.length);
|
||||
const regions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>> = {};
|
||||
const hitRegions: Partial<Record<PuzzleUiSpriteKind, PuzzleUiSpriteRegion>> =
|
||||
{};
|
||||
sortedComponents.forEach((component, index) => {
|
||||
const kind = PUZZLE_UI_SPRITE_ORDER[index];
|
||||
if (!kind) {
|
||||
return;
|
||||
}
|
||||
const region = {
|
||||
x: component.x,
|
||||
y: component.y,
|
||||
width: component.width,
|
||||
height: component.height,
|
||||
};
|
||||
regions[kind] = region;
|
||||
if (component.hitRegion) {
|
||||
hitRegions[kind] = component.hitRegion;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
regions,
|
||||
hitRegions,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleUiSpriteBackgroundStyle({
|
||||
src,
|
||||
kind,
|
||||
layout,
|
||||
}: BuildPuzzleUiSpriteBackgroundStyleInput): CSSProperties {
|
||||
const region = layout?.regions[kind];
|
||||
if (!layout || !region) {
|
||||
const index = PUZZLE_UI_FIXED_GRID_INDEX[kind];
|
||||
return {
|
||||
backgroundImage: `url("${src}")`,
|
||||
backgroundSize: '200% 300%',
|
||||
backgroundPosition: `${(index % 2) * 100}% ${
|
||||
Math.floor(index / 2) * 50
|
||||
}%`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundImage: `url("${src}")`,
|
||||
backgroundSize: `${(layout.width / region.width) * 100}% ${
|
||||
(layout.height / region.height) * 100
|
||||
}%`,
|
||||
backgroundPosition: `${resolvePuzzleUiSpriteBackgroundAxisPosition(
|
||||
region.x,
|
||||
layout.width,
|
||||
region.width,
|
||||
)}% ${resolvePuzzleUiSpriteBackgroundAxisPosition(
|
||||
region.y,
|
||||
layout.height,
|
||||
region.height,
|
||||
)}%`,
|
||||
aspectRatio: `${region.width} / ${region.height}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleUiSpriteHitZoneStyle({
|
||||
kind,
|
||||
layout,
|
||||
}: BuildPuzzleUiSpriteHitZoneStyleInput): CSSProperties {
|
||||
const region = layout?.regions[kind];
|
||||
const hitRegion = layout?.hitRegions?.[kind];
|
||||
if (!region || !hitRegion) {
|
||||
return {
|
||||
inset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${resolvePuzzleUiSpriteHitZoneOffset(
|
||||
hitRegion.x,
|
||||
region.x,
|
||||
region.width,
|
||||
)}%`,
|
||||
top: `${resolvePuzzleUiSpriteHitZoneOffset(
|
||||
hitRegion.y,
|
||||
region.y,
|
||||
region.height,
|
||||
)}%`,
|
||||
width: `${resolvePuzzleUiSpriteHitZoneSize(
|
||||
hitRegion.width,
|
||||
region.width,
|
||||
)}%`,
|
||||
height: `${resolvePuzzleUiSpriteHitZoneSize(
|
||||
hitRegion.height,
|
||||
region.height,
|
||||
)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPuzzleUiSpritesheetLayout(
|
||||
source: string,
|
||||
options: LoadPuzzleUiSpritesheetLayoutOptions = {},
|
||||
) {
|
||||
const response = await readAssetBytes(source, {
|
||||
signal: options.signal,
|
||||
expireSeconds: options.expireSeconds,
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const image = await loadPuzzleUiSpritesheetImage(objectUrl);
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
const alpha = new Uint8ClampedArray(width * height);
|
||||
for (let index = 0; index < alpha.length; index += 1) {
|
||||
alpha[index] = imageData.data[index * 4 + 3] ?? 0;
|
||||
}
|
||||
|
||||
return detectPuzzleUiSpritesheetLayout({
|
||||
alpha,
|
||||
width,
|
||||
height,
|
||||
minArea:
|
||||
options.minArea ?? Math.max(16, Math.floor(width * height * 0.0002)),
|
||||
alphaThreshold: options.alphaThreshold ?? 16,
|
||||
hitAlphaThreshold: options.hitAlphaThreshold ?? 192,
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function sortPuzzleUiSpriteComponentsByOriginalPosition(
|
||||
components: PuzzleUiDetectedComponent[],
|
||||
) {
|
||||
const averageHeight =
|
||||
components.reduce((total, component) => total + component.height, 0) /
|
||||
components.length;
|
||||
const rowTolerance = Math.max(2, averageHeight * 0.65);
|
||||
const rows: PuzzleUiDetectedComponent[][] = [];
|
||||
|
||||
for (const component of components
|
||||
.slice()
|
||||
.sort((left, right) => left.y - right.y)) {
|
||||
const centerY = component.y + component.height / 2;
|
||||
const row = rows.find((items) => {
|
||||
const rowCenter =
|
||||
items.reduce((total, item) => total + item.y + item.height / 2, 0) /
|
||||
items.length;
|
||||
return Math.abs(rowCenter - centerY) <= rowTolerance;
|
||||
});
|
||||
|
||||
if (row) {
|
||||
row.push(component);
|
||||
} else {
|
||||
rows.push([component]);
|
||||
}
|
||||
}
|
||||
|
||||
return rows.flatMap((row) => row.sort((left, right) => left.x - right.x));
|
||||
}
|
||||
|
||||
function resolvePuzzleUiSpriteBackgroundAxisPosition(
|
||||
offset: number,
|
||||
imageSize: number,
|
||||
regionSize: number,
|
||||
) {
|
||||
const movableSize = imageSize - regionSize;
|
||||
if (movableSize <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (offset / movableSize) * 100;
|
||||
}
|
||||
|
||||
function resolvePuzzleUiSpriteHitZoneOffset(
|
||||
hitOffset: number,
|
||||
regionOffset: number,
|
||||
regionSize: number,
|
||||
) {
|
||||
if (regionSize <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return clampPuzzleUiSpritePercent(
|
||||
((hitOffset - regionOffset) / regionSize) * 100,
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleUiSpriteHitZoneSize(
|
||||
hitSize: number,
|
||||
regionSize: number,
|
||||
) {
|
||||
if (regionSize <= 0) {
|
||||
return 100;
|
||||
}
|
||||
return clampPuzzleUiSpritePercent((hitSize / regionSize) * 100);
|
||||
}
|
||||
|
||||
function clampPuzzleUiSpritePercent(value: number) {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function loadPuzzleUiSpritesheetImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('拼图 UI spritesheet 图片解码失败'));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function floodFillPuzzleUiSpriteComponent({
|
||||
alpha,
|
||||
visited,
|
||||
width,
|
||||
height,
|
||||
start,
|
||||
alphaThreshold,
|
||||
hitAlphaThreshold,
|
||||
}: {
|
||||
alpha: ArrayLike<number>;
|
||||
visited: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
start: number;
|
||||
alphaThreshold: number;
|
||||
hitAlphaThreshold: number;
|
||||
}): PuzzleUiDetectedComponent {
|
||||
const stack = [start];
|
||||
visited[start] = 1;
|
||||
|
||||
let minX = start % width;
|
||||
let maxX = minX;
|
||||
let minY = Math.floor(start / width);
|
||||
let maxY = minY;
|
||||
let area = 0;
|
||||
let hitMinX = Number.POSITIVE_INFINITY;
|
||||
let hitMaxX = Number.NEGATIVE_INFINITY;
|
||||
let hitMinY = Number.POSITIVE_INFINITY;
|
||||
let hitMaxY = Number.NEGATIVE_INFINITY;
|
||||
let hitArea = 0;
|
||||
|
||||
while (stack.length > 0) {
|
||||
const index = stack.pop()!;
|
||||
const x = index % width;
|
||||
const y = Math.floor(index / width);
|
||||
const alphaValue = alpha[index] ?? 0;
|
||||
area += 1;
|
||||
minX = Math.min(minX, x);
|
||||
maxX = Math.max(maxX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxY = Math.max(maxY, y);
|
||||
if (alphaValue > hitAlphaThreshold) {
|
||||
hitArea += 1;
|
||||
hitMinX = Math.min(hitMinX, x);
|
||||
hitMaxX = Math.max(hitMaxX, x);
|
||||
hitMinY = Math.min(hitMinY, y);
|
||||
hitMaxY = Math.max(hitMaxY, y);
|
||||
}
|
||||
|
||||
visitPuzzleUiSpriteNeighbor(
|
||||
index - 1,
|
||||
x > 0,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
visitPuzzleUiSpriteNeighbor(
|
||||
index + 1,
|
||||
x + 1 < width,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
visitPuzzleUiSpriteNeighbor(
|
||||
index - width,
|
||||
y > 0,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
visitPuzzleUiSpriteNeighbor(
|
||||
index + width,
|
||||
y + 1 < height,
|
||||
alpha,
|
||||
visited,
|
||||
stack,
|
||||
alphaThreshold,
|
||||
);
|
||||
}
|
||||
|
||||
const component: PuzzleUiDetectedComponent = {
|
||||
area,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX + 1,
|
||||
height: maxY - minY + 1,
|
||||
};
|
||||
if (hitArea > 0) {
|
||||
component.hitRegion = {
|
||||
x: hitMinX,
|
||||
y: hitMinY,
|
||||
width: hitMaxX - hitMinX + 1,
|
||||
height: hitMaxY - hitMinY + 1,
|
||||
};
|
||||
}
|
||||
return component;
|
||||
}
|
||||
|
||||
function visitPuzzleUiSpriteNeighbor(
|
||||
index: number,
|
||||
inBounds: boolean,
|
||||
alpha: ArrayLike<number>,
|
||||
visited: Uint8Array,
|
||||
stack: number[],
|
||||
alphaThreshold: number,
|
||||
) {
|
||||
const alphaValue = alpha[index];
|
||||
if (
|
||||
!inBounds ||
|
||||
visited[index] ||
|
||||
alphaValue === undefined ||
|
||||
alphaValue <= alphaThreshold
|
||||
) {
|
||||
return;
|
||||
}
|
||||
visited[index] = 1;
|
||||
stack.push(index);
|
||||
}
|
||||
Reference in New Issue
Block a user