This commit is contained in:
2026-05-11 16:15:48 +08:00
parent 0c9254502c
commit e30b733b17
87 changed files with 3527 additions and 1261 deletions

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
@@ -51,7 +52,7 @@ const testEntryConfig = {
subtitle: '形状投放挑战',
badge: '可创建',
imageSrc: '/creation-type-references/square-hole.webp',
visible: true,
visible: false,
open: true,
sortOrder: 50,
updatedAtMicros: 1,
@@ -164,6 +165,30 @@ const baseDraftItem: CustomWorldWorkSummary = {
canEnterWorld: false,
};
const hiddenSquareHoleItem: SquareHoleWorkSummary = {
workId: 'square-hole:work-hidden',
profileId: 'square-hole-profile-hidden',
ownerUserId: 'user-1',
gameName: '隐藏方洞挑战',
themeText: '方洞',
twistRule: '隐藏入口',
summary: '入口隐藏后,这条作品不应出现在创作页作品架。',
tags: ['方洞'],
coverImageSrc: null,
backgroundPrompt: '',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 0,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: new Date('2026-05-10T10:00:00.000Z').toISOString(),
publishedAt: null,
publishReady: false,
sourceSessionId: 'square-hole-session-hidden',
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -185,19 +210,20 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', {
name: /.*/u,
const puzzleButton = screen.getByRole('button', {
name: /.*/u,
});
const match3dButton = screen.getByRole('button', {
name: /.*3D /u,
});
const squareHoleButton = screen.getByRole('button', { name: //u });
expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
expect(puzzleButton).toBeTruthy();
expect(match3dButton).toBeTruthy();
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByText('反直觉形状分拣')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
await user.click(match3dButton);
@@ -234,6 +260,29 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.queryByText('地点 6')).toBeNull();
});
test('creation hub hides square hole works when the creation type is hidden', () => {
const onOpenSquareHoleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
squareHoleItems={[hiddenSquareHoleItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenSquareHoleDetail={onOpenSquareHoleDetail}
/>,
);
expect(screen.queryByText('隐藏方洞挑战')).toBeNull();
expect(screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。')).toBeNull();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
render(
<CustomWorldCreationHub

View File

@@ -47,7 +47,7 @@ const testEntryConfig = {
subtitle: '形状投放挑战',
badge: '可创建',
imageSrc: '/creation-type-references/square-hole.webp',
visible: true,
visible: false,
open: true,
sortOrder: 50,
updatedAtMicros: 1,
@@ -120,10 +120,10 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).toContain('拼图关卡创作');
expect(html).toContain('抓大鹅');
expect(html).toContain('经典消除玩法');
expect(html).not.toContain('角色扮演');
expect(html).toContain('3D 消除关卡');
expect(html).not.toContain('文字冒险');
expect(html).not.toContain('大鱼吃小鱼');
});

View File

@@ -9,6 +9,7 @@ import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contrac
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldProfile } from '../../types';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import type {
PlatformCreationTypeCard,
PlatformCreationTypeId,
@@ -169,6 +170,10 @@ export function CustomWorldCreationHub({
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const shelfItems = useMemo(
() =>
buildCreationWorkShelfItems({
@@ -176,18 +181,20 @@ export function CustomWorldCreationHub({
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
puzzleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
}),
[
bigFishItems,
isSquareHoleCreationVisible,
items,
match3dItems,
onDeleteBigFish,

View File

@@ -0,0 +1,315 @@
import { Box, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { readAssetBytes } from '../../services/assetReadUrlService';
type ThreeModule = typeof import('three');
type GltfPayload = import('three/examples/jsm/loaders/GLTFLoader.js').GLTF;
type PreviewStatus = 'empty' | 'loading' | 'ready' | 'fallback';
type Match3DModelPreviewProps = {
modelSrc?: string | null;
className?: string;
};
function hasWebGLSupport() {
try {
const canvas = document.createElement('canvas');
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
} catch {
return false;
}
}
function disposeThreeObject(object: import('three').Object3D) {
object.traverse((child) => {
const mesh = child as import('three').Mesh;
mesh.geometry?.dispose();
const material = mesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
function applyCanvasLayout(canvas: HTMLCanvasElement) {
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.inset = '0';
canvas.style.position = 'absolute';
canvas.style.width = '100%';
}
function centerAndScaleModel(three: ThreeModule, model: import('three').Object3D) {
const bounds = new three.Box3().setFromObject(model);
const size = bounds.getSize(new three.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z, 0.001);
const scale = 1.45 / maxDimension;
model.scale.setScalar(scale);
const centeredBounds = new three.Box3().setFromObject(model);
const center = centeredBounds.getCenter(new three.Vector3());
model.position.sub(center);
}
export function Match3DModelPreview({
modelSrc,
className = '',
}: Match3DModelPreviewProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasHostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<{
animationId: number | null;
cleanup: (() => void) | null;
renderer: import('three').WebGLRenderer;
} | null>(null);
const [status, setStatus] = useState<PreviewStatus>(
modelSrc ? 'loading' : 'empty',
);
useEffect(() => {
const container = containerRef.current;
const canvasHost = canvasHostRef.current;
if (!container || !canvasHost) {
return undefined;
}
const source = modelSrc?.trim() ?? '';
if (!source) {
setStatus('empty');
runtimeRef.current?.cleanup?.();
runtimeRef.current = null;
canvasHost.replaceChildren();
return undefined;
}
let cancelled = false;
let objectUrl: string | null = null;
const teardown = () => {
const runtime = runtimeRef.current;
if (runtime?.animationId != null) {
window.cancelAnimationFrame(runtime.animationId);
}
runtime?.cleanup?.();
runtime?.renderer.dispose();
runtime?.renderer.domElement.remove();
runtimeRef.current = null;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
canvasHost.replaceChildren();
};
const setup = async () => {
if (!hasWebGLSupport()) {
setStatus('fallback');
return;
}
setStatus('loading');
try {
const [three, loaderModule, response] = await Promise.all([
import('three'),
import('three/examples/jsm/loaders/GLTFLoader.js'),
readAssetBytes(source, { expireSeconds: 600 }),
]);
if (cancelled || !containerRef.current) {
return;
}
const bytes = await response.arrayBuffer();
if (bytes.byteLength === 0) {
throw new Error('empty model');
}
const blob = new Blob([bytes], {
type: 'model/gltf-binary',
});
objectUrl = URL.createObjectURL(blob);
const renderer = new three.WebGLRenderer({
alpha: true,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
applyCanvasLayout(renderer.domElement);
canvasHost.appendChild(renderer.domElement);
const scene = new three.Scene();
scene.background = null;
const camera = new three.PerspectiveCamera(35, 1, 0.1, 100);
camera.position.set(0.06, 0.92, 2.8);
camera.lookAt(0, 0.1, 0);
scene.add(new three.AmbientLight(0xffffff, 1.55));
const keyLight = new three.DirectionalLight(0xffffff, 2.8);
keyLight.position.set(-3.6, 4.8, 3.5);
scene.add(keyLight);
const fillLight = new three.DirectionalLight(0xfef3c7, 0.62);
fillLight.position.set(2.5, 1.8, -3.2);
scene.add(fillLight);
const rimLight = new three.DirectionalLight(0xffffff, 0.8);
rimLight.position.set(1.8, 3.6, -4.4);
scene.add(rimLight);
const modelRoot = new three.Group();
modelRoot.rotation.set(0.2, 0.45, 0.02);
scene.add(modelRoot);
const loader = new loaderModule.GLTFLoader();
const gltf = await new Promise<GltfPayload>(
(resolve, reject) => {
loader.load(
objectUrl as string,
(loaded: GltfPayload) => resolve(loaded),
undefined,
(error) => reject(error),
);
},
);
if (cancelled) {
const cancelledModel = gltf.scene ?? gltf.scenes[0];
if (cancelledModel) {
disposeThreeObject(cancelledModel);
}
return;
}
const model = gltf.scene ?? gltf.scenes[0];
if (!model) {
throw new Error('missing model scene');
}
modelRoot.add(model);
centerAndScaleModel(three, model);
const resize = () => {
const rect = container.getBoundingClientRect();
const width = Math.max(1, rect.width);
const height = Math.max(1, rect.height);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.render(scene, camera);
};
const resizeObserver = window.ResizeObserver
? new window.ResizeObserver(resize)
: null;
resizeObserver?.observe(container);
resize();
const pointerState = {
dragging: false,
lastX: 0,
lastY: 0,
};
const handlePointerDown = (event: PointerEvent) => {
if (event.button !== 0) {
return;
}
pointerState.dragging = true;
pointerState.lastX = event.clientX;
pointerState.lastY = event.clientY;
if (typeof container.setPointerCapture === 'function') {
container.setPointerCapture(event.pointerId);
}
};
const handlePointerMove = (event: PointerEvent) => {
if (!pointerState.dragging) {
return;
}
const deltaX = event.clientX - pointerState.lastX;
const deltaY = event.clientY - pointerState.lastY;
pointerState.lastX = event.clientX;
pointerState.lastY = event.clientY;
modelRoot.rotation.y += deltaX * 0.01;
modelRoot.rotation.x = Math.max(
-1.15,
Math.min(1.15, modelRoot.rotation.x + deltaY * 0.01),
);
renderer.render(scene, camera);
};
const handlePointerEnd = (event: PointerEvent) => {
pointerState.dragging = false;
if (
typeof container.hasPointerCapture === 'function' &&
container.hasPointerCapture(event.pointerId)
) {
container.releasePointerCapture(event.pointerId);
}
};
container.addEventListener('pointerdown', handlePointerDown);
container.addEventListener('pointermove', handlePointerMove);
container.addEventListener('pointerup', handlePointerEnd);
container.addEventListener('pointercancel', handlePointerEnd);
const animate = () => {
renderer.render(scene, camera);
if (runtimeRef.current) {
runtimeRef.current.animationId = window.requestAnimationFrame(animate);
}
};
runtimeRef.current = {
animationId: window.requestAnimationFrame(animate),
cleanup: () => {
resizeObserver?.disconnect();
container.removeEventListener('pointerdown', handlePointerDown);
container.removeEventListener('pointermove', handlePointerMove);
container.removeEventListener('pointerup', handlePointerEnd);
container.removeEventListener('pointercancel', handlePointerEnd);
disposeThreeObject(modelRoot);
},
renderer,
};
setStatus('ready');
} catch {
if (!cancelled) {
setStatus('fallback');
}
}
};
void setup();
return () => {
cancelled = true;
teardown();
};
}, [modelSrc]);
return (
<div
ref={containerRef}
aria-label="3D 模型预览"
className={`relative overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/78 ${className}`}
data-testid="match3d-model-preview"
>
<div ref={canvasHostRef} aria-hidden="true" className="absolute inset-0" />
{status !== 'ready' ? (
<div className="absolute inset-0 grid place-items-center text-[var(--platform-text-soft)]">
{status === 'loading' ? (
<Loader2 className="h-8 w-8 animate-spin" />
) : (
<Box className="h-8 w-8" />
)}
</div>
) : null}
<div className="pointer-events-none absolute inset-0 z-10" />
</div>
);
}

View File

@@ -21,14 +21,14 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src?.startsWith('/generated-')
? `https://signed.example.com${src}`
: (src ?? ''),
isResolving: false,
shouldResolve: Boolean(src?.startsWith('/generated-')),
}),
vi.mock('./Match3DModelPreview', () => ({
Match3DModelPreview: ({
modelSrc,
}: {
modelSrc?: string | null;
}) => (
<div data-model-src={modelSrc ?? ''} data-testid="match3d-model-preview" />
),
}));
vi.mock('../../services/assetReadUrlService', () => ({
@@ -45,6 +45,7 @@ vi.mock('../../services/assetReadUrlService', () => ({
}));
vi.mock('../../services/match3d-works', () => ({
generateMatch3DWorkTags: vi.fn(),
publishMatch3DWork: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
@@ -53,7 +54,6 @@ vi.mock('../../services/hyper3dModelGenerationService', () => ({
getHyper3dDownloads: vi.fn(),
getHyper3dTaskStatus: vi.fn(),
submitHyper3dImageToModel: vi.fn(),
submitHyper3dTextToModel: vi.fn(),
}));
afterEach(() => {
@@ -71,8 +71,8 @@ function createProfile(
sourceSessionId: 'match3d-session-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '水果主题的经典消除玩法。',
tags: ['水果'],
summary: '',
tags: ['水果', '抓大鹅', '经典消除'],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 4,
@@ -87,6 +87,68 @@ function createProfile(
}
describe('Match3DResultView', () => {
test('作品信息 Tab 字段命名对齐拼图草稿且描述可为空', async () => {
const profile = createProfile();
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
item: profile,
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'水果抓大鹅',
);
expect(screen.getByLabelText('作品描述')).toHaveProperty('value', '');
expect(screen.getByText('作品标签')).toBeTruthy();
expect(screen.getByText('水果')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
await waitFor(() => {
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
'match3d-profile-1',
expect.objectContaining({
gameName: '水果抓大鹅',
summary: '',
tags: ['水果', '抓大鹅', '经典消除'],
}),
);
});
});
test('作品标签支持 AI 生成并写回标签编辑区', async () => {
vi.mocked(match3dWorksService.generateMatch3DWorkTags).mockResolvedValue({
tags: ['果园', '抓大鹅', '经典消除', '轻量休闲'],
});
render(
<Match3DResultView
profile={createProfile({ tags: [] })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
await waitFor(() => {
expect(match3dWorksService.generateMatch3DWorkTags).toHaveBeenCalledWith({
gameName: '水果抓大鹅',
themeText: '水果',
});
expect(screen.getByText('果园')).toBeTruthy();
expect(screen.getByText('轻量休闲')).toBeTruthy();
});
});
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
const profile = createProfile();
const onStartTestRun = vi.fn();
@@ -121,7 +183,7 @@ describe('Match3DResultView', () => {
test('发布仍要求封面和标签数量满足门槛', () => {
render(
<Match3DResultView
profile={createProfile()}
profile={createProfile({ tags: ['水果', '抓大鹅'] })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
@@ -150,22 +212,15 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByRole('button', { name: '文生模型' })).toBeTruthy();
expect(screen.getByRole('button', { name: '图生模型' })).toBeTruthy();
expect(screen.getByTestId('match3d-model-preview')).toBeTruthy();
expect(screen.getByRole('button', { name: '重新生成' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '文生模型' })).toBeNull();
expect(screen.queryByRole('button', { name: '图生模型' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
expect(screen.queryByText('提示词')).toBeNull();
});
test('Rodin 文生模型提交使用 Hyper3D 代理', async () => {
vi.mocked(hyper3dService.submitHyper3dTextToModel).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
mode: 'text-to-model',
taskUuid: 'task-1',
subscriptionKey: 'sub-1',
jobUuids: ['job-1'],
message: 'submitted',
tier: 'Gen-2',
});
test('重新生成缺少参考图时阻止提交', async () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
@@ -176,42 +231,15 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(hyper3dService.submitHyper3dTextToModel).toHaveBeenCalledWith(
expect.objectContaining({
geometryFileFormat: 'glb',
material: 'PBR',
meshMode: 'Quad',
prompt: expect.stringContaining('水果核心物件'),
}),
);
});
await waitFor(() => {
expect(screen.getAllByText('排队中').length).toBeGreaterThan(0);
});
});
test('Rodin 图生模型没有参考图时阻止提交', async () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '图生模型' }));
const generateButton = screen.getByRole('button', { name: '生成' });
const generateButton = screen.getByRole('button', { name: '重新生成' });
expect(generateButton).toHaveProperty('disabled', true);
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
});
test('结果页优先预览生成出来的物品图片和模型文件', () => {
test('结果页优先生成出来的模型文件交给模型预览', () => {
const modelSrc =
'/generated-match3d-assets/session/profile/items/strawberry/model/model.glb';
render(
<Match3DResultView
profile={createProfile({
@@ -223,7 +251,7 @@ describe('Match3DResultView', () => {
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/image.png',
modelSrc: '/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelSrc,
modelObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelFileName: 'strawberry.glb',
@@ -244,13 +272,12 @@ describe('Match3DResultView', () => {
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
const modelLink = screen.getByRole('link', { name: /strawberry\.glb/u });
expect(modelLink.getAttribute('href')).toBe(
'https://signed.example.com/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe(
modelSrc,
);
});
test('草稿阶段仅有切割图片时展示图片已就绪,不要求模型文件', () => {
test('草稿阶段仅有切割图片时模型预览为空', () => {
render(
<Match3DResultView
profile={createProfile({
@@ -282,7 +309,7 @@ describe('Match3DResultView', () => {
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
expect(screen.getByText('0 文件')).toBeTruthy();
expect(screen.getByTestId('match3d-model-preview').getAttribute('data-model-src')).toBe('');
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
});
@@ -348,6 +375,24 @@ describe('Match3DResultView', () => {
message: 'submitted',
tier: 'Gen-2',
});
vi.mocked(hyper3dService.getHyper3dTaskStatus).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
status: 'done',
jobs: [],
raw: {},
});
vi.mocked(hyper3dService.getHyper3dDownloads).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
files: [
{
name: 'strawberry.glb',
url: 'https://cdn.example.com/strawberry.glb',
},
],
raw: {},
});
vi.stubGlobal('fetch', vi.fn());
render(
@@ -378,7 +423,7 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
fireEvent.click(screen.getByRole('button', { name: '重新生成' }));
await waitFor(() => {
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
@@ -392,6 +437,9 @@ describe('Match3DResultView', () => {
prompt: expect.stringContaining('草莓'),
}),
);
expect(hyper3dService.getHyper3dDownloads).toHaveBeenCalledWith({
taskUuid: 'task-image',
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,24 @@
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';
import type {
Match3DItemSnapshot,
Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import { readAssetBytes } from '../../services/assetReadUrlService';
import {
isItemState,
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import {
resolveGeometryAsset,
type Match3DGeometryAsset,
type Match3DGeometryShape,
resolveGeometryAsset,
} from './match3dVisualAssets';
type Match3DPhysicsBoardProps = {
run: Match3DRunSnapshot;
generatedItemAssets?: Match3DGeneratedItemAsset[];
disabled: boolean;
onClickItem: (item: Match3DItemSnapshot) => void;
onFallback: () => void;
@@ -30,11 +33,17 @@ type ThreeObject3D = import('three').Object3D;
type ThreeScene = import('three').Scene;
type ThreeRenderer = import('three').WebGLRenderer;
type ThreeCamera = import('three').OrthographicCamera;
type Match3DGeneratedModelTemplate = {
source: string;
scene: ThreeObject3D;
};
type Match3DGeneratedModelTemplateMap = Map<string, Match3DGeneratedModelTemplate>;
type PhysicsEntry = {
boundaryRadius: number;
colliderHeight: number;
item: Match3DItemSnapshot;
baseVisualScale: import('three').Vector3;
body: PhysicsBody;
lockReadableTop: boolean;
mesh: ThreeObject3D;
@@ -101,6 +110,7 @@ type PhysicsRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, PhysicsEntry>;
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
pendingSpawns: Map<string, PendingPhysicsSpawn>;
raycaster: import('three').Raycaster;
renderer: ThreeRenderer;
@@ -148,12 +158,182 @@ const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
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;
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
new Set([
'ring',
'arch',
]);
function normalizeMatch3DGeneratedModelSource(
asset: Match3DGeneratedItemAsset,
) {
return asset.modelSrc?.trim() || asset.modelObjectKey?.trim() || '';
}
function compareMatch3DGeneratedTypeId(left: string, right: string) {
const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) {
return leftIndex - rightIndex;
}
return left.localeCompare(right);
}
function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) {
return [
...new Set(
items.map((item) => item.itemTypeId.trim()).filter(Boolean),
),
].sort(compareMatch3DGeneratedTypeId);
}
function buildMatch3DGeneratedAssetTypeMap(
run: Match3DRunSnapshot,
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
const typeIds = resolveMatch3DGeneratedModelTypeIds(run.items);
const readyAssets = generatedItemAssets
.map((asset) => ({
asset,
source: normalizeMatch3DGeneratedModelSource(asset),
}))
.filter(({ source }) => Boolean(source))
.slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT);
const assetMap = new Map<string, Match3DGeneratedItemAsset>();
typeIds.forEach((itemTypeId, index) => {
const resolved = readyAssets[index];
if (!resolved) {
return;
}
assetMap.set(itemTypeId, {
...resolved.asset,
modelSrc: resolved.source,
});
});
return assetMap;
}
function buildGeneratedModelMapSignature(
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
) {
return [...generatedModelByType.entries()]
.map(
([itemTypeId, asset]) =>
`${itemTypeId}:${normalizeMatch3DGeneratedModelSource(asset)}`,
)
.join('|');
}
function resolveGeneratedModelSourceForItemType(
generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
itemTypeId: string,
) {
const asset = generatedModelByType.get(itemTypeId);
return asset ? normalizeMatch3DGeneratedModelSource(asset) : '';
}
async function loadMatch3DGeneratedModelTemplate(
templateMap: Match3DGeneratedModelTemplateMap,
three: ThreeModule,
itemTypeId: string,
source: string,
signal?: AbortSignal,
) {
const cached = templateMap.get(itemTypeId);
if (cached?.source === source) {
return cached.scene;
}
const response = await readAssetBytes(source, {
expireSeconds: 300,
signal,
});
const bytes = await response.arrayBuffer();
if (bytes.byteLength === 0) {
throw new Error('抓大鹅 3D 模型内容为空');
}
if (signal?.aborted) {
throw new DOMException('加载已取消', 'AbortError');
}
const [{ GLTFLoader }] = await Promise.all([
import('three/examples/jsm/loaders/GLTFLoader.js'),
]);
const loader = new GLTFLoader();
const gltf = await loader.parseAsync(bytes, '');
if (signal?.aborted) {
throw new DOMException('加载已取消', 'AbortError');
}
const scene = gltf.scene;
scene.traverse((child) => {
child.castShadow = true;
child.receiveShadow = true;
});
const previous = templateMap.get(itemTypeId);
if (previous && previous.source !== source) {
disposeThreeObject(previous.scene);
}
templateMap.set(itemTypeId, {
scene,
source,
});
return scene;
}
function cloneThreeObjectWithMaterials(template: ThreeObject3D) {
const clone = template.clone(true);
clone.traverse((child) => {
const maybeMesh = child as import('three').Mesh;
if (maybeMesh.geometry) {
maybeMesh.geometry = maybeMesh.geometry.clone();
}
if (maybeMesh.material) {
maybeMesh.material = Array.isArray(maybeMesh.material)
? maybeMesh.material.map((material) => material.clone())
: maybeMesh.material.clone();
}
});
return clone;
}
function createGeneratedModelMesh(
three: ThreeModule,
item: Match3DItemSnapshot,
templateMap: Match3DGeneratedModelTemplateMap | null | undefined,
) {
const template = templateMap?.get(item.itemTypeId)?.scene;
if (!template) {
return null;
}
const position = toWorldPosition(item);
const model = cloneThreeObjectWithMaterials(template);
markObjectForItem(model, item.itemInstanceId);
const bounds = new three.Box3().setFromObject(model);
const size = bounds.getSize(new three.Vector3());
const dimension = Math.max(size.x, size.y, size.z, 0.001);
const targetDimension =
position.radius * MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE;
const scale = targetDimension / dimension;
model.scale.multiplyScalar(scale);
const scaledBounds = new three.Box3().setFromObject(model);
const center = scaledBounds.getCenter(new three.Vector3());
model.position.sub(center);
const bottomY = scaledBounds.min.y - center.y;
model.position.y -= bottomY;
return {
lockReadableTop: false,
mesh: model,
radius: position.radius,
shape: 'brick' as Match3DGeometryShape,
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
position,
};
}
function hasWebGLSupport() {
try {
const canvas = document.createElement('canvas');
@@ -951,13 +1131,19 @@ export function createMatch3DItemMesh(
function createItemMesh(
three: ThreeModule,
item: Match3DItemSnapshot,
templateMap?: Match3DGeneratedModelTemplateMap | null,
) {
return createMatch3DItemMesh(three, item);
return (
createGeneratedModelMesh(three, item, templateMap) ??
createMatch3DItemMesh(three, item)
);
}
export function buildMatch3DPhysicsEntrySignature(
runId: string,
item: Match3DItemSnapshot,
generatedModelSource = '',
generatedModelRevision = 0,
) {
return [
runId,
@@ -966,6 +1152,8 @@ export function buildMatch3DPhysicsEntrySignature(
item.visualKey,
item.radius.toFixed(5),
item.layer,
generatedModelSource,
generatedModelRevision,
].join(':');
}
@@ -984,8 +1172,9 @@ function createPhysicsEntryFromPendingSpawn(
runtime: PhysicsRuntime,
pendingSpawn: PendingPhysicsSpawn,
now: number,
templateMap?: Match3DGeneratedModelTemplateMap | null,
) {
const visual = createItemMesh(runtime.three, pendingSpawn.item);
const visual = createItemMesh(runtime.three, pendingSpawn.item, templateMap);
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
@@ -1042,11 +1231,15 @@ function createPhysicsEntryFromPendingSpawn(
0.08,
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
);
visual.mesh.scale.setScalar(MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START);
const baseVisualScale = visual.mesh.scale.clone();
visual.mesh.scale
.copy(baseVisualScale)
.multiplyScalar(MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START);
runtime.world.addBody(body);
runtime.scene.add(visual.mesh);
runtime.entries.set(pendingSpawn.item.itemInstanceId, {
baseVisualScale,
body,
boundaryRadius,
colliderHeight: colliderBounds.height,
@@ -1075,7 +1268,12 @@ function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
runtime.pendingSpawns.delete(itemInstanceId);
createPhysicsEntryFromPendingSpawn(runtime, pendingSpawn, now);
createPhysicsEntryFromPendingSpawn(
runtime,
pendingSpawn,
now,
runtime.generatedModelTemplates,
);
});
}
@@ -1089,6 +1287,10 @@ function disposeRuntime(runtime: PhysicsRuntime | null) {
runtime.entries.forEach((entry) => {
disposeThreeObject(entry.mesh);
});
runtime.generatedModelTemplates.forEach((template) => {
disposeThreeObject(template.scene);
});
runtime.generatedModelTemplates.clear();
runtime.renderer.dispose();
runtime.renderer.domElement.remove();
}
@@ -1097,6 +1299,7 @@ type TrayPreviewRuntime = {
animationId: number | null;
camera: ThreeCamera;
entries: Map<string, ThreeObject3D>;
generatedModelTemplates: Match3DGeneratedModelTemplateMap;
renderer: ThreeRenderer;
scene: ThreeScene;
three: ThreeModule;
@@ -1114,13 +1317,18 @@ function buildTrayPreviewMeasureKey(item: Match3DItemSnapshot) {
function buildTrayPreviewSignature(
item: Match3DItemSnapshot,
referenceMaxDimension: number,
generatedModelSource = '',
generatedModelRevision = 0,
) {
return [
item.visualKey,
item.itemTypeId,
item.radius.toFixed(5),
referenceMaxDimension.toFixed(5),
MATCH3D_TRAY_MODEL_TARGET_SIZE.toFixed(5),
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE.toFixed(5),
generatedModelSource,
generatedModelRevision,
].join(':');
}
@@ -1215,6 +1423,10 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
disposeThreeObject(mesh);
});
runtime.entries.clear();
runtime.generatedModelTemplates.forEach((template) => {
disposeThreeObject(template.scene);
});
runtime.generatedModelTemplates.clear();
runtime.renderer.dispose();
runtime.renderer.domElement.remove();
}
@@ -1255,18 +1467,66 @@ function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) {
});
}
function buildMatch3DTrayModelSourceMap(
referenceItems: Match3DItemSnapshot[],
slotItems: Array<Match3DItemSnapshot | null>,
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
const itemTypeIds = resolveMatch3DGeneratedModelTypeIds([
...referenceItems,
...slotItems.filter((item): item is Match3DItemSnapshot => Boolean(item)),
]);
const readyAssets = generatedItemAssets
.map((asset) => ({
asset,
source: normalizeMatch3DGeneratedModelSource(asset),
}))
.filter(({ source }) => Boolean(source))
.slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT);
const result = new Map<string, string>();
itemTypeIds.forEach((itemTypeId, index) => {
const resolved = readyAssets[index];
if (!resolved) {
return;
}
result.set(itemTypeId, resolved.source);
});
return result;
}
export function Match3DTrayPreviewBoard({
onFallback,
referenceItems,
slotItems,
generatedItemAssets = [],
}: {
onFallback: () => void;
referenceItems: Match3DItemSnapshot[];
slotItems: Array<Match3DItemSnapshot | null>;
generatedItemAssets?: Match3DGeneratedItemAsset[];
}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TrayPreviewRuntime | null>(null);
const [ready, setReady] = useState(false);
const trayModelSourceByType = useMemo(
() =>
buildMatch3DTrayModelSourceMap(
referenceItems,
slotItems,
generatedItemAssets,
),
[generatedItemAssets, referenceItems, slotItems],
);
const trayModelSignature = useMemo(
() =>
[...trayModelSourceByType.entries()]
.map(([type, source]) => `${type}:${source}`)
.join('|'),
[trayModelSourceByType],
);
const [trayModelRevision, setTrayModelRevision] = useState(0);
useEffect(() => {
let cancelled = false;
@@ -1342,6 +1602,8 @@ export function Match3DTrayPreviewBoard({
animationId: null,
camera,
entries: runtimeRef.current?.entries ?? new Map(),
generatedModelTemplates:
runtimeRef.current?.generatedModelTemplates ?? new Map(),
renderer,
scene,
three,
@@ -1365,6 +1627,7 @@ export function Match3DTrayPreviewBoard({
animationId: window.requestAnimationFrame(animate),
camera,
entries: new Map(),
generatedModelTemplates: new Map(),
renderer,
scene,
three,
@@ -1395,6 +1658,61 @@ export function Match3DTrayPreviewBoard({
};
}, [onFallback]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return undefined;
}
const abortController = new AbortController();
const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
trayModelSourceByType.forEach((source, itemTypeId) => {
staleItemTypeIds.delete(itemTypeId);
const hadFreshTemplate =
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
void loadMatch3DGeneratedModelTemplate(
runtime.generatedModelTemplates,
runtime.three,
itemTypeId,
source,
abortController.signal,
)
.then(() => {
if (hadFreshTemplate) {
return;
}
setTrayModelRevision((current) => current + 1);
runtime.entries.forEach((mesh, itemInstanceId) => {
const itemType = slotItems.find(
(item) => item?.itemInstanceId === itemInstanceId,
)?.itemTypeId;
if (itemType !== itemTypeId) {
return;
}
runtime.scene.remove(mesh);
disposeThreeObject(mesh);
runtime.entries.delete(itemInstanceId);
});
})
.catch(() => {
if (abortController.signal.aborted) {
return;
}
runtime.generatedModelTemplates.delete(itemTypeId);
});
});
staleItemTypeIds.forEach((itemTypeId) => {
const template = runtime.generatedModelTemplates.get(itemTypeId);
if (template) {
disposeThreeObject(template.scene);
}
runtime.generatedModelTemplates.delete(itemTypeId);
});
return () => {
abortController.abort();
};
}, [slotItems, trayModelSignature, trayModelSourceByType]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
@@ -1430,6 +1748,8 @@ export function Match3DTrayPreviewBoard({
const previewSignature = buildTrayPreviewSignature(
item,
referenceMaxDimension,
trayModelSourceByType.get(item.itemTypeId) ?? '',
trayModelRevision,
);
let mesh = runtime.entries.get(item.itemInstanceId);
if (mesh && mesh.userData.trayPreviewSignature !== previewSignature) {
@@ -1439,7 +1759,11 @@ export function Match3DTrayPreviewBoard({
mesh = undefined;
}
if (!mesh) {
const preview = createMatch3DItemMesh(runtime.three, item);
const preview = createItemMesh(
runtime.three,
item,
runtime.generatedModelTemplates,
);
const model = preview.mesh;
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
model.rotation.set(rotation.x, rotation.y, rotation.z);
@@ -1474,7 +1798,14 @@ export function Match3DTrayPreviewBoard({
});
runtime.renderer.render(runtime.scene, runtime.camera);
}, [ready, referenceItems, slotItems]);
}, [
ready,
referenceItems,
slotItems,
trayModelRevision,
trayModelSignature,
trayModelSourceByType,
]);
return (
<div
@@ -1487,6 +1818,7 @@ export function Match3DTrayPreviewBoard({
export function Match3DPhysicsBoard({
run,
generatedItemAssets = [],
disabled,
onClickItem,
onFallback,
@@ -1496,7 +1828,16 @@ export function Match3DPhysicsBoard({
const disabledRef = useRef(disabled);
const fallbackRef = useRef(onFallback);
const runRef = useRef(run);
const generatedModelByType = useMemo(
() => buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets),
[generatedItemAssets, run],
);
const generatedModelSignature = useMemo(
() => buildGeneratedModelMapSignature(generatedModelByType),
[generatedModelByType],
);
const [ready, setReady] = useState(false);
const [generatedModelRevision, setGeneratedModelRevision] = useState(0);
useEffect(() => {
fallbackRef.current = onFallback;
@@ -1686,6 +2027,7 @@ export function Match3DPhysicsBoard({
animationId: null,
camera,
entries: new Map(),
generatedModelTemplates: new Map(),
pendingSpawns: new Map(),
raycaster: new three.Raycaster(),
renderer,
@@ -1737,7 +2079,7 @@ export function Match3DPhysicsBoard({
constrainBodyInsidePot(entry);
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
const spawnScale = resolveMatch3DSpawnVisualScale(spawnProgress);
entry.mesh.scale.setScalar(spawnScale);
entry.mesh.scale.copy(entry.baseVisualScale).multiplyScalar(spawnScale);
entry.mesh.position.set(
entry.body.position.x,
entry.body.position.y -
@@ -1787,6 +2129,65 @@ export function Match3DPhysicsBoard({
};
}, []);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
return undefined;
}
const abortController = new AbortController();
const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
generatedModelByType.forEach((asset, itemTypeId) => {
const source = normalizeMatch3DGeneratedModelSource(asset);
staleItemTypeIds.delete(itemTypeId);
if (!source) {
return;
}
const hadFreshTemplate =
runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
void loadMatch3DGeneratedModelTemplate(
runtime.generatedModelTemplates,
runtime.three,
itemTypeId,
source,
abortController.signal,
)
.then(() => {
if (hadFreshTemplate) {
return;
}
setGeneratedModelRevision((current) => current + 1);
const hasActiveEntry = [...runtime.entries.values()].some(
(entry) => entry.item.itemTypeId === itemTypeId,
);
if (!hasActiveEntry) {
return;
}
runtime.entries.forEach((entry, itemInstanceId) => {
if (entry.item.itemTypeId === itemTypeId) {
removePhysicsEntry(runtime, itemInstanceId, entry);
}
});
})
.catch(() => {
if (abortController.signal.aborted) {
return;
}
runtime.generatedModelTemplates.delete(itemTypeId);
});
});
staleItemTypeIds.forEach((itemTypeId) => {
const template = runtime.generatedModelTemplates.get(itemTypeId);
if (template) {
disposeThreeObject(template.scene);
}
runtime.generatedModelTemplates.delete(itemTypeId);
});
return () => {
abortController.abort();
};
}, [generatedModelByType, generatedModelSignature]);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime) {
@@ -1823,6 +2224,11 @@ export function Match3DPhysicsBoard({
const renderSignature = buildMatch3DPhysicsEntrySignature(
run.runId,
item,
resolveGeneratedModelSourceForItemType(
generatedModelByType,
item.itemTypeId,
),
generatedModelRevision,
);
const existing = runtime.entries.get(item.itemInstanceId);
if (existing) {
@@ -1873,7 +2279,16 @@ export function Match3DPhysicsBoard({
resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
});
});
}, [ready, run.items, run.runId, run.snapshotVersion]);
}, [
generatedModelSignature,
generatedModelRevision,
generatedModelByType,
ready,
run,
run.items,
run.runId,
run.snapshotVersion,
]);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
event.stopPropagation();

View File

@@ -22,10 +22,7 @@ import type {
Match3DRunSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import {
Match3DPhysicsBoard,
Match3DTrayPreviewBoard,
@@ -35,9 +32,14 @@ import {
isRunState,
resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import {
Match3DVisualIcon,
resolveVisualSeed,
} from './match3dVisualAssets';
type Match3DRuntimeShellProps = {
run: Match3DRunSnapshot | null;
generatedItemAssets?: Match3DGeneratedItemAsset[];
isBusy?: boolean;
error?: string | null;
embedded?: boolean;
@@ -300,6 +302,7 @@ function Match3DSettlement({
export function Match3DRuntimeShell({
run,
generatedItemAssets = [],
isBusy = false,
error = null,
embedded = false,
@@ -504,6 +507,7 @@ export function Match3DRuntimeShell({
<Match3DPhysicsBoard
run={run}
disabled={Boolean(pendingClick)}
generatedItemAssets={generatedItemAssets}
onClickItem={(item) => {
void handleItemClick(item);
}}
@@ -539,6 +543,7 @@ export function Match3DRuntimeShell({
onFallback={handleTrayPreviewFallback}
referenceItems={run.items}
slotItems={trayPreviewItems}
generatedItemAssets={generatedItemAssets}
/>
) : null}
{run.traySlots.map((slot) => {

View File

@@ -294,7 +294,7 @@ import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
type VisualNovelEntryFormPayload,
} from '../visual-novel-creation/VisualNovelAgentWorkspace';
} from '../visual-novel-creation/visualNovelEntryGeneration';
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
import {
canExposePublicWork,
@@ -426,7 +426,7 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: 'rpg';
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -579,6 +579,7 @@ function mapPublicWorkDetailToMatch3DWork(
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
publishReady: true,
generatedItemAssets: entry.generatedItemAssets ?? [],
};
}
@@ -1446,7 +1447,9 @@ const VisualNovelResultView = lazy(async () => {
});
const VisualNovelRuntimeShell = lazy(async () => {
const module = await import('../visual-novel-runtime/VisualNovelRuntimeShell');
const module = await import(
'../visual-novel-runtime/VisualNovelRuntimeShell'
);
return {
default: module.VisualNovelRuntimeShell,
};
@@ -1698,8 +1701,10 @@ export function PlatformEntryFlowShellImpl({
useState<VisualNovelRuntimeReturnStage>('visual-novel-result');
const [visualNovelFormDraftPayload, setVisualNovelFormDraftPayload] =
useState<VisualNovelEntryFormPayload | null>(null);
const [visualNovelGenerationStartedAtMs, setVisualNovelGenerationStartedAtMs] =
useState<number | null>(null);
const [
visualNovelGenerationStartedAtMs,
setVisualNovelGenerationStartedAtMs,
] = useState<number | null>(null);
const [visualNovelGenerationPhase, setVisualNovelGenerationPhase] =
useState<VisualNovelEntryGenerationPhase>('generating');
const [isVisualNovelLoadingLibrary, setIsVisualNovelLoadingLibrary] =
@@ -1739,6 +1744,10 @@ export function PlatformEntryFlowShellImpl({
creationEntryTypes,
'big-fish',
);
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationEntryTypes,
'square-hole',
);
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
@@ -2240,19 +2249,16 @@ export function PlatformEntryFlowShellImpl({
visualNovelGalleryEntries,
],
);
const recommendRuntimeEntries = useMemo(
() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([
...featuredGalleryEntries,
...latestGalleryEntries,
]).forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values());
},
[featuredGalleryEntries, latestGalleryEntries],
);
const recommendRuntimeEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([
...featuredGalleryEntries,
...latestGalleryEntries,
]).forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredGalleryEntries, latestGalleryEntries]);
const creationHubItems = useMemo<CustomWorldWorkSummary[]>(
() =>
@@ -3676,7 +3682,10 @@ export function PlatformEntryFlowShellImpl({
setVisualNovelRuntimeReturnStage('visual-novel-result');
setSelectionStage('visual-novel-runtime');
setVisualNovelError(
resolvePuzzleErrorMessage(error, '已进入本地试玩,真实运行接口暂不可用。'),
resolvePuzzleErrorMessage(
error,
'已进入本地试玩,真实运行接口暂不可用。',
),
);
} finally {
setIsVisualNovelBusy(false);
@@ -3976,9 +3985,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOperation(response.operation);
puzzleFlow.setSession(response.session);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '执行拼图操作失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
}
},
[puzzleFlow, resolvePuzzleErrorMessage, setPuzzleError],
@@ -4053,7 +4060,9 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
setActiveCreativeAgentSessionId(creativeAgentSession.sessionId);
setCreativeDraftEditError(null);
setSelectionStage(resolveCreativeAgentTargetSelectionStage(binding.targetStage));
setSelectionStage(
resolveCreativeAgentTargetSelectionStage(binding.targetStage),
);
} catch (error) {
setCreativeAgentError(
resolvePuzzleErrorMessage(error, '打开拼图草稿失败。'),
@@ -5959,7 +5968,9 @@ export function PlatformEntryFlowShellImpl({
throw new Error('未找到视觉小说作品。');
}
openPublicWorkDetail(mapVisualNovelWorkToPublicWorkDetail(matchedEntry));
openPublicWorkDetail(
mapVisualNovelWorkToPublicWorkDetail(matchedEntry),
);
} catch (error) {
setPublicWorkDetailError(
resolvePuzzleErrorMessage(error, '读取视觉小说详情失败。'),
@@ -6522,7 +6533,9 @@ export function PlatformEntryFlowShellImpl({
} else if (isSquareHoleGalleryEntry(entry)) {
const work = mapPublicWorkDetailToSquareHoleWork(entry);
if (!work) {
setSquareHoleError('当前方洞挑战作品信息不完整,暂时无法进入玩法。');
setSquareHoleError(
'当前方洞挑战作品信息不完整,暂时无法进入玩法。',
);
} else {
started = await startSquareHoleRunFromProfile(
work,
@@ -6655,12 +6668,16 @@ export function PlatformEntryFlowShellImpl({
}
if (activeRecommendRuntimeKind === 'match3d') {
const generatedItemAssets = isMatch3DGalleryEntry(activeEntry)
? activeEntry.generatedItemAssets ?? []
: [];
return (
<Match3DRuntimeShell
run={match3dRun}
isBusy={isMatch3DBusy}
error={match3dError}
embedded
generatedItemAssets={generatedItemAssets}
onBack={() => {
setActiveRecommendRuntimeKind(null);
}}
@@ -6677,10 +6694,7 @@ export function PlatformEntryFlowShellImpl({
})
.catch((error) => {
setMatch3DError(
resolveMatch3DErrorMessage(
error,
'重新开始抓大鹅玩法失败。',
),
resolveMatch3DErrorMessage(error, '重新开始抓大鹅玩法失败。'),
);
})
.finally(() => {
@@ -6706,10 +6720,7 @@ export function PlatformEntryFlowShellImpl({
})
.catch((error) => {
setMatch3DError(
resolveMatch3DErrorMessage(
error,
'同步抓大鹅倒计时失败。',
),
resolveMatch3DErrorMessage(error, '同步抓大鹅倒计时失败。'),
);
});
}}
@@ -6847,7 +6858,9 @@ export function PlatformEntryFlowShellImpl({
}
return (
<div className={`platform-theme ${platformThemeClass} flex h-full min-h-0 items-center justify-center bg-[var(--platform-recommend-runtime-state-fill)] px-5 text-center text-sm font-semibold leading-6 text-[var(--platform-recommend-runtime-state-text)]`}>
<div
className={`platform-theme ${platformThemeClass} flex h-full min-h-0 items-center justify-center bg-[var(--platform-recommend-runtime-state-fill)] px-5 text-center text-sm font-semibold leading-6 text-[var(--platform-recommend-runtime-state-text)]`}
>
</div>
);
@@ -7260,7 +7273,10 @@ export function PlatformEntryFlowShellImpl({
const detailEntry = mapBigFishWorkToPublicWorkDetail(entry);
return (
canExposePublicWork(detailEntry) &&
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId)
isSameBigFishPublicWorkCode(
normalizedKeyword,
entry.sourceSessionId,
)
);
});
@@ -7325,7 +7341,9 @@ export function PlatformEntryFlowShellImpl({
throw new Error('未找到视觉小说作品。');
}
openPublicWorkDetail(mapVisualNovelWorkToPublicWorkDetail(matchedEntry));
openPublicWorkDetail(
mapVisualNovelWorkToPublicWorkDetail(matchedEntry),
);
};
try {
@@ -7577,11 +7595,14 @@ export function PlatformEntryFlowShellImpl({
}
void refreshMatch3DGallery();
void refreshPuzzleGallery();
void refreshSquareHoleGallery();
if (isSquareHoleCreationVisible) {
void refreshSquareHoleGallery();
}
void refreshVisualNovelGallery();
}
}, [
isBigFishCreationVisible,
isSquareHoleCreationVisible,
refreshBigFishGallery,
refreshMatch3DGallery,
refreshPuzzleGallery,
@@ -7598,10 +7619,13 @@ export function PlatformEntryFlowShellImpl({
) {
void refreshPuzzleShelf();
void refreshMatch3DShelf();
void refreshSquareHoleShelf();
if (isSquareHoleCreationVisible) {
void refreshSquareHoleShelf();
}
void refreshVisualNovelShelf();
}
}, [
isSquareHoleCreationVisible,
platformBootstrap.canReadProtectedData,
platformBootstrap.platformTab,
refreshMatch3DShelf,
@@ -7641,7 +7665,7 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.isLoadingPlatform ||
isBigFishLoadingLibrary ||
isMatch3DLoadingLibrary ||
isSquareHoleLoadingLibrary ||
(isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) ||
isPuzzleLoadingLibrary ||
isVisualNovelLoadingLibrary
}
@@ -7649,7 +7673,7 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.isLoadingPlatform ||
isBigFishLoadingLibrary ||
isMatch3DLoadingLibrary ||
isSquareHoleLoadingLibrary ||
(isSquareHoleCreationVisible && isSquareHoleLoadingLibrary) ||
isPuzzleLoadingLibrary ||
isVisualNovelLoadingLibrary
? null
@@ -7657,7 +7681,7 @@ export function PlatformEntryFlowShellImpl({
sessionController.agentWorkspaceRestoreError ??
bigFishError ??
match3dError ??
squareHoleError ??
(isSquareHoleCreationVisible ? squareHoleError : null) ??
puzzleShelfError ??
puzzleError ??
visualNovelError)
@@ -7688,7 +7712,9 @@ export function PlatformEntryFlowShellImpl({
void refreshBigFishShelf();
}
void refreshMatch3DShelf();
void refreshSquareHoleShelf();
if (isSquareHoleCreationVisible) {
void refreshSquareHoleShelf();
}
void refreshPuzzleShelf();
void refreshVisualNovelShelf();
}}
@@ -7697,7 +7723,7 @@ export function PlatformEntryFlowShellImpl({
sessionController.creationTypeError ??
bigFishError ??
match3dError ??
squareHoleError ??
(isSquareHoleCreationVisible ? squareHoleError : null) ??
puzzleCreationError ??
puzzleError ??
visualNovelError
@@ -7709,7 +7735,7 @@ export function PlatformEntryFlowShellImpl({
isCreativeAgentStreaming ||
isBigFishBusy ||
isMatch3DBusy ||
isSquareHoleBusy ||
(isSquareHoleCreationVisible && isSquareHoleBusy) ||
isPuzzleBusy ||
isVisualNovelBusy ||
isVisualNovelStreamingReply
@@ -7764,15 +7790,23 @@ export function PlatformEntryFlowShellImpl({
onDeleteMatch3D={(item) => {
handleDeleteMatch3DWork(item);
}}
squareHoleItems={squareHoleWorks}
onOpenSquareHoleDetail={(item) => {
runProtectedAction(() => {
void openSquareHoleDraft(item);
});
}}
onDeleteSquareHole={(item) => {
handleDeleteSquareHoleWork(item);
}}
squareHoleItems={isSquareHoleCreationVisible ? squareHoleWorks : []}
onOpenSquareHoleDetail={
isSquareHoleCreationVisible
? (item) => {
runProtectedAction(() => {
void openSquareHoleDraft(item);
});
}
: undefined
}
onDeleteSquareHole={
isSquareHoleCreationVisible
? (item) => {
handleDeleteSquareHoleWork(item);
}
: null
}
puzzleItems={puzzleWorks}
onOpenPuzzleDetail={(item) => {
runProtectedAction(() => {
@@ -7883,9 +7917,7 @@ export function PlatformEntryFlowShellImpl({
<div className="mt-3 min-h-0 flex-1 overflow-hidden">
{activeCreationFormType === 'match3d' ? (
<Suspense
fallback={
<LazyPanelFallback label="正在加载抓大鹅创作..." />
}
fallback={<LazyPanelFallback label="正在加载抓大鹅创作..." />}
>
<Match3DAgentWorkspace
session={match3dSession}
@@ -7925,7 +7957,9 @@ export function PlatformEntryFlowShellImpl({
/>
</Suspense>
) : (
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
>
<PuzzleAgentWorkspace
session={puzzleSession}
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
@@ -8558,6 +8592,12 @@ export function PlatformEntryFlowShellImpl({
run={match3dRun}
isBusy={isMatch3DBusy}
error={match3dError}
generatedItemAssets={
selectedPublicWorkDetail &&
isMatch3DGalleryEntry(selectedPublicWorkDetail)
? (selectedPublicWorkDetail.generatedItemAssets ?? [])
: (match3dProfile?.generatedItemAssets ?? [])
}
onBack={() => {
if (match3dRun?.runId && match3dRun.status === 'running') {
void stopMatch3DRun(match3dRun.runId).catch(
@@ -9042,7 +9082,9 @@ export function PlatformEntryFlowShellImpl({
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载视觉小说生成面板..." />}
fallback={
<LazyPanelFallback label="正在加载视觉小说生成面板..." />
}
>
<CustomWorldGenerationView
settingText={

View File

@@ -1,6 +1,10 @@
import { expect, test } from 'vitest';
import { derivePlatformCreationTypes, getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
import {
derivePlatformCreationTypes,
getVisiblePlatformCreationTypes,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
test('database entry config controls visibility open state and display order', () => {
const cards = derivePlatformCreationTypes([
@@ -100,4 +104,12 @@ test('visible platform creation types hide invisible cards and put locked cards
'open',
'locked',
]);
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
expect(
cards.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
),
).toBe(true);
});

View File

@@ -33,6 +33,10 @@ import type {
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
fetchCreationEntryConfig,
type CreationEntryConfig,
} from '../../services/creationEntryConfigService';
import {
createBigFishCreationSession,
getBigFishCreationSession,
@@ -227,6 +231,87 @@ function getPlatformTabPanel(tab: string) {
return panel;
}
const testCreationEntryConfig = {
startCard: {
title: '新建作品',
description: '选择模板后进入对应的创作表单。',
idleBadge: '模板 Tab',
busyBadge: '正在开启',
},
typeModal: {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
creationTypes: [
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图关卡创作',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 30,
updatedAtMicros: 1,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '3D 消除关卡',
badge: '可创建',
imageSrc: '/creation-type-references/match3d.webp',
visible: true,
open: true,
sortOrder: 40,
updatedAtMicros: 1,
},
{
id: 'square-hole',
title: '方洞挑战',
subtitle: '形状投放挑战',
badge: '可创建',
imageSrc: '/creation-type-references/square-hole.webp',
visible: false,
open: true,
sortOrder: 50,
updatedAtMicros: 1,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '可创建',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: true,
sortOrder: 60,
updatedAtMicros: 1,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '即将开放',
imageSrc: '/creation-type-references/airp.webp',
visible: true,
open: false,
sortOrder: 70,
updatedAtMicros: 1,
},
{
id: 'creative-agent',
title: '智能创作',
subtitle: '对话式创作实验',
badge: '内测',
imageSrc: '/creation-type-references/creative-agent.webp',
visible: false,
open: true,
sortOrder: 80,
updatedAtMicros: 1,
},
],
} satisfies CreationEntryConfig;
const rpgCreationServiceMocks = vi.hoisted(() => ({
createRpgCreationSession: vi.fn(),
deleteRpgCreationAgentSession: vi.fn(),
@@ -286,6 +371,10 @@ vi.mock('../../services/rpg-entry', () => ({
upsertRpgProfileBrowseHistory: vi.fn(),
}));
vi.mock('../../services/creationEntryConfigService', () => ({
fetchCreationEntryConfig: vi.fn(),
}));
vi.mock('../../services/puzzle-works', () => ({
listPuzzleWorks: vi.fn(),
}));
@@ -1460,6 +1549,7 @@ beforeEach(() => {
'genarrative.puzzle-onboarding.first-visit.v1',
'1',
);
vi.mocked(fetchCreationEntryConfig).mockResolvedValue(testCreationEntryConfig);
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 0,
totalPlayTimeMs: 0,
@@ -2396,9 +2486,6 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: '方洞挑战' }).querySelector('img')?.src,
).toContain('/creation-type-references/square-hole.webp');
expect(
screen.getByRole('tab', { name: '视觉小说' }).querySelector('img')?.src,
).toContain('/creation-type-references/visual-novel.webp');
@@ -2417,6 +2504,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('tab', { name: //u })).toBeNull();
expect(screen.getByRole('tab', { name: //u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();

View File

@@ -1,8 +1,10 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type {
Match3DGeneratedItemAsset,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
@@ -12,6 +14,7 @@ import type {
SquareHoleShapeOption,
SquareHoleWorkSummary,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
@@ -105,6 +108,7 @@ export type PlatformMatch3DGalleryCard = {
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
generatedItemAssets?: Match3DGeneratedItemAsset[];
};
export type PlatformSquareHoleGalleryCard = {
@@ -251,6 +255,7 @@ export function mapMatch3DWorkToPlatformGalleryCard(
visibility: 'published',
publishedAt: work.publishedAt ?? null,
updatedAt: work.updatedAt,
generatedItemAssets: work.generatedItemAssets ?? [],
};
}

View File

@@ -1,16 +1,16 @@
/* @vitest-environment jsdom */
import type { ComponentProps } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
VisualNovelAgentWorkspace,
} from './VisualNovelAgentWorkspace';
} from './visualNovelEntryGeneration';
function renderWorkspace(
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
@@ -27,8 +27,42 @@ test('visual novel workspace only exposes one-line input and visual style entry'
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
expect(screen.getByText('视觉画风')).toBeTruthy();
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
expect(
screen
.getByRole('button', { name: '映画动画' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/cinematic-anime.png');
expect(
screen
.getByRole('button', { name: '水彩绘本' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/watercolor.png');
expect(
screen
.getByRole('button', { name: '像素霓虹' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/pixel-noir.png');
expect(
screen
.getByRole('button', { name: '水墨幻想' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/ink-fantasy.png');
expect(
screen
.getByRole('button', { name: '柔彩校园' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/soft-pastel.png');
expect(
screen
.getByRole('button', { name: '暗色哥特' })
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/dark-gothic.png');
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
@@ -47,9 +81,7 @@ test('visual novel workspace submits idea and selected visual style as seed text
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
});
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
fireEvent.click(
screen.getByRole('button', { name: /稿/u }),
);
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
sourceMode: 'idea',
@@ -67,16 +99,15 @@ test('visual novel workspace submits idea and selected visual style as seed text
test('visual novel workspace restores idea text from existing session', () => {
renderWorkspace({ session: mockVisualNovelSession });
expect((screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value).toBe(
'想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
);
expect(
(screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value,
).toBe('想做一个雪夜列车和旧电台有关的悬疑视觉小说。');
});
test('visual novel generation helpers build process page data', () => {
const payload = {
sourceMode: 'idea' as const,
seedText:
'雨夜书店\n视觉画风水彩绘本\n画风要求透明水彩与绘本质感。',
seedText: '雨夜书店\n视觉画风水彩绘本\n画风要求透明水彩与绘本质感。',
sourceAssetIds: [],
ideaText: '雨夜书店',
visualStyleId: 'watercolor' as const,

View File

@@ -1,12 +1,8 @@
import { ArrowLeft, Loader2, Sparkles, WandSparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type {
CreateVisualNovelSessionRequest,
VisualNovelAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import type { VisualNovelEntryFormPayload } from './visualNovelEntryGeneration';
type VisualNovelAgentWorkspaceProps = {
session?: VisualNovelAgentSessionSnapshot | null;
@@ -19,19 +15,6 @@ type VisualNovelAgentWorkspaceProps = {
title?: string | null;
};
export type VisualNovelEntryFormPayload = Omit<
CreateVisualNovelSessionRequest,
'seedText' | 'sourceMode' | 'sourceAssetIds'
> & {
sourceMode: 'idea';
seedText: string;
sourceAssetIds: string[];
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
visualStyleLabel: string;
visualStylePrompt: string;
};
type VisualNovelFormState = {
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
@@ -41,31 +24,37 @@ const VISUAL_NOVEL_STYLE_OPTIONS = [
{
id: 'cinematic-anime',
label: '映画动画',
imageSrc: '/visual-novel-style-references/cinematic-anime.png',
prompt: '电影感动画视觉小说画风,光影层次清晰,角色立绘精致,背景有景深。',
},
{
id: 'watercolor',
label: '水彩绘本',
imageSrc: '/visual-novel-style-references/watercolor.png',
prompt: '透明水彩与绘本质感,色彩柔和,边缘带手绘晕染,适合温柔叙事。',
},
{
id: 'pixel-noir',
label: '像素霓虹',
imageSrc: '/visual-novel-style-references/pixel-noir.png',
prompt: '高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
},
{
id: 'ink-fantasy',
label: '水墨幻想',
imageSrc: '/visual-novel-style-references/ink-fantasy.png',
prompt: '东方水墨幻想画风,留白、墨色层次和淡彩点染并重。',
},
{
id: 'soft-pastel',
label: '柔彩校园',
imageSrc: '/visual-novel-style-references/soft-pastel.png',
prompt: '柔和粉彩校园画风,干净明亮,角色表情细腻,日常氛围轻盈。',
},
{
id: 'dark-gothic',
label: '暗色哥特',
imageSrc: '/visual-novel-style-references/dark-gothic.png',
prompt: '暗色哥特视觉小说画风,深色场景、烛光高光和华丽服装细节。',
},
] as const;
@@ -136,11 +125,13 @@ function buildVisualNovelSeedText(
function VisualNovelStyleButton({
active,
disabled,
imageSrc,
label,
onClick,
}: {
active: boolean;
disabled: boolean;
imageSrc: string;
label: string;
onClick: () => void;
}) {
@@ -157,7 +148,16 @@ function VisualNovelStyleButton({
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95'
} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
<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))]" />
{imageSrc ? (
<img
src={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%)]" />
{active ? (
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
@@ -299,6 +299,7 @@ export function VisualNovelAgentWorkspace({
key={option.id}
active={formState.visualStyleId === option.id}
disabled={isBusy}
imageSrc={option.imageSrc}
label={option.label}
onClick={() =>
setFormState((current) => ({
@@ -348,158 +349,4 @@ export function VisualNovelAgentWorkspace({
);
}
export function buildVisualNovelEntryGenerationAnchorEntries(
payload: VisualNovelEntryFormPayload | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!payload) {
return [];
}
return [
{
id: 'visual-novel-idea',
label: '一句话',
value: payload.ideaText,
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: payload.visualStyleLabel,
},
].filter((entry) => entry.value.trim());
}
export function buildVisualNovelEntryGenerationProgress(
startedAtMs: number | null,
phase: 'generating' | 'ready' | 'failed',
nowMs = Date.now(),
): CustomWorldGenerationProgress {
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const timeline: [
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-session',
label: '创建创作会话',
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
weight: 24,
durationMs: 5_000,
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
},
{
id: 'visual-novel-ready',
label: '准备草稿页',
detail: '校验可编辑字段并进入草稿页。',
weight: 20,
durationMs: 4_000,
},
];
let elapsedBeforeStep = 0;
const activeStepIndex =
phase === 'ready'
? timeline.length - 1
: timeline.findIndex((step) => {
const elapsedInStep = elapsedMs - elapsedBeforeStep;
const isActive = elapsedInStep < step.durationMs;
if (!isActive) {
elapsedBeforeStep += step.durationMs;
}
return isActive;
});
const normalizedActiveStepIndex =
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
const activeElapsed =
elapsedMs -
timeline
.slice(0, normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const activeRatio =
phase === 'ready'
? 1
: phase === 'failed'
? 0
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
const completedWeight = timeline
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
phase === 'ready'
? 100
: phase === 'failed'
? Math.max(1, completedWeight)
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
return {
phaseId: phase,
phaseLabel:
phase === 'ready'
? '生成完成'
: phase === 'failed'
? '生成失败'
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
batchLabel: activeStep.label,
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =
phase === 'ready' || index < normalizedActiveStepIndex;
const isActive =
phase !== 'failed' &&
!isCompleted &&
index === normalizedActiveStepIndex;
const status: 'completed' | 'active' | 'pending' = isCompleted
? 'completed'
: isActive
? 'active'
: 'pending';
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
total: 1,
status,
};
}),
};
}
export default VisualNovelAgentWorkspace;

View File

@@ -0,0 +1,178 @@
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type { CreateVisualNovelSessionRequest } from '../../../packages/shared/src/contracts/visualNovel';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
export type VisualNovelEntryFormPayload = Omit<
CreateVisualNovelSessionRequest,
'seedText' | 'sourceMode' | 'sourceAssetIds'
> & {
sourceMode: 'idea';
seedText: string;
sourceAssetIds: string[];
ideaText: string;
visualStyleId: VisualNovelStyleOptionId;
visualStyleLabel: string;
visualStylePrompt: string;
};
type VisualNovelStyleOptionId =
| 'cinematic-anime'
| 'watercolor'
| 'pixel-noir'
| 'ink-fantasy'
| 'soft-pastel'
| 'dark-gothic';
export function buildVisualNovelEntryGenerationAnchorEntries(
payload: VisualNovelEntryFormPayload | null | undefined,
): CustomWorldStructuredAnchorEntry[] {
if (!payload) {
return [];
}
return [
{
id: 'visual-novel-idea',
label: '一句话',
value: payload.ideaText,
},
{
id: 'visual-novel-style',
label: '视觉画风',
value: payload.visualStyleLabel,
},
].filter((entry) => entry.value.trim());
}
export function buildVisualNovelEntryGenerationProgress(
startedAtMs: number | null,
phase: 'generating' | 'ready' | 'failed',
nowMs = Date.now(),
): CustomWorldGenerationProgress {
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
const timeline: [
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
{
id: string;
label: string;
detail: string;
weight: number;
durationMs: number;
},
] = [
{
id: 'visual-novel-session',
label: '创建创作会话',
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
weight: 24,
durationMs: 5_000,
},
{
id: 'visual-novel-draft',
label: '生成故事底稿',
detail: '整理世界观、角色、场景和剧情阶段。',
weight: 56,
durationMs: 22_000,
},
{
id: 'visual-novel-ready',
label: '准备草稿页',
detail: '校验可编辑字段并进入草稿页。',
weight: 20,
durationMs: 4_000,
},
];
let elapsedBeforeStep = 0;
const activeStepIndex =
phase === 'ready'
? timeline.length - 1
: timeline.findIndex((step) => {
const elapsedInStep = elapsedMs - elapsedBeforeStep;
const isActive = elapsedInStep < step.durationMs;
if (!isActive) {
elapsedBeforeStep += step.durationMs;
}
return isActive;
});
const normalizedActiveStepIndex =
activeStepIndex >= 0 ? activeStepIndex : timeline.length - 1;
const activeStep = timeline[normalizedActiveStepIndex] ?? timeline[0];
const activeElapsed =
elapsedMs -
timeline
.slice(0, normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.durationMs, 0);
const activeRatio =
phase === 'ready'
? 1
: phase === 'failed'
? 0
: Math.max(0, Math.min(1, activeElapsed / activeStep.durationMs));
const completedWeight = timeline
.slice(0, phase === 'ready' ? timeline.length : normalizedActiveStepIndex)
.reduce((sum, step) => sum + step.weight, 0);
const overallProgress =
phase === 'ready'
? 100
: phase === 'failed'
? Math.max(1, completedWeight)
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
return {
phaseId: phase,
phaseLabel:
phase === 'ready'
? '生成完成'
: phase === 'failed'
? '生成失败'
: activeStep.label,
phaseDetail:
phase === 'ready'
? '视觉小说草稿已准备完成。'
: phase === 'failed'
? '草稿生成失败,请返回入口页调整后重试。'
: activeStep.detail,
batchLabel: activeStep.label,
overallProgress: Math.max(0, Math.min(100, Math.round(overallProgress))),
completedWeight: Math.max(0, Math.min(100, Math.round(overallProgress))),
totalWeight: 100,
elapsedMs,
estimatedRemainingMs:
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
activeStepIndex: normalizedActiveStepIndex,
steps: timeline.map((step, index) => {
const isCompleted =
phase === 'ready' || index < normalizedActiveStepIndex;
const isActive =
phase !== 'failed' &&
!isCompleted &&
index === normalizedActiveStepIndex;
const status: 'completed' | 'active' | 'pending' = isCompleted
? 'completed'
: isActive
? 'active'
: 'pending';
return {
id: step.id,
label: step.label,
detail: step.detail,
completed: isCompleted ? 1 : isActive ? activeRatio : 0,
total: 1,
status,
};
}),
};
}

View File

@@ -19,6 +19,7 @@ type CreationAgentClientOptions = {
apiBase: string;
messages: CreationAgentClientMessages;
createSessionTimeoutMs?: number;
executeActionTimeoutMs?: number;
readRetry?: ApiRetryOptions;
writeRetry?: ApiRetryOptions;
};
@@ -84,6 +85,7 @@ export function createCreationAgentClient<
apiBase,
messages,
createSessionTimeoutMs = 15000,
executeActionTimeoutMs,
readRetry = DEFAULT_CREATION_AGENT_READ_RETRY,
writeRetry = DEFAULT_CREATION_AGENT_WRITE_RETRY,
}: CreationAgentClientOptions) {
@@ -152,6 +154,7 @@ export function createCreationAgentClient<
messages.executeAction,
{
retry: writeRetry,
timeoutMs: executeActionTimeoutMs,
},
);

View File

@@ -10,6 +10,7 @@ import type { TextStreamOptions } from '../aiTypes';
import { createCreationAgentClient } from '../creation-agent';
const MATCH3D_AGENT_API_BASE = '/api/creation/match3d/sessions';
const MATCH3D_EXECUTE_ACTION_TIMEOUT_MS = 20 * 60 * 1000;
const match3dAgentHttpClient = createCreationAgentClient<
CreateMatch3DSessionRequest,
@@ -29,6 +30,7 @@ const match3dAgentHttpClient = createCreationAgentClient<
streamIncomplete: '抓大鹅共创消息流式结果不完整',
executeAction: '执行抓大鹅共创操作失败',
},
executeActionTimeoutMs: MATCH3D_EXECUTE_ACTION_TIMEOUT_MS,
});
/**

View File

@@ -1,5 +1,6 @@
export {
deleteMatch3DWork,
generateMatch3DWorkTags,
getMatch3DWorkDetail,
listMatch3DGallery,
listMatch3DWorks,

View File

@@ -1,4 +1,6 @@
import type {
GenerateMatch3DWorkTagsRequest,
GenerateMatch3DWorkTagsResponse,
Match3DWorkDetailResponse,
Match3DWorkMutationResponse,
Match3DWorksResponse,
@@ -79,6 +81,22 @@ export function updateMatch3DWork(
);
}
/**
* 根据当前作品名称与题材生成发布标签。
*/
export function generateMatch3DWorkTags(payload: GenerateMatch3DWorkTagsRequest) {
return requestJson<GenerateMatch3DWorkTagsResponse>(
`${MATCH3D_WORKS_API_BASE}/tags`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成抓大鹅作品标签失败',
{ retry: MATCH3D_WORKS_WRITE_RETRY },
);
}
/**
* 发布抓大鹅作品。发布门槛由后端最终确认。
*/
@@ -105,6 +123,7 @@ export function deleteMatch3DWork(profileId: string) {
export const match3dWorksClient = {
delete: deleteMatch3DWork,
generateTags: generateMatch3DWorkTags,
getDetail: getMatch3DWorkDetail,
listGallery: listMatch3DGallery,
list: listMatch3DWorks,

View File

@@ -161,14 +161,29 @@ describe('miniGameDraftGenerationProgress', () => {
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'match3d-work-title',
'match3d-item-names',
'match3d-material-sheet',
'match3d-slice-images',
'match3d-upload-images',
'match3d-generate-models',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('生成素材图');
expect(progress?.estimatedRemainingMs).toBe(103_000);
expect(progress?.estimatedRemainingMs).toBe(583_000);
});
test('match3d draft generation starts from title generation', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 1_000,
);
expect(progress?.phaseId).toBe('match3d-work-title');
expect(progress?.phaseLabel).toBe('生成游戏名称');
expect(progress?.steps[0]?.detail).toBe('根据题材设定生成作品名称与标签。');
});
test('match3d generation anchors show theme and fixed three items', () => {

View File

@@ -30,10 +30,12 @@ export type MiniGameDraftGenerationPhase =
| 'square-hole-cover'
| 'square-hole-shapes'
| 'square-hole-ready'
| 'match3d-work-title'
| 'match3d-item-names'
| 'match3d-material-sheet'
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-generate-models'
| 'match3d-ready'
| 'puzzle-images'
| 'puzzle-select-image'
@@ -140,29 +142,41 @@ const SQUARE_HOLE_STEPS = [
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_STEPS = [
{
id: 'match3d-work-title',
label: '生成游戏名称',
detail: '根据题材设定生成作品名称与标签。',
weight: 8,
},
{
id: 'match3d-item-names',
label: '生成物品名称',
detail: '根据题材生成本局的 3 个物品名称。',
weight: 16,
weight: 8,
},
{
id: 'match3d-material-sheet',
label: '生成素材图',
detail: '生成一张 1:1 的网格素材图。',
weight: 30,
weight: 18,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成独立物品参考图。',
weight: 14,
weight: 8,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '写入切割图片并准备进入草稿页。',
weight: 40,
detail: '写入素材图和独立物品参考图。',
weight: 8,
},
{
id: 'match3d-generate-models',
label: '生成3D模型',
detail: '调用 Hyper3D Rodin 生成 GLB 模型并转存。',
weight: 50,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -234,7 +248,7 @@ export function createMiniGameDraftGenerationState(
: kind === 'square-hole'
? 'square-hole-draft'
: kind === 'match3d'
? 'match3d-item-names'
? 'match3d-work-title'
: 'compile',
startedAtMs: Date.now(),
completedAssetCount: 0,
@@ -270,6 +284,9 @@ function resolveSquareHolePhaseByElapsedMs(
function resolveMatch3DPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 92_000) {
return 'match3d-generate-models';
}
if (elapsedMs >= 72_000) {
return 'match3d-upload-images';
}
@@ -279,7 +296,10 @@ function resolveMatch3DPhaseByElapsedMs(
if (elapsedMs >= 16_000) {
return 'match3d-material-sheet';
}
return 'match3d-item-names';
if (elapsedMs >= 4_000) {
return 'match3d-item-names';
}
return 'match3d-work-title';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
@@ -422,7 +442,7 @@ export function buildMiniGameDraftGenerationProgress(
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: normalizedState.kind === 'match3d'
? Math.max(0, 120_000 - elapsedMs)
? Math.max(0, 10 * 60_000 - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(

View File

@@ -395,6 +395,7 @@ describe('puzzleLocalRuntime', () => {
rank: 1,
nickname: '本地玩家',
elapsedMs: clearedRun.currentLevel?.elapsedMs ?? 0,
visibleTags: [],
isCurrentPlayer: true,
},
]);