Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
@@ -44,6 +45,9 @@ type CustomWorldCreationHubProps = {
|
||||
bigFishItems?: BigFishWorkSummary[];
|
||||
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
|
||||
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
|
||||
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
@@ -130,6 +134,9 @@ export function CustomWorldCreationHub({
|
||||
bigFishItems = [],
|
||||
onOpenBigFishDetail,
|
||||
onDeleteBigFish = null,
|
||||
match3dItems = [],
|
||||
onOpenMatch3DDetail,
|
||||
onDeleteMatch3D = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle = null,
|
||||
@@ -144,15 +151,19 @@ export function CustomWorldCreationHub({
|
||||
rpgItems: items,
|
||||
rpgLibraryEntries,
|
||||
bigFishItems,
|
||||
match3dItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
canDeleteMatch3D: Boolean(onDeleteMatch3D),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
}),
|
||||
[
|
||||
bigFishItems,
|
||||
items,
|
||||
match3dItems,
|
||||
onDeleteBigFish,
|
||||
onDeleteMatch3D,
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
puzzleItems,
|
||||
@@ -187,6 +198,9 @@ export function CustomWorldCreationHub({
|
||||
case 'big-fish':
|
||||
onOpenBigFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'match3d':
|
||||
onOpenMatch3DDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
@@ -217,6 +231,12 @@ export function CustomWorldCreationHub({
|
||||
onDeleteBigFish?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'match3d': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeleteMatch3D?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'rpg': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
|
||||
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'match3d' | 'puzzle';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
|
||||
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
||||
@@ -50,6 +52,10 @@ export type CreationWorkShelfSource =
|
||||
kind: 'big-fish';
|
||||
item: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'match3d';
|
||||
item: Match3DWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
@@ -80,18 +86,22 @@ export function buildCreationWorkShelfItems(params: {
|
||||
rpgItems: CustomWorldWorkSummary[];
|
||||
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
bigFishItems: BigFishWorkSummary[];
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
canDeleteBigFish?: boolean;
|
||||
canDeleteMatch3D?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
}) {
|
||||
const {
|
||||
rpgItems,
|
||||
rpgLibraryEntries = [],
|
||||
bigFishItems,
|
||||
match3dItems = [],
|
||||
puzzleItems,
|
||||
canDeleteRpg = false,
|
||||
canDeleteBigFish = false,
|
||||
canDeleteMatch3D = false,
|
||||
canDeletePuzzle = false,
|
||||
} = params;
|
||||
|
||||
@@ -102,6 +112,9 @@ export function buildCreationWorkShelfItems(params: {
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||
),
|
||||
...match3dItems.map((item) =>
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
|
||||
),
|
||||
@@ -203,6 +216,48 @@ function mapBigFishWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapMatch3DWorkToShelfItem(
|
||||
item: Match3DWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published' ? buildMatch3DPublicWorkCode(item.profileId) : null;
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'match3d',
|
||||
status,
|
||||
title: item.gameName,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '抓鹅', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
source: { kind: 'match3d', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapPuzzleWorkToShelfItem(
|
||||
item: PuzzleWorkSummary,
|
||||
canDelete: boolean,
|
||||
|
||||
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal file
103
src/components/match3d-result/Match3DResultView.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import * as match3dWorksService from '../../services/match3d-works';
|
||||
import { Match3DResultView } from './Match3DResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-works', () => ({
|
||||
publishMatch3DWork: vi.fn(),
|
||||
updateMatch3DWork: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function createProfile(
|
||||
overrides: Partial<Match3DWorkProfile> = {},
|
||||
): Match3DWorkProfile {
|
||||
return {
|
||||
workId: 'match3d-work-1',
|
||||
profileId: 'match3d-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果',
|
||||
summary: '水果主题的经典消除玩法。',
|
||||
tags: ['水果'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 4,
|
||||
difficulty: 3,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-01T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Match3DResultView', () => {
|
||||
test('试玩只要求基础配置可保存,不被发布封面门槛阻断', async () => {
|
||||
const profile = createProfile();
|
||||
const onStartTestRun = vi.fn();
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: profile,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dWorksService.updateMatch3DWork).toHaveBeenCalledWith(
|
||||
'match3d-profile-1',
|
||||
expect.objectContaining({
|
||||
clearCount: 4,
|
||||
difficulty: 3,
|
||||
gameName: '水果抓大鹅',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(profile);
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('发布仍要求封面和标签数量满足门槛', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile()}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const publishButton = screen.getByRole('button', { name: '发布' });
|
||||
expect(publishButton).toHaveProperty('disabled', true);
|
||||
|
||||
fireEvent.click(publishButton);
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
588
src/components/match3d-result/Match3DResultView.tsx
Normal file
588
src/components/match3d-result/Match3DResultView.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Play,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type {
|
||||
Match3DWorkProfile,
|
||||
PutMatch3DWorkRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import {
|
||||
publishMatch3DWork,
|
||||
updateMatch3DWork,
|
||||
} from '../../services/match3d-works';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type Match3DResultViewProps = {
|
||||
profile: Match3DWorkProfile;
|
||||
draft?: Match3DResultDraft | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSaved?: (profile: Match3DWorkProfile) => void;
|
||||
onPublished?: (profile: Match3DWorkProfile) => void;
|
||||
onStartTestRun: (profile: Match3DWorkProfile) => void;
|
||||
};
|
||||
|
||||
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
type Match3DResultEditState = {
|
||||
gameName: string;
|
||||
summary: string;
|
||||
tagsText: string;
|
||||
coverImageSrc: string;
|
||||
themeText: string;
|
||||
clearCountText: string;
|
||||
difficultyText: string;
|
||||
};
|
||||
|
||||
const MATCH3D_MIN_TAG_COUNT = 3;
|
||||
const MATCH3D_MAX_TAG_COUNT = 6;
|
||||
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
|
||||
|
||||
function normalizeTags(value: string) {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(/[\n,,、]/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeDifficulty(value: string) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
function createEditState(profile: Match3DWorkProfile): Match3DResultEditState {
|
||||
return {
|
||||
gameName: profile.gameName,
|
||||
summary: profile.summary,
|
||||
tagsText: profile.tags.join(','),
|
||||
coverImageSrc:
|
||||
profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '',
|
||||
themeText: profile.themeText,
|
||||
clearCountText: String(profile.clearCount),
|
||||
difficultyText: String(profile.difficulty),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSavePayload(
|
||||
editState: Match3DResultEditState,
|
||||
): PutMatch3DWorkRequest | null {
|
||||
const clearCount = normalizePositiveInteger(editState.clearCountText);
|
||||
const difficulty = normalizeDifficulty(editState.difficultyText);
|
||||
const gameName = editState.gameName.trim();
|
||||
const themeText = editState.themeText.trim();
|
||||
const summary = editState.summary.trim();
|
||||
const tags = normalizeTags(editState.tagsText);
|
||||
|
||||
if (!gameName || !themeText || !summary || !clearCount || !difficulty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
gameName,
|
||||
themeText,
|
||||
summary,
|
||||
tags,
|
||||
coverImageSrc: editState.coverImageSrc.trim() || null,
|
||||
clearCount,
|
||||
difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublishBlockers(editState: Match3DResultEditState) {
|
||||
const tags = normalizeTags(editState.tagsText);
|
||||
const blockers = [
|
||||
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
|
||||
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
|
||||
...(editState.summary.trim() ? [] : ['简介不能为空。']),
|
||||
...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']),
|
||||
...(tags.length >= MATCH3D_MIN_TAG_COUNT && tags.length <= MATCH3D_MAX_TAG_COUNT
|
||||
? []
|
||||
: [`标签数量需要在 ${MATCH3D_MIN_TAG_COUNT} 到 ${MATCH3D_MAX_TAG_COUNT} 个之间。`]),
|
||||
...(normalizePositiveInteger(editState.clearCountText)
|
||||
? []
|
||||
: ['需要消除次数必须为正整数。']),
|
||||
...(normalizeDifficulty(editState.difficultyText)
|
||||
? []
|
||||
: ['难度必须为 1 到 10。']),
|
||||
];
|
||||
|
||||
return [...new Set(blockers)];
|
||||
}
|
||||
|
||||
function buildTestRunBlockers(editState: Match3DResultEditState) {
|
||||
const blockers = [
|
||||
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
|
||||
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
|
||||
...(editState.summary.trim() ? [] : ['简介不能为空。']),
|
||||
...(normalizePositiveInteger(editState.clearCountText)
|
||||
? []
|
||||
: ['需要消除次数必须为正整数。']),
|
||||
...(normalizeDifficulty(editState.difficultyText)
|
||||
? []
|
||||
: ['难度必须为 1 到 10。']),
|
||||
];
|
||||
|
||||
return [...new Set(blockers)];
|
||||
}
|
||||
|
||||
function readImageAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
reject(new Error('请选择图片文件。'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('封面图读取失败,请重试。'));
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function buildPlayableProfile(
|
||||
profile: Match3DWorkProfile,
|
||||
editState: Match3DResultEditState,
|
||||
) {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
gameName: payload.gameName,
|
||||
themeText: payload.themeText ?? profile.themeText,
|
||||
summary: payload.summary,
|
||||
tags: payload.tags,
|
||||
coverImageSrc: payload.coverImageSrc,
|
||||
clearCount: payload.clearCount,
|
||||
difficulty: payload.difficulty,
|
||||
};
|
||||
}
|
||||
|
||||
function Match3DResultHeader({
|
||||
autoSaveState,
|
||||
isBusy,
|
||||
onBack,
|
||||
}: {
|
||||
autoSaveState: Match3DAutoSaveState;
|
||||
isBusy: boolean;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const badge =
|
||||
autoSaveState === 'saving' ? (
|
||||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
保存中
|
||||
</div>
|
||||
) : autoSaveState === 'saved' ? (
|
||||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已自动保存
|
||||
</div>
|
||||
) : autoSaveState === 'error' ? (
|
||||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||||
保存失败
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回
|
||||
</span>
|
||||
</button>
|
||||
{badge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DResultView({
|
||||
profile,
|
||||
draft = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSaved,
|
||||
onPublished,
|
||||
onStartTestRun,
|
||||
}: Match3DResultViewProps) {
|
||||
const [editState, setEditState] = useState(() => createEditState(profile));
|
||||
const [autoSaveState, setAutoSaveState] =
|
||||
useState<Match3DAutoSaveState>('idle');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
|
||||
const blockers = useMemo(() => buildPublishBlockers(editState), [editState]);
|
||||
const testRunBlockers = useMemo(
|
||||
() => buildTestRunBlockers(editState),
|
||||
[editState],
|
||||
);
|
||||
const canStartTestRun = testRunBlockers.length === 0;
|
||||
const canSubmit = blockers.length === 0;
|
||||
const totalItemCount =
|
||||
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
|
||||
3;
|
||||
|
||||
useEffect(() => {
|
||||
setEditState(createEditState(profile));
|
||||
setAutoSaveState('idle');
|
||||
setLocalError(null);
|
||||
}, [profile.profileId, profile.updatedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const currentTags = normalizeTags(profile.tags.join(','));
|
||||
const nextTags = payload.tags;
|
||||
const changed =
|
||||
payload.gameName !== profile.gameName ||
|
||||
payload.themeText !== profile.themeText ||
|
||||
payload.summary !== profile.summary ||
|
||||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
|
||||
payload.clearCount !== profile.clearCount ||
|
||||
payload.difficulty !== profile.difficulty ||
|
||||
nextTags.length !== currentTags.length ||
|
||||
nextTags.some((tag, index) => tag !== currentTags[index]);
|
||||
|
||||
if (!changed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setAutoSaveState('saving');
|
||||
setLocalError(null);
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void updateMatch3DWork(profile.profileId, payload)
|
||||
.then(({ item }) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAutoSaveState('saved');
|
||||
onSaved?.(item);
|
||||
})
|
||||
.catch((saveError) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAutoSaveState('error');
|
||||
setLocalError(
|
||||
saveError instanceof Error ? saveError.message : '自动保存失败。',
|
||||
);
|
||||
});
|
||||
}, MATCH3D_AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [editState, onSaved, profile]);
|
||||
|
||||
const saveNow = async () => {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
setLocalError(testRunBlockers[0] ?? '请补全作品信息。');
|
||||
return null;
|
||||
}
|
||||
|
||||
setAutoSaveState('saving');
|
||||
setLocalError(null);
|
||||
const { item } = await updateMatch3DWork(profile.profileId, payload);
|
||||
setAutoSaveState('saved');
|
||||
onSaved?.(item);
|
||||
return item;
|
||||
};
|
||||
|
||||
const handleCoverImageChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.target.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await readImageAsDataUrl(file);
|
||||
setEditState((current) => ({
|
||||
...current,
|
||||
coverImageSrc: dataUrl,
|
||||
}));
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTestRun = async () => {
|
||||
if (!canStartTestRun || isStartingTestRun) {
|
||||
setLocalError(testRunBlockers[0] ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingTestRun(true);
|
||||
try {
|
||||
const savedProfile = await saveNow();
|
||||
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '启动试玩前保存失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsStartingTestRun(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!canSubmit || isPublishing) {
|
||||
setLocalError(blockers[0] ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const savedProfile = await saveNow();
|
||||
const { item } = await publishMatch3DWork(
|
||||
savedProfile?.profileId ?? profile.profileId,
|
||||
);
|
||||
onPublished?.(item);
|
||||
setLocalError(null);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '发布抓大鹅作品失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = isBusy || isPublishing || isStartingTestRun;
|
||||
const displayError = error ?? localError;
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
|
||||
<Match3DResultHeader
|
||||
autoSaveState={autoSaveState}
|
||||
isBusy={busy}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(17rem,0.72fr)_minmax(0,1fr)]">
|
||||
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="aspect-[4/3] overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))]">
|
||||
{editState.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={editState.coverImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-emerald-700">
|
||||
<ImagePlus className="h-10 w-10" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="platform-button platform-button--ghost mt-3 flex min-h-10 cursor-pointer items-center justify-center gap-2 px-3 py-2 text-sm">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
封面图
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
disabled={busy}
|
||||
onChange={handleCoverImageChange}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs font-bold text-[var(--platform-text-base)]">
|
||||
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
|
||||
{totalItemCount} 件
|
||||
</div>
|
||||
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
|
||||
{editState.clearCountText || '-'} 组
|
||||
</div>
|
||||
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
|
||||
难度 {editState.difficultyText || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
游戏名称
|
||||
</span>
|
||||
<input
|
||||
value={editState.gameName}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setEditState({ ...editState, gameName: event.target.value })
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
标签
|
||||
</span>
|
||||
<input
|
||||
value={editState.tagsText}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setEditState({ ...editState, tagsText: event.target.value })
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
简介
|
||||
</span>
|
||||
<textarea
|
||||
value={editState.summary}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setEditState({ ...editState, summary: event.target.value })
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
题材主题
|
||||
</span>
|
||||
<input
|
||||
value={editState.themeText}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setEditState({ ...editState, themeText: event.target.value })
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
需要消除次数
|
||||
</span>
|
||||
<input
|
||||
value={editState.clearCountText}
|
||||
inputMode="numeric"
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setEditState({
|
||||
...editState,
|
||||
clearCountText: event.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
难度
|
||||
</span>
|
||||
<input
|
||||
value={editState.difficultyText}
|
||||
inputMode="numeric"
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setEditState({
|
||||
...editState,
|
||||
difficultyText: event.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft?.referenceImageSrc || profile.referenceImageSrc ? (
|
||||
<div className="mt-4 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2">
|
||||
<ResolvedAssetImage
|
||||
src={draft?.referenceImageSrc ?? profile.referenceImageSrc ?? ''}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-24 w-full rounded-[0.8rem] object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{displayError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartTestRun}
|
||||
disabled={!canStartTestRun || busy}
|
||||
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isStartingTestRun ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublish}
|
||||
disabled={!canSubmit || busy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : profile.publicationStatus === 'published' ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DResultView;
|
||||
1
src/components/match3d-result/index.ts
Normal file
1
src/components/match3d-result/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Match3DResultView } from './Match3DResultView';
|
||||
@@ -70,6 +70,22 @@ function buildClientEventId(itemInstanceId: string) {
|
||||
)}`;
|
||||
}
|
||||
|
||||
function isRunState(
|
||||
status: Match3DRunSnapshot['status'],
|
||||
expected: 'running' | 'won' | 'failed' | 'stopped',
|
||||
) {
|
||||
return String(status).toLowerCase() === expected;
|
||||
}
|
||||
|
||||
function isItemState(
|
||||
state: Match3DItemSnapshot['state'],
|
||||
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
|
||||
) {
|
||||
return String(state)
|
||||
.replace(/([a-z])([A-Z])/gu, '$1_$2')
|
||||
.toLowerCase() === expected;
|
||||
}
|
||||
|
||||
function isPointInsideCircle(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
@@ -86,7 +102,7 @@ function findHitItem(
|
||||
return run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.state === 'InBoard' &&
|
||||
isItemState(item.state, 'in_board') &&
|
||||
item.clickable &&
|
||||
isPointInsideCircle(pointX, pointY, item),
|
||||
)
|
||||
@@ -137,13 +153,13 @@ function Match3DToken({
|
||||
const visualSeed = resolveVisualSeed(item.visualKey);
|
||||
const size = `${item.radius * 200}%`;
|
||||
const itemStateClass =
|
||||
item.state === 'Flying'
|
||||
isItemState(item.state, 'flying')
|
||||
? 'scale-75 opacity-0'
|
||||
: item.clickable
|
||||
? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95'
|
||||
: 'opacity-48';
|
||||
|
||||
if (item.state !== 'InBoard' && item.state !== 'Flying') {
|
||||
if (!isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -160,7 +176,7 @@ function Match3DToken({
|
||||
}}
|
||||
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
||||
data-testid={`match3d-item-${item.itemInstanceId}`}
|
||||
disabled={disabled || !item.clickable || item.state !== 'InBoard'}
|
||||
disabled={disabled || !item.clickable || !isItemState(item.state, 'in_board')}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<span className="relative z-10">{visualSeed.label}</span>
|
||||
@@ -193,11 +209,11 @@ function Match3DSettlement({
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
if (run.status === 'Running') {
|
||||
if (isRunState(run.status, 'running')) {
|
||||
return null;
|
||||
}
|
||||
const won = run.status === 'Won';
|
||||
const stopped = run.status === 'Stopped';
|
||||
const won = isRunState(run.status, 'won');
|
||||
const stopped = isRunState(run.status, 'stopped');
|
||||
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
|
||||
const description = won
|
||||
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
|
||||
@@ -265,7 +281,7 @@ export function Match3DRuntimeShell({
|
||||
}, [run?.remainingMs, run?.snapshotVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || run.status !== 'Running') {
|
||||
if (!run || !isRunState(run.status, 'running')) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -296,7 +312,7 @@ export function Match3DRuntimeShell({
|
||||
}, [run]);
|
||||
|
||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||
if (!run || run.status !== 'Running' || pendingClick) {
|
||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||
return;
|
||||
}
|
||||
const optimisticRun = buildOptimisticRun(run, item);
|
||||
@@ -337,7 +353,7 @@ export function Match3DRuntimeShell({
|
||||
};
|
||||
|
||||
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!run || run.status !== 'Running' || pendingClick) {
|
||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||
return;
|
||||
}
|
||||
const rect = stageRef.current?.getBoundingClientRect();
|
||||
|
||||
@@ -27,6 +27,11 @@ import type {
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DWorkProfile,
|
||||
Match3DWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentOperationRecord,
|
||||
@@ -85,6 +90,19 @@ import {
|
||||
shouldRestoreCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
} from '../../services/match3d-works';
|
||||
import {
|
||||
buildBigFishGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
@@ -95,8 +113,10 @@ import {
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
isSameBigFishPublicWorkCode,
|
||||
isSameMatch3DPublicWorkCode,
|
||||
isSamePuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import {
|
||||
@@ -153,8 +173,10 @@ import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
isBigFishGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
mapMatch3DWorkToPlatformGalleryCard,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
@@ -239,7 +261,9 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: 'rpg';
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
@@ -282,12 +306,76 @@ function mapPuzzleWorkToPublicWorkDetail(
|
||||
return mapPuzzleWorkToPlatformGalleryCard(item);
|
||||
}
|
||||
|
||||
function mapMatch3DWorkToPublicWorkDetail(
|
||||
item: Match3DWorkSummary,
|
||||
): PlatformPublicGalleryCard {
|
||||
return mapMatch3DWorkToPlatformGalleryCard(item);
|
||||
}
|
||||
|
||||
function mapBigFishWorkToPublicWorkDetail(
|
||||
item: BigFishWorkSummary,
|
||||
): PlatformPublicGalleryCard {
|
||||
return mapBigFishWorkToPlatformGalleryCard(item);
|
||||
}
|
||||
|
||||
function mapPublicWorkDetailToMatch3DWork(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): Match3DWorkSummary | null {
|
||||
if (!isMatch3DGalleryEntry(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
workId: entry.workId,
|
||||
profileId: entry.profileId,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
sourceSessionId: null,
|
||||
gameName: entry.worldName,
|
||||
themeText: entry.themeTags[0] ?? '经典消除',
|
||||
summary: entry.summaryText,
|
||||
tags: entry.themeTags,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
publicationStatus: 'published',
|
||||
playCount: entry.playCount ?? 0,
|
||||
updatedAt: entry.updatedAt,
|
||||
publishedAt: entry.publishedAt,
|
||||
publishReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DProfileFromSession(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
): Match3DWorkProfile | null {
|
||||
const draft = session?.draft;
|
||||
if (!session || !draft?.profileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
return {
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
sourceSessionId: session.sessionId,
|
||||
gameName: draft.gameName,
|
||||
themeText: draft.themeText,
|
||||
summary: draft.summary ?? draft.summaryText ?? '',
|
||||
tags: draft.tags,
|
||||
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
|
||||
referenceImageSrc: draft.referenceImageSrc ?? null,
|
||||
clearCount: draft.clearCount,
|
||||
difficulty: draft.difficulty,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
publishReady: Boolean(draft.publishReady),
|
||||
};
|
||||
}
|
||||
|
||||
function mapPublicWorkDetailToPuzzleWork(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): PuzzleWorkSummary | null {
|
||||
@@ -686,10 +774,17 @@ const Match3DAgentWorkspace = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DDraftReadyView = lazy(async () => {
|
||||
const module = await import('../match3d-creation/Match3DDraftReadyView');
|
||||
const Match3DResultView = lazy(async () => {
|
||||
const module = await import('../match3d-result/Match3DResultView');
|
||||
return {
|
||||
default: module.Match3DDraftReadyView,
|
||||
default: module.Match3DResultView,
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DRuntimeShell = lazy(async () => {
|
||||
const module = await import('../match3d-runtime/Match3DRuntimeShell');
|
||||
return {
|
||||
default: module.Match3DRuntimeShell,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -823,6 +918,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
|
||||
BigFishWorkSummary[]
|
||||
>([]);
|
||||
const [match3dWorks, setMatch3DWorks] = useState<Match3DWorkSummary[]>([]);
|
||||
const [match3dGalleryEntries, setMatch3DGalleryEntries] = useState<
|
||||
Match3DWorkSummary[]
|
||||
>([]);
|
||||
const [match3dProfile, setMatch3DProfile] =
|
||||
useState<Match3DWorkProfile | null>(null);
|
||||
const [match3dRun, setMatch3DRun] = useState<Match3DRunSnapshot | null>(null);
|
||||
const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] =
|
||||
useState<'match3d-result' | 'work-detail'>('match3d-result');
|
||||
const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false);
|
||||
const [bigFishRun, setBigFishRun] =
|
||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
|
||||
@@ -955,6 +1060,34 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, [resolveBigFishErrorMessage]);
|
||||
|
||||
const refreshMatch3DShelf = useCallback(async () => {
|
||||
setIsMatch3DLoadingLibrary(true);
|
||||
|
||||
try {
|
||||
const worksResponse = await listMatch3DWorks();
|
||||
setMatch3DWorks(worksResponse.items);
|
||||
setMatch3DError(null);
|
||||
} catch (error) {
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(error, '读取抓大鹅作品列表失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsMatch3DLoadingLibrary(false);
|
||||
}
|
||||
}, [resolveMatch3DErrorMessage]);
|
||||
|
||||
const refreshMatch3DGallery = useCallback(async () => {
|
||||
try {
|
||||
const galleryResponse = await listMatch3DGallery();
|
||||
setMatch3DGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setMatch3DGalleryEntries([]);
|
||||
setMatch3DError(resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'));
|
||||
return [];
|
||||
}
|
||||
}, [resolveMatch3DErrorMessage]);
|
||||
|
||||
const refreshPuzzleShelf = useCallback(async () => {
|
||||
setIsPuzzleLoadingLibrary(true);
|
||||
|
||||
@@ -1136,16 +1269,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
const bigFishPublicEntries = isBigFishCreationVisible
|
||||
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const match3dPublicEntries = match3dGalleryEntries.map(
|
||||
mapMatch3DWorkToPlatformGalleryCard,
|
||||
);
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
[...bigFishPublicEntries, ...puzzlePublicEntries],
|
||||
[...bigFishPublicEntries, ...match3dPublicEntries, ...puzzlePublicEntries],
|
||||
).slice(0, 6);
|
||||
}, [
|
||||
isBigFishCreationVisible,
|
||||
bigFishGalleryEntries,
|
||||
match3dGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
]);
|
||||
@@ -1157,12 +1294,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
...(isBigFishCreationVisible
|
||||
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: []),
|
||||
...match3dGalleryEntries.map(mapMatch3DWorkToPlatformGalleryCard),
|
||||
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
],
|
||||
),
|
||||
[
|
||||
isBigFishCreationVisible,
|
||||
bigFishGalleryEntries,
|
||||
match3dGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
],
|
||||
@@ -1336,8 +1475,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
onSessionOpened: () => {
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
onActionComplete: ({ response, setSession }) => {
|
||||
onActionComplete: async ({ payload, response, setSession }) => {
|
||||
setSession(response.session);
|
||||
if (payload.action !== 'match3d_compile_draft') {
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId = response.session.draft?.profileId;
|
||||
if (!profileId) {
|
||||
setMatch3DProfile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
setMatch3DProfile(item);
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
} catch {
|
||||
setMatch3DProfile(buildMatch3DProfileFromSession(response.session));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1495,6 +1651,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const openMatch3DAgentWorkspace = useCallback(async () => {
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
@@ -1503,6 +1661,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
match3dFlow,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DProfile,
|
||||
setMatch3DRun,
|
||||
setMatch3DSession,
|
||||
setStreamingMatch3DReplyText,
|
||||
]);
|
||||
@@ -1595,6 +1755,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishGenerationState(null);
|
||||
setBigFishError(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DProfile(null);
|
||||
setMatch3DWorks([]);
|
||||
setMatch3DGalleryEntries([]);
|
||||
setMatch3DRun(null);
|
||||
setMatch3DRuntimeReturnStage('match3d-result');
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
@@ -1689,6 +1854,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const leaveMatch3DFlow = useCallback(() => {
|
||||
setMatch3DRun(null);
|
||||
setMatch3DRuntimeReturnStage('match3d-result');
|
||||
match3dFlow.leaveFlow();
|
||||
}, [match3dFlow]);
|
||||
|
||||
@@ -1758,7 +1925,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
match3dSession ? 'match3d-agent-workspace' : 'platform',
|
||||
);
|
||||
}
|
||||
}, [match3dSession, selectionStage, setSelectionStage]);
|
||||
if (selectionStage === 'match3d-runtime' && !match3dRun) {
|
||||
setSelectionStage(match3dSession?.draft ? 'match3d-result' : 'platform');
|
||||
}
|
||||
}, [match3dRun, match3dSession, selectionStage, setSelectionStage]);
|
||||
|
||||
const startBigFishRun = useCallback(() => {
|
||||
if (!bigFishSession) {
|
||||
@@ -1875,6 +2045,54 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const startMatch3DRunFromProfile = useCallback(
|
||||
async (
|
||||
profile: Match3DWorkProfile | Match3DWorkSummary,
|
||||
returnStage: 'match3d-result' | 'work-detail' = 'match3d-result',
|
||||
mirrorErrorToPublicDetail = false,
|
||||
) => {
|
||||
if (isMatch3DBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
match3dFlow.setIsBusy(true);
|
||||
setMatch3DError(null);
|
||||
|
||||
try {
|
||||
const { run } = await startMatch3DRun(profile.profileId);
|
||||
setMatch3DRun(run);
|
||||
setMatch3DRuntimeReturnStage(returnStage);
|
||||
setSelectionStage('match3d-runtime');
|
||||
if (profile.publicationStatus === 'published') {
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath(
|
||||
'work-detail',
|
||||
buildMatch3DPublicWorkCode(profile.profileId),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = resolveMatch3DErrorMessage(
|
||||
error,
|
||||
'启动抓大鹅玩法失败。',
|
||||
);
|
||||
setMatch3DError(message);
|
||||
if (mirrorErrorToPublicDetail) {
|
||||
setPublicWorkDetailError(message);
|
||||
}
|
||||
} finally {
|
||||
match3dFlow.setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
isMatch3DBusy,
|
||||
match3dFlow,
|
||||
resolveMatch3DErrorMessage,
|
||||
setMatch3DError,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const buildPuzzleTestWork = useCallback(
|
||||
(draft: PuzzleResultDraft) => {
|
||||
const profileId =
|
||||
@@ -2595,6 +2813,47 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteMatch3DWork = useCallback(
|
||||
(work: Match3DWorkSummary) => {
|
||||
if (deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
const confirmed = window.confirm(
|
||||
`确认删除作品《${work.gameName}》吗?删除后会从你的作品列表中移除。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCreationWorkId(work.workId);
|
||||
setMatch3DError(null);
|
||||
|
||||
void deleteMatch3DWork(work.profileId)
|
||||
.then((response) => {
|
||||
setMatch3DWorks(response.items);
|
||||
void refreshMatch3DGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
deletingCreationWorkId,
|
||||
refreshMatch3DGallery,
|
||||
resolveMatch3DErrorMessage,
|
||||
runProtectedAction,
|
||||
setMatch3DError,
|
||||
],
|
||||
);
|
||||
|
||||
const clearSelectedPublicWorkAuthor = useCallback(() => {
|
||||
publicWorkAuthorRequestKeyRef.current += 1;
|
||||
setSelectedPublicWorkAuthor(null);
|
||||
@@ -2911,6 +3170,45 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const openMatch3DPublicWorkDetail = useCallback(
|
||||
async (profileId: string) => {
|
||||
setIsPublicWorkDetailBusy(true);
|
||||
setMatch3DError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setSelectionStage('work-detail');
|
||||
|
||||
try {
|
||||
const entries =
|
||||
match3dGalleryEntries.length > 0
|
||||
? match3dGalleryEntries
|
||||
: await refreshMatch3DGallery();
|
||||
const matchedEntry = entries.find(
|
||||
(entry) => entry.profileId === profileId,
|
||||
);
|
||||
|
||||
if (!matchedEntry) {
|
||||
throw new Error('未找到抓大鹅作品。');
|
||||
}
|
||||
|
||||
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(matchedEntry));
|
||||
} catch (error) {
|
||||
setPublicWorkDetailError(
|
||||
resolveMatch3DErrorMessage(error, '读取抓大鹅详情失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsPublicWorkDetailBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
match3dGalleryEntries,
|
||||
openPublicWorkDetail,
|
||||
refreshMatch3DGallery,
|
||||
resolveMatch3DErrorMessage,
|
||||
setMatch3DError,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const openPuzzleDetail = useCallback(
|
||||
async (
|
||||
profileId: string,
|
||||
@@ -2986,6 +3284,47 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const openMatch3DDraft = useCallback(
|
||||
async (item: Match3DWorkSummary) => {
|
||||
setMatch3DRun(null);
|
||||
setMatch3DError(null);
|
||||
setMatch3DProfile(null);
|
||||
|
||||
if (item.publicationStatus === 'published') {
|
||||
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.sourceSessionId?.trim()) {
|
||||
setMatch3DError('这份抓大鹅草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
}
|
||||
|
||||
const restoredSession = await match3dFlow.restoreDraft(item.sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { item: profile } = await getMatch3DWorkDetail(item.profileId);
|
||||
setMatch3DProfile(profile);
|
||||
} catch (error) {
|
||||
setMatch3DProfile(buildMatch3DProfileFromSession(restoredSession));
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(error, '读取抓大鹅作品详情失败。'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
match3dFlow,
|
||||
openPublicWorkDetail,
|
||||
refreshMatch3DShelf,
|
||||
resolveMatch3DErrorMessage,
|
||||
setMatch3DError,
|
||||
],
|
||||
);
|
||||
|
||||
const startBigFishRunFromWork = useCallback(
|
||||
(
|
||||
item: BigFishWorkSummary,
|
||||
@@ -3053,6 +3392,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) {
|
||||
const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail);
|
||||
if (!work) {
|
||||
setPublicWorkDetailError('当前抓大鹅作品信息不完整,暂时无法进入玩法。');
|
||||
return;
|
||||
}
|
||||
setPublicWorkDetailError(null);
|
||||
void startMatch3DRunFromProfile(work, 'work-detail', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const launchEntry =
|
||||
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
|
||||
? selectedDetailEntry
|
||||
@@ -3090,6 +3440,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectedDetailEntry,
|
||||
selectedPublicWorkDetail,
|
||||
startBigFishRunFromWork,
|
||||
startMatch3DRunFromProfile,
|
||||
startPuzzleRunFromProfile,
|
||||
]);
|
||||
|
||||
@@ -3140,6 +3491,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
setPublicWorkDetailError('抓大鹅作品改造将在后续版本开放。');
|
||||
setIsPublicWorkDetailBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
|
||||
.then((response) => {
|
||||
const nextEntry = response.entry;
|
||||
@@ -3199,10 +3556,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
normalizedKeyword,
|
||||
);
|
||||
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
|
||||
const shouldSearchMatch3DFirst = upperKeyword.startsWith('M3');
|
||||
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
|
||||
const shouldSearchWorkFirst =
|
||||
!shouldSearchUserIdFirst &&
|
||||
!shouldSearchBigFishFirst &&
|
||||
!shouldSearchMatch3DFirst &&
|
||||
!shouldSearchPuzzleFirst &&
|
||||
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
|
||||
const shouldSearchUserFirst =
|
||||
@@ -3210,6 +3569,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
upperKeyword.startsWith('SY') ||
|
||||
(!shouldSearchWorkFirst &&
|
||||
!shouldSearchBigFishFirst &&
|
||||
!shouldSearchMatch3DFirst &&
|
||||
!shouldSearchPuzzleFirst);
|
||||
|
||||
const tryOpenGalleryEntry = async () => {
|
||||
@@ -3270,6 +3630,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry));
|
||||
};
|
||||
const tryOpenMatch3DGalleryEntry = async () => {
|
||||
const entries =
|
||||
match3dGalleryEntries.length > 0
|
||||
? match3dGalleryEntries
|
||||
: await refreshMatch3DGallery();
|
||||
const matchedEntry = entries.find((entry) =>
|
||||
isSameMatch3DPublicWorkCode(normalizedKeyword, entry.profileId),
|
||||
);
|
||||
|
||||
if (!matchedEntry) {
|
||||
throw new Error('未找到抓大鹅作品。');
|
||||
}
|
||||
|
||||
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(matchedEntry));
|
||||
};
|
||||
|
||||
try {
|
||||
if (shouldSearchUserIdFirst) {
|
||||
@@ -3288,6 +3663,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchMatch3DFirst) {
|
||||
await tryOpenMatch3DGalleryEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSearchWorkFirst) {
|
||||
try {
|
||||
await tryOpenGalleryEntry();
|
||||
@@ -3324,6 +3704,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
bigFishGalleryEntries,
|
||||
match3dGalleryEntries,
|
||||
refreshMatch3DGallery,
|
||||
openPuzzlePublicWorkDetail,
|
||||
openPublicWorkDetail,
|
||||
platformBootstrap.platformTab,
|
||||
@@ -3365,6 +3747,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'match3d' ||
|
||||
worldType === 'match_3d' ||
|
||||
work.worldKey.startsWith('match3d:')
|
||||
) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^match3d:/u, '');
|
||||
if (profileId) {
|
||||
void openMatch3DPublicWorkDetail(profileId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
@@ -3442,6 +3837,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
},
|
||||
[
|
||||
openMatch3DPublicWorkDetail,
|
||||
openPuzzlePublicWorkDetail,
|
||||
openPublicWorkDetail,
|
||||
openRpgPublicWorkDetail,
|
||||
@@ -3481,11 +3877,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (isBigFishCreationVisible) {
|
||||
void refreshBigFishGallery();
|
||||
}
|
||||
void refreshMatch3DGallery();
|
||||
void refreshPuzzleGallery();
|
||||
}
|
||||
}, [
|
||||
isBigFishCreationVisible,
|
||||
refreshBigFishGallery,
|
||||
refreshMatch3DGallery,
|
||||
refreshPuzzleGallery,
|
||||
selectionStage,
|
||||
]);
|
||||
@@ -3497,10 +3895,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
platformBootstrap.canReadProtectedData
|
||||
) {
|
||||
void refreshPuzzleShelf();
|
||||
void refreshMatch3DShelf();
|
||||
}
|
||||
}, [
|
||||
platformBootstrap.canReadProtectedData,
|
||||
platformBootstrap.platformTab,
|
||||
refreshMatch3DShelf,
|
||||
refreshPuzzleShelf,
|
||||
selectionStage,
|
||||
]);
|
||||
@@ -3529,11 +3929,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
loading={
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isMatch3DLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
}
|
||||
error={
|
||||
platformBootstrap.isLoadingPlatform ||
|
||||
isBigFishLoadingLibrary ||
|
||||
isMatch3DLoadingLibrary ||
|
||||
isPuzzleLoadingLibrary
|
||||
? null
|
||||
: (platformBootstrap.platformError ??
|
||||
@@ -3555,6 +3957,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (isBigFishCreationVisible) {
|
||||
void refreshBigFishShelf();
|
||||
}
|
||||
void refreshMatch3DShelf();
|
||||
void refreshPuzzleShelf();
|
||||
}}
|
||||
createError={
|
||||
@@ -3608,6 +4011,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
: null
|
||||
}
|
||||
match3dItems={match3dWorks}
|
||||
onOpenMatch3DDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DDraft(item);
|
||||
});
|
||||
}}
|
||||
onDeleteMatch3D={(item) => {
|
||||
handleDeleteMatch3DWork(item);
|
||||
}}
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
@@ -3689,6 +4101,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
openPublicWorkDetail(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
void openRpgPublicWorkDetail(entry);
|
||||
}}
|
||||
onOpenLibraryDetail={(entry) => {
|
||||
@@ -4081,13 +4498,106 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅结果..." />}
|
||||
>
|
||||
<Match3DDraftReadyView
|
||||
session={match3dSession}
|
||||
<Match3DResultView
|
||||
profile={
|
||||
match3dProfile ?? buildMatch3DProfileFromSession(match3dSession)!
|
||||
}
|
||||
draft={match3dSession.draft}
|
||||
isBusy={isMatch3DBusy}
|
||||
error={match3dError}
|
||||
onBack={() => {
|
||||
setSelectionStage('match3d-agent-workspace');
|
||||
}}
|
||||
onSaved={(profile) => {
|
||||
setMatch3DProfile(profile);
|
||||
}}
|
||||
onPublished={(profile) => {
|
||||
setMatch3DProfile(profile);
|
||||
void Promise.allSettled([
|
||||
refreshMatch3DShelf(),
|
||||
refreshMatch3DGallery(),
|
||||
]);
|
||||
openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(profile));
|
||||
}}
|
||||
onStartTestRun={(profile) => {
|
||||
setMatch3DProfile(profile);
|
||||
void startMatch3DRunFromProfile(profile, 'match3d-result');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-runtime' && (
|
||||
<motion.div
|
||||
key="match3d-runtime"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100]"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅玩法..." />}
|
||||
>
|
||||
<Match3DRuntimeShell
|
||||
run={match3dRun}
|
||||
isBusy={isMatch3DBusy}
|
||||
error={match3dError}
|
||||
onBack={() => {
|
||||
if (match3dRun?.runId && match3dRun.status === 'running') {
|
||||
void stopMatch3DRun(match3dRun.runId).catch(() => undefined);
|
||||
}
|
||||
setSelectionStage(match3dRuntimeReturnStage);
|
||||
}}
|
||||
onRestart={() => {
|
||||
if (!match3dRun?.runId || isMatch3DBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
match3dFlow.setIsBusy(true);
|
||||
setMatch3DError(null);
|
||||
void restartMatch3DRun(match3dRun.runId)
|
||||
.then(({ run }) => {
|
||||
setMatch3DRun(run);
|
||||
})
|
||||
.catch((error) => {
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(
|
||||
error,
|
||||
'重新开始抓大鹅玩法失败。',
|
||||
),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
match3dFlow.setIsBusy(false);
|
||||
});
|
||||
}}
|
||||
onOptimisticRunChange={setMatch3DRun}
|
||||
onClickItem={(payload) => {
|
||||
const runId = payload.runId ?? match3dRun?.runId;
|
||||
if (!runId) {
|
||||
return Promise.reject(new Error('抓大鹅运行态缺少 runId。'));
|
||||
}
|
||||
return clickMatch3DItem(runId, payload);
|
||||
}}
|
||||
onTimeExpired={() => {
|
||||
if (!match3dRun?.runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void finishMatch3DTimeUp(match3dRun.runId)
|
||||
.then(({ run }) => {
|
||||
setMatch3DRun(run);
|
||||
})
|
||||
.catch((error) => {
|
||||
setMatch3DError(
|
||||
resolveMatch3DErrorMessage(
|
||||
error,
|
||||
'同步抓大鹅倒计时失败。',
|
||||
),
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
@@ -53,6 +53,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
||||
return '大鱼吃小鱼';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'match3d') {
|
||||
return '抓大鹅';
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export type SelectionStage =
|
||||
| 'big-fish-runtime'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'match3d-result'
|
||||
| 'match3d-runtime'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-result'
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
@@ -30,6 +32,20 @@ import {
|
||||
startLocalBigFishRuntimeRun,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import { listBigFishWorks } from '../../services/big-fish-works';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
clickMatch3DItem,
|
||||
finishMatch3DTimeUp,
|
||||
restartMatch3DRun,
|
||||
startMatch3DRun,
|
||||
stopMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
deleteMatch3DWork,
|
||||
getMatch3DWorkDetail,
|
||||
listMatch3DGallery,
|
||||
listMatch3DWorks,
|
||||
} from '../../services/match3d-works';
|
||||
import {
|
||||
createPuzzleAgentSession,
|
||||
getPuzzleAgentSession,
|
||||
@@ -242,10 +258,34 @@ vi.mock('../../services/big-fish-gallery', () => ({
|
||||
|
||||
vi.mock('../../services/big-fish-runtime', () => ({
|
||||
advanceLocalBigFishRuntimeRun: vi.fn((run) => run),
|
||||
recordBigFishPlay: vi.fn(),
|
||||
recordBigFishPlay: vi.fn(() => Promise.resolve()),
|
||||
startLocalBigFishRuntimeRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-creation', () => ({
|
||||
match3dCreationClient: {
|
||||
createSession: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
streamMessage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-works', () => ({
|
||||
deleteMatch3DWork: vi.fn(),
|
||||
getMatch3DWorkDetail: vi.fn(),
|
||||
listMatch3DGallery: vi.fn(),
|
||||
listMatch3DWorks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/match3d-runtime', () => ({
|
||||
clickMatch3DItem: vi.fn(),
|
||||
finishMatch3DTimeUp: vi.fn(),
|
||||
restartMatch3DRun: vi.fn(),
|
||||
startMatch3DRun: vi.fn(),
|
||||
stopMatch3DRun: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/puzzle-runtime/puzzleLocalRuntime')
|
||||
@@ -399,6 +439,23 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
|
||||
Match3DRuntimeShell: ({
|
||||
run,
|
||||
onBack,
|
||||
}: {
|
||||
run: Match3DRunSnapshot | null;
|
||||
onBack: () => void;
|
||||
}) => (
|
||||
<div className="match3d-runtime-shell-mock">
|
||||
<div>抓大鹅运行态:{run?.runId ?? 'missing-run'}</div>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
|
||||
CustomWorldAgentWorkspace: ({
|
||||
session,
|
||||
@@ -599,6 +656,26 @@ function buildClearedPuzzleRun(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot {
|
||||
return {
|
||||
runId: `match3d-run-${profileId}`,
|
||||
profileId,
|
||||
ownerUserId: 'user-2',
|
||||
status: 'running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1_000,
|
||||
durationLimitMs: 600_000,
|
||||
serverNowMs: 1_000,
|
||||
remainingMs: 600_000,
|
||||
clearCount: 4,
|
||||
totalItemCount: 12,
|
||||
clearedItemCount: 0,
|
||||
items: [],
|
||||
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMockRpgGalleryDetail(
|
||||
entry: CustomWorldGalleryCard,
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
@@ -1056,6 +1133,7 @@ beforeEach(() => {
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
|
||||
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
|
||||
async (ownerUserId, profileId) => ({
|
||||
ownerUserId,
|
||||
@@ -1500,8 +1578,43 @@ beforeEach(() => {
|
||||
vi.mocked(listBigFishGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(recordBigFishPlay).mockResolvedValue({
|
||||
session: {} as never,
|
||||
vi.mocked(recordBigFishPlay).mockResolvedValue(undefined);
|
||||
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
|
||||
session: null,
|
||||
});
|
||||
vi.mocked(match3dCreationClient.getSession).mockResolvedValue({
|
||||
session: null,
|
||||
});
|
||||
vi.mocked(match3dCreationClient.streamMessage).mockResolvedValue(null);
|
||||
vi.mocked(match3dCreationClient.executeAction).mockResolvedValue({
|
||||
session: null,
|
||||
});
|
||||
vi.mocked(listMatch3DWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(getMatch3DWorkDetail).mockRejectedValue(
|
||||
new Error('未找到抓大鹅作品'),
|
||||
);
|
||||
vi.mocked(deleteMatch3DWork).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockRejectedValue(
|
||||
new Error('未启动抓大鹅运行态'),
|
||||
);
|
||||
vi.mocked(clickMatch3DItem).mockRejectedValue(
|
||||
new Error('未执行抓大鹅点击'),
|
||||
);
|
||||
vi.mocked(restartMatch3DRun).mockRejectedValue(
|
||||
new Error('未重新开始抓大鹅运行态'),
|
||||
);
|
||||
vi.mocked(finishMatch3DTimeUp).mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-time-up'),
|
||||
});
|
||||
vi.mocked(stopMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun('match3d-profile-stopped'),
|
||||
});
|
||||
vi.mocked(startLocalBigFishRuntimeRun).mockReturnValue({
|
||||
runId: 'big-fish-run-1',
|
||||
@@ -2057,7 +2170,7 @@ test('logged out public detail gates big fish start before local runtime', async
|
||||
);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2518,7 +2631,6 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
|
||||
const button = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||
await user.click(button);
|
||||
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -2527,9 +2639,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成草稿' }) as HTMLButtonElement,
|
||||
).toHaveProperty('disabled', false);
|
||||
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -2734,7 +2844,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2807,7 +2917,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2850,7 +2960,7 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2872,6 +2982,56 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('public code search opens a published Match3D work by M3 code and starts runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dWork: Match3DWorkSummary = {
|
||||
workId: 'match3d-work-public-1',
|
||||
profileId: 'match3d-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'match3d-session-public-1',
|
||||
gameName: '水果抓大鹅',
|
||||
themeText: '水果消除',
|
||||
summary: '把圆形空间里的水果全部消除。',
|
||||
tags: ['水果', '消除'],
|
||||
coverImageSrc: null,
|
||||
referenceImageSrc: null,
|
||||
clearCount: 4,
|
||||
difficulty: 5,
|
||||
publicationStatus: 'published',
|
||||
playCount: 3,
|
||||
updatedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishedAt: '2026-04-25T10:30:00.000Z',
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
vi.mocked(listMatch3DGallery).mockResolvedValue({
|
||||
items: [match3dWork],
|
||||
});
|
||||
vi.mocked(startMatch3DRun).mockResolvedValue({
|
||||
run: buildMockMatch3DRun(match3dWork.profileId),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'M3-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
|
||||
expect(await screen.findByText('详情')).toBeTruthy();
|
||||
expect(screen.getByText('水果抓大鹅')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '启动' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startMatch3DRun).toHaveBeenCalledWith('match3d-profile-public-1');
|
||||
});
|
||||
expect(
|
||||
await screen.findByText('抓大鹅运行态:match3d-run-match3d-profile-public-1'),
|
||||
).toBeTruthy();
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -620,7 +620,7 @@ test('mobile home search submits public work code', async () => {
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'输入 SY / CW / BF / PZ 编号',
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
formatPlatformWorkDisplayTag,
|
||||
formatPlatformWorldTime,
|
||||
isBigFishGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
@@ -303,7 +304,7 @@ function PublicCodeSearchBar({
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="输入 SY / CW / BF / PZ 编号"
|
||||
placeholder="输入 SY / CW / BF / M3 / PZ 编号"
|
||||
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||||
/>
|
||||
<button
|
||||
@@ -1020,7 +1021,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: 'rpg';
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
@@ -1029,7 +1032,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
? '大鱼'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? '抓鹅'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
return formatPlatformWorkDisplayTag(kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { 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 {
|
||||
@@ -9,6 +10,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import {
|
||||
buildBigFishPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
@@ -20,6 +22,7 @@ export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
@@ -71,9 +74,31 @@ export type PlatformBigFishGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformMatch3DGalleryCard = {
|
||||
sourceType: 'match3d';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformBigFishGalleryCard
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
@@ -94,6 +119,12 @@ export function isBigFishGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'big-fish';
|
||||
}
|
||||
|
||||
export function isMatch3DGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformMatch3DGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'match3d';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
@@ -120,6 +151,31 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapMatch3DWorkToPlatformGalleryCard(
|
||||
work: Match3DWorkSummary,
|
||||
): PlatformMatch3DGalleryCard {
|
||||
return {
|
||||
sourceType: 'match3d',
|
||||
workId: work.workId,
|
||||
profileId: work.profileId,
|
||||
publicWorkCode: buildMatch3DPublicWorkCode(work.profileId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: '玩家',
|
||||
worldName: work.gameName,
|
||||
subtitle: '经典消除玩法',
|
||||
summaryText: work.summary,
|
||||
coverImageSrc: work.coverImageSrc ?? null,
|
||||
themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '抓大鹅'],
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt ?? null,
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBigFishWorkToPlatformGalleryCard(
|
||||
work: BigFishWorkSummary,
|
||||
): PlatformBigFishGalleryCard {
|
||||
@@ -307,6 +363,10 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅'];
|
||||
}
|
||||
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
@@ -381,6 +441,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user