1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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('大鱼吃小鱼');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
315
src/components/match3d-result/Match3DModelPreview.tsx
Normal file
315
src/components/match3d-result/Match3DModelPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
deleteMatch3DWork,
|
||||
generateMatch3DWorkTags,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -395,6 +395,7 @@ describe('puzzleLocalRuntime', () => {
|
||||
rank: 1,
|
||||
nickname: '本地玩家',
|
||||
elapsedMs: clearedRun.currentLevel?.elapsedMs ?? 0,
|
||||
visibleTags: [],
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user