Merge remote-tracking branch 'origin/master'
# Conflicts: # docs/technical/README.md # server-rs/crates/spacetime-client/src/module_bindings/mod.rs
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles, X } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -75,15 +75,21 @@ type CreationAgentWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
quickActions?: CreationAgentQuickAction[];
|
||||
referenceImagePreviewSrc?: string | null;
|
||||
referenceImageLabel?: string | null;
|
||||
referenceImageError?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitText: (text: string, quickActionKey?: string) => void;
|
||||
onPrimaryAction: () => void;
|
||||
onQuickAction?: (action: CreationAgentQuickAction) => void;
|
||||
onReferenceImageChange?: (file: File) => Promise<void> | void;
|
||||
onClearReferenceImage?: () => void;
|
||||
};
|
||||
|
||||
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
|
||||
const DOCUMENT_INPUT_ACCEPT =
|
||||
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
|
||||
const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp';
|
||||
|
||||
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
|
||||
return [
|
||||
@@ -290,19 +296,26 @@ export function CreationAgentWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
quickActions = [],
|
||||
referenceImagePreviewSrc = null,
|
||||
referenceImageLabel = null,
|
||||
referenceImageError = null,
|
||||
onBack,
|
||||
onSubmitText,
|
||||
onPrimaryAction,
|
||||
onQuickAction,
|
||||
onReferenceImageChange,
|
||||
onClearReferenceImage,
|
||||
}: CreationAgentWorkspaceProps) {
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const [documentInputError, setDocumentInputError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
|
||||
const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false);
|
||||
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const documentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const referenceImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -376,7 +389,7 @@ export function CreationAgentWorkspace({
|
||||
|
||||
const submit = () => {
|
||||
const text = draftText.trim();
|
||||
if (!text || isBusy || isParsingDocumentInput) {
|
||||
if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,6 +412,10 @@ export function CreationAgentWorkspace({
|
||||
documentInputRef.current?.click();
|
||||
};
|
||||
|
||||
const openReferenceImagePicker = () => {
|
||||
referenceImageInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDocumentInputChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -426,6 +443,25 @@ export function CreationAgentWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReferenceImageInputChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.target.value = '';
|
||||
|
||||
if (!file || isBusy || isReadingReferenceImage || !onReferenceImageChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReadingReferenceImage(true);
|
||||
|
||||
try {
|
||||
await onReferenceImageChange(file);
|
||||
} finally {
|
||||
setIsReadingReferenceImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div
|
||||
@@ -545,9 +581,36 @@ export function CreationAgentWorkspace({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{documentInputError || error ? (
|
||||
{referenceImagePreviewSrc ? (
|
||||
<div className="mx-4 mb-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-[0.9rem] bg-[var(--platform-track-fill)]">
|
||||
<img
|
||||
src={referenceImagePreviewSrc}
|
||||
alt="参考图"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{referenceImageLabel || '已选择参考图'}
|
||||
</div>
|
||||
{onClearReferenceImage ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || isReadingReferenceImage}
|
||||
onClick={onClearReferenceImage}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="移除参考图"
|
||||
title="移除参考图"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{documentInputError || referenceImageError || error ? (
|
||||
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{documentInputError || error}
|
||||
{documentInputError || referenceImageError || error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -560,6 +623,15 @@ export function CreationAgentWorkspace({
|
||||
className="hidden"
|
||||
onChange={handleDocumentInputChange}
|
||||
/>
|
||||
{onReferenceImageChange ? (
|
||||
<input
|
||||
ref={referenceImageInputRef}
|
||||
type="file"
|
||||
accept={REFERENCE_IMAGE_INPUT_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleReferenceImageInputChange}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
@@ -575,9 +647,30 @@ export function CreationAgentWorkspace({
|
||||
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{onReferenceImageChange ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
|
||||
}
|
||||
title={
|
||||
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
|
||||
}
|
||||
aria-busy={isReadingReferenceImage}
|
||||
disabled={isBusy || isReadingReferenceImage}
|
||||
onClick={openReferenceImagePicker}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ImagePlus
|
||||
className={`h-4 w-4 ${isReadingReferenceImage ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<textarea
|
||||
value={draftText}
|
||||
disabled={isBusy || isParsingDocumentInput}
|
||||
disabled={
|
||||
isBusy || isParsingDocumentInput || isReadingReferenceImage
|
||||
}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDraftText(event.target.value);
|
||||
@@ -595,7 +688,12 @@ export function CreationAgentWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="发送"
|
||||
disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
|
||||
disabled={
|
||||
isBusy ||
|
||||
isParsingDocumentInput ||
|
||||
isReadingReferenceImage ||
|
||||
!draftText.trim()
|
||||
}
|
||||
onClick={submit}
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
|
||||
>
|
||||
|
||||
215
src/components/match3d-creation/Match3DAgentWorkspace.tsx
Normal file
215
src/components/match3d-creation/Match3DAgentWorkspace.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type {
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DAnchorItemResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import {
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
createCreationAgentClientMessageId,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from '../../services/creation-agent';
|
||||
import {
|
||||
type CreationAgentAnchorView,
|
||||
type CreationAgentSessionView,
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
} from '../creation-agent';
|
||||
|
||||
type Match3DAgentWorkspaceProps = {
|
||||
session: Match3DAgentSessionSnapshot | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitMessage: (payload: SendMatch3DMessageRequest) => void;
|
||||
onExecuteAction: (payload: ExecuteMatch3DActionRequest) => void;
|
||||
};
|
||||
|
||||
type Match3DReferenceImageState = {
|
||||
src: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const MATCH3D_AGENT_THEME: CreationAgentTheme = {
|
||||
accentTextClass: 'text-lime-100/86',
|
||||
accentBgClass: 'bg-lime-200',
|
||||
accentButtonClass: 'bg-lime-200 shadow-emerald-950/20',
|
||||
userBubbleClass: 'bg-emerald-600 text-white',
|
||||
heroClass:
|
||||
'border border-lime-100/18 bg-[radial-gradient(circle_at_top_left,rgba(190,242,100,0.24),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(251,146,60,0.2),transparent_32%),linear-gradient(135deg,rgba(20,83,45,0.96),rgba(39,39,42,0.96))]',
|
||||
anchorGridClass: 'grid gap-2 sm:grid-cols-3',
|
||||
};
|
||||
|
||||
const MATCH3D_QUICK_ACTIONS = [
|
||||
...createCreationAgentChatQuickActions(),
|
||||
{
|
||||
key: 'match3d-auto-config',
|
||||
label: '自动配置',
|
||||
},
|
||||
];
|
||||
|
||||
function readMatch3DReferenceImageAsDataUrl(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 mapMatch3DAnchor(
|
||||
anchor: Match3DAnchorItemResponse,
|
||||
): CreationAgentAnchorView {
|
||||
return {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
value: anchor.value,
|
||||
status: anchor.status,
|
||||
};
|
||||
}
|
||||
|
||||
function mapMatch3DSession(
|
||||
session: Match3DAgentSessionSnapshot,
|
||||
): CreationAgentSessionView {
|
||||
// 中文注释:抓大鹅 F1 只展示聊天与配置锚点,草稿结果交给后续结果页承接。
|
||||
const chatMessages = session.messages.filter(
|
||||
(message) =>
|
||||
message.kind === 'chat' ||
|
||||
message.kind === 'summary' ||
|
||||
message.kind === 'warning',
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: session.currentTurn,
|
||||
progressPercent: session.progressPercent,
|
||||
anchors: [
|
||||
session.anchorPack.theme,
|
||||
session.anchorPack.clearCount,
|
||||
session.anchorPack.difficulty,
|
||||
].map(mapMatch3DAnchor),
|
||||
messages: chatMessages,
|
||||
recommendedReplies: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatch3DChatPayload({
|
||||
text,
|
||||
quickFillRequested = false,
|
||||
referenceImageSrc,
|
||||
}: {
|
||||
text: string;
|
||||
quickFillRequested?: boolean;
|
||||
referenceImageSrc?: string | null;
|
||||
}) {
|
||||
return buildCreationAgentChatMessage<{
|
||||
referenceImageSrc?: string | null;
|
||||
}>({
|
||||
clientMessageId: createCreationAgentClientMessageId('match3d'),
|
||||
text,
|
||||
quickFillRequested,
|
||||
extraPayload: {
|
||||
referenceImageSrc: referenceImageSrc || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function Match3DAgentWorkspace({
|
||||
session,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitMessage,
|
||||
onExecuteAction,
|
||||
}: Match3DAgentWorkspaceProps) {
|
||||
const [referenceImage, setReferenceImage] =
|
||||
useState<Match3DReferenceImageState | null>(null);
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<CreationAgentWorkspace
|
||||
session={session ? mapMatch3DSession(session) : null}
|
||||
theme={MATCH3D_AGENT_THEME}
|
||||
loadingText="正在准备抓大鹅共创工作区..."
|
||||
composerPlaceholder="题材、消除次数、难度..."
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={MATCH3D_QUICK_ACTIONS}
|
||||
referenceImagePreviewSrc={referenceImage?.src ?? null}
|
||||
referenceImageLabel={referenceImage?.label ?? null}
|
||||
referenceImageError={referenceImageError}
|
||||
onBack={onBack}
|
||||
onSubmitText={(text) => {
|
||||
onSubmitMessage(
|
||||
buildMatch3DChatPayload({
|
||||
text,
|
||||
referenceImageSrc: referenceImage?.src ?? null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onPrimaryAction={() => {
|
||||
onExecuteAction({ action: 'match3d_compile_draft' });
|
||||
}}
|
||||
onQuickAction={(action) => {
|
||||
const quickActionMessage =
|
||||
action.key === 'match3d-auto-config'
|
||||
? {
|
||||
text: '自动配置',
|
||||
quickFillRequested: true,
|
||||
}
|
||||
: resolveCreationAgentQuickActionMessage(
|
||||
action.key,
|
||||
'请总结一下当前抓大鹅设定。',
|
||||
);
|
||||
|
||||
onSubmitMessage(
|
||||
buildMatch3DChatPayload({
|
||||
...quickActionMessage,
|
||||
referenceImageSrc: referenceImage?.src ?? null,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onReferenceImageChange={async (file) => {
|
||||
try {
|
||||
const dataUrl = await readMatch3DReferenceImageAsDataUrl(file);
|
||||
setReferenceImage({
|
||||
src: dataUrl,
|
||||
label: file.name.trim() || '本地参考图',
|
||||
});
|
||||
setReferenceImageError(null);
|
||||
} catch (caughtError) {
|
||||
setReferenceImageError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '参考图读取失败,请重试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onClearReferenceImage={() => {
|
||||
setReferenceImage(null);
|
||||
setReferenceImageError(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DAgentWorkspace;
|
||||
105
src/components/match3d-creation/Match3DDraftReadyView.tsx
Normal file
105
src/components/match3d-creation/Match3DDraftReadyView.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ArrowLeft, Edit3, Sparkles } from 'lucide-react';
|
||||
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
|
||||
type Match3DDraftReadyViewProps = {
|
||||
session: Match3DAgentSessionSnapshot;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function Match3DDraftReadyView({
|
||||
session,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
}: Match3DDraftReadyViewProps) {
|
||||
const draft = session.draft;
|
||||
const title = draft?.gameName || '抓大鹅草稿';
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section className="platform-subpanel min-h-0 flex-1 overflow-y-auto rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="grid aspect-square w-full max-w-[10rem] shrink-0 place-items-center rounded-[1.2rem] bg-[radial-gradient(circle_at_30%_25%,rgba(190,242,100,0.36),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] text-emerald-800">
|
||||
<Sparkles className="h-10 w-10" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-2xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-3xl">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{draft?.summaryText ?? session.lastAssistantReply ?? ''}
|
||||
</div>
|
||||
|
||||
{draft ? (
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
题材
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.themeText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
物品
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.totalItemCount ?? draft.clearCount * 3} 件
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
难度
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{draft.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="platform-button platform-button--primary cursor-not-allowed opacity-55"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
继续编辑
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DDraftReadyView;
|
||||
68
src/components/match3d-runtime/Match3DRuntimeShell.test.tsx
Normal file
68
src/components/match3d-runtime/Match3DRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
confirmLocalMatch3DClick,
|
||||
startLocalMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
|
||||
function renderRuntime(run: Match3DRunSnapshot) {
|
||||
let currentRun = run;
|
||||
let authorityRun = run;
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => {
|
||||
const result = await confirmLocalMatch3DClick(authorityRun, payload);
|
||||
authorityRun = result.run;
|
||||
return result;
|
||||
});
|
||||
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
|
||||
currentRun = nextRun;
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={currentRun}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
onClickItem,
|
||||
onOptimisticRunChange,
|
||||
};
|
||||
}
|
||||
|
||||
test('展示圆形空间和 7 格备选栏', () => {
|
||||
renderRuntime(startLocalMatch3DRun(4));
|
||||
|
||||
expect(screen.getByTestId('match3d-board')).toBeTruthy();
|
||||
expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7);
|
||||
});
|
||||
|
||||
test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
const clickableItem = run.items.find((item) => item.clickable);
|
||||
expect(clickableItem).toBeTruthy();
|
||||
const { onClickItem, onOptimisticRunChange } = renderRuntime(run);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`match3d-item-${clickableItem!.itemInstanceId}`));
|
||||
|
||||
expect(onOptimisticRunChange).toHaveBeenCalled();
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
454
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal file
454
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
Match3DClickItemResult,
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
Match3DTraySlot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
|
||||
|
||||
type Match3DRuntimeShellProps = {
|
||||
run: Match3DRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
onOptimisticRunChange: (run: Match3DRunSnapshot) => void;
|
||||
onClickItem: (
|
||||
payload: Match3DClickItemRequest,
|
||||
) => Promise<Match3DClickItemResult>;
|
||||
onTimeExpired?: () => void;
|
||||
};
|
||||
|
||||
type PendingClick = {
|
||||
clientEventId: string;
|
||||
itemInstanceId: string;
|
||||
previousRun: Match3DRunSnapshot;
|
||||
};
|
||||
|
||||
type Match3DFeedbackEvent = {
|
||||
id: string;
|
||||
kind: 'cleared' | 'rejected';
|
||||
itemIds: string[];
|
||||
};
|
||||
|
||||
function formatTimer(value: number) {
|
||||
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs: number) {
|
||||
const elapsedMs = Math.max(0, durationLimitMs - remainingMs);
|
||||
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function resolveVisualSeed(visualKey: string) {
|
||||
return (
|
||||
MATCH3D_VISUAL_SEEDS.find((seed) => seed.visualKey === visualKey) ??
|
||||
MATCH3D_VISUAL_SEEDS[0]!
|
||||
);
|
||||
}
|
||||
|
||||
function buildClientEventId(itemInstanceId: string) {
|
||||
return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round(
|
||||
Math.random() * 1_000_000,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function isPointInsideCircle(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
return Math.hypot(pointX - item.x, pointY - item.y) <= item.radius;
|
||||
}
|
||||
|
||||
function findHitItem(
|
||||
run: Match3DRunSnapshot,
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
) {
|
||||
return run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.state === 'InBoard' &&
|
||||
item.clickable &&
|
||||
isPointInsideCircle(pointX, pointY, item),
|
||||
)
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
}
|
||||
|
||||
function buildOptimisticRun(
|
||||
run: Match3DRunSnapshot,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId);
|
||||
if (!nextSlot) {
|
||||
return run;
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
items: run.items.map((entry) =>
|
||||
entry.itemInstanceId === item.itemInstanceId
|
||||
? {
|
||||
...entry,
|
||||
state: 'Flying' as const,
|
||||
clickable: false,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === nextSlot.slotIndex
|
||||
? {
|
||||
slotIndex: slot.slotIndex,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
itemTypeId: item.itemTypeId,
|
||||
visualKey: item.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function Match3DToken({
|
||||
item,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
item: Match3DItemSnapshot;
|
||||
disabled: boolean;
|
||||
onClick: (item: Match3DItemSnapshot) => void;
|
||||
}) {
|
||||
const visualSeed = resolveVisualSeed(item.visualKey);
|
||||
const size = `${item.radius * 200}%`;
|
||||
const itemStateClass =
|
||||
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') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-sm font-black text-white shadow-[0_10px_18px_rgba(15,23,42,0.32)] transition-all duration-300 [text-shadow:0_1px_2px_rgba(15,23,42,0.65)] ${itemStateClass}`}
|
||||
style={{
|
||||
left: `${item.x * 100}%`,
|
||||
top: `${item.y * 100}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: item.layer,
|
||||
}}
|
||||
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
||||
data-testid={`match3d-item-${item.itemInstanceId}`}
|
||||
disabled={disabled || !item.clickable || item.state !== 'InBoard'}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<span className="relative z-10">{visualSeed.label}</span>
|
||||
<span className="absolute inset-[16%] rounded-full bg-white/24" />
|
||||
<span className="absolute left-[18%] top-[14%] h-[18%] w-[28%] rounded-full bg-white/42 blur-[1px]" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
|
||||
if (!slot.visualKey) {
|
||||
return <span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />;
|
||||
}
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
return (
|
||||
<span
|
||||
className={`flex h-full w-full items-center justify-center rounded-xl border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-xs font-black text-white shadow-[0_8px_16px_rgba(15,23,42,0.24)] [text-shadow:0_1px_2px_rgba(15,23,42,0.62)]`}
|
||||
>
|
||||
{visualSeed.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DSettlement({
|
||||
run,
|
||||
onBack,
|
||||
onRestart,
|
||||
}: {
|
||||
run: Match3DRunSnapshot;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
}) {
|
||||
if (run.status === 'Running') {
|
||||
return null;
|
||||
}
|
||||
const won = run.status === 'Won';
|
||||
const stopped = run.status === 'Stopped';
|
||||
const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败';
|
||||
const description = won
|
||||
? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}`
|
||||
: `已清除 ${run.clearedItemCount}/${run.totalItemCount}`;
|
||||
return (
|
||||
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
|
||||
<section
|
||||
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<span
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
||||
won ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'
|
||||
}`}
|
||||
>
|
||||
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-xl font-black">{title}</h2>
|
||||
<p className="text-sm font-semibold text-slate-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white"
|
||||
onClick={onRestart}
|
||||
>
|
||||
再来一局
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DRuntimeShell({
|
||||
run,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onRestart,
|
||||
onOptimisticRunChange,
|
||||
onClickItem,
|
||||
onTimeExpired,
|
||||
}: Match3DRuntimeShellProps) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
|
||||
const [feedbackEvent, setFeedbackEvent] = useState<Match3DFeedbackEvent | null>(
|
||||
null,
|
||||
);
|
||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeftMs(run?.remainingMs ?? 0);
|
||||
}, [run?.remainingMs, run?.snapshotVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || run.status !== 'Running') {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setTimeLeftMs((current) => {
|
||||
const next = Math.max(0, current - 1000);
|
||||
if (next <= 0) {
|
||||
onTimeExpired?.();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [onTimeExpired, run]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!feedbackEvent) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => setFeedbackEvent(null), 520);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [feedbackEvent]);
|
||||
|
||||
const progressText = useMemo(() => {
|
||||
if (!run) {
|
||||
return '0/0';
|
||||
}
|
||||
return `${run.clearedItemCount}/${run.totalItemCount}`;
|
||||
}, [run]);
|
||||
|
||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||
if (!run || run.status !== 'Running' || pendingClick) {
|
||||
return;
|
||||
}
|
||||
const optimisticRun = buildOptimisticRun(run, item);
|
||||
const clientEventId = buildClientEventId(item.itemInstanceId);
|
||||
// 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。
|
||||
setPendingClick({
|
||||
clientEventId,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
previousRun: run,
|
||||
});
|
||||
onOptimisticRunChange(optimisticRun);
|
||||
|
||||
const result = await onClickItem({
|
||||
runId: run.runId,
|
||||
itemInstanceId: item.itemInstanceId,
|
||||
clientSnapshotVersion: run.snapshotVersion,
|
||||
clientEventId,
|
||||
clickedAtMs: Date.now(),
|
||||
});
|
||||
if (result.status === 'Accepted') {
|
||||
if (result.clearedItemInstanceIds.length > 0) {
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'cleared',
|
||||
itemIds: result.clearedItemInstanceIds,
|
||||
});
|
||||
}
|
||||
onOptimisticRunChange(result.run);
|
||||
} else {
|
||||
setFeedbackEvent({
|
||||
id: clientEventId,
|
||||
kind: 'rejected',
|
||||
itemIds: [item.itemInstanceId],
|
||||
});
|
||||
onOptimisticRunChange(result.run ?? run);
|
||||
}
|
||||
setPendingClick(null);
|
||||
};
|
||||
|
||||
const handleBoardPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!run || run.status !== 'Running' || pendingClick) {
|
||||
return;
|
||||
}
|
||||
const rect = stageRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const pointX = (event.clientX - rect.left) / rect.width;
|
||||
const pointY = (event.clientY - rect.top) / rect.height;
|
||||
const item = findHitItem(run, pointX, pointY);
|
||||
if (item) {
|
||||
void handleItemClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
|
||||
{isBusy ? '载入中' : error ?? '暂无运行态'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#16221f] text-white">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||||
<div className="relative flex min-h-dvh w-full max-w-md flex-col px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]">
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
|
||||
<Clock3 size={16} />
|
||||
<span>{formatTimer(timeLeftMs)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
|
||||
onClick={onRestart}
|
||||
aria-label="重新开始"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="mt-3 grid grid-cols-3 gap-2 text-center text-[0.72rem] font-black">
|
||||
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
||||
{progressText}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
||||
{run.clearCount} 组
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
||||
v{run.snapshotVersion}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative aspect-square w-full max-w-[min(92vw,58dvh)] overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
|
||||
onPointerDown={handleBoardPointerDown}
|
||||
data-testid="match3d-board"
|
||||
>
|
||||
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
{run.items.map((item) => (
|
||||
<Match3DToken
|
||||
key={item.itemInstanceId}
|
||||
item={item}
|
||||
disabled={Boolean(pendingClick)}
|
||||
onClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
{feedbackEvent?.kind === 'cleared' ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
|
||||
<Sparkles size={42} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-3 rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
||||
<div className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray">
|
||||
{run.traySlots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotIndex}
|
||||
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
||||
data-testid="match3d-tray-slot"
|
||||
>
|
||||
<Match3DTrayToken slot={slot} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{feedbackEvent?.kind === 'rejected' ? (
|
||||
<div className="pointer-events-none absolute left-1/2 top-24 z-[90] -translate-x-1/2 rounded-full border border-rose-200/60 bg-rose-500/88 px-4 py-2 text-xs font-black text-white shadow-lg">
|
||||
已校正
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Match3DSettlement run={run} onBack={onBack} onRestart={onRestart} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Match3DRuntimeShell;
|
||||
1
src/components/match3d-runtime/index.ts
Normal file
1
src/components/match3d-runtime/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
@@ -10,6 +10,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
onClose: () => void;
|
||||
onSelectRpg: () => void;
|
||||
onSelectBigFish: () => void;
|
||||
onSelectMatch3D: () => void;
|
||||
onSelectPuzzle: () => void;
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
onClose,
|
||||
onSelectRpg,
|
||||
onSelectBigFish,
|
||||
onSelectMatch3D,
|
||||
onSelectPuzzle,
|
||||
}: PlatformEntryCreationTypeModalProps) {
|
||||
if (!isOpen) {
|
||||
@@ -103,6 +105,9 @@ export function PlatformEntryCreationTypeModal({
|
||||
if (item.id === 'big-fish') {
|
||||
onSelectBigFish();
|
||||
}
|
||||
if (item.id === 'match3d') {
|
||||
onSelectMatch3D();
|
||||
}
|
||||
if (item.id === 'puzzle') {
|
||||
onSelectPuzzle();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ import type {
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type {
|
||||
CreateMatch3DSessionRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse,
|
||||
Match3DAgentSessionSnapshot,
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentOperationRecord,
|
||||
@@ -72,6 +80,7 @@ import {
|
||||
readCustomWorldAgentUiState,
|
||||
shouldRestoreCustomWorldAgentUiState,
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import { match3dCreationClient } from '../../services/match3d-creation';
|
||||
import {
|
||||
buildBigFishGenerationAnchorEntries,
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
@@ -510,6 +519,20 @@ const BigFishRuntimeShell = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DAgentWorkspace = lazy(async () => {
|
||||
const module = await import('../match3d-creation/Match3DAgentWorkspace');
|
||||
return {
|
||||
default: module.Match3DAgentWorkspace,
|
||||
};
|
||||
});
|
||||
|
||||
const Match3DDraftReadyView = lazy(async () => {
|
||||
const module = await import('../match3d-creation/Match3DDraftReadyView');
|
||||
return {
|
||||
default: module.Match3DDraftReadyView,
|
||||
};
|
||||
});
|
||||
|
||||
const CustomWorldCreationHub = lazy(async () => {
|
||||
const module = await import('../custom-world-home/CustomWorldCreationHub');
|
||||
return {
|
||||
@@ -707,6 +730,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
const resolveMatch3DErrorMessage = useCallback(
|
||||
(error: unknown, fallback: string) =>
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshBigFishShelf = useCallback(async () => {
|
||||
setIsBigFishLoadingLibrary(true);
|
||||
@@ -1086,6 +1114,44 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
});
|
||||
|
||||
const match3dFlow = usePlatformCreationAgentFlowController<
|
||||
Match3DAgentSessionSnapshot,
|
||||
CreateMatch3DSessionRequest,
|
||||
Match3DSessionResponse,
|
||||
SendMatch3DMessageRequest,
|
||||
ExecuteMatch3DActionRequest,
|
||||
Match3DActionResponse
|
||||
>({
|
||||
client: {
|
||||
createSession: match3dCreationClient.createSession,
|
||||
getSession: match3dCreationClient.getSession,
|
||||
streamMessage: match3dCreationClient.streamMessage,
|
||||
executeAction: match3dCreationClient.executeAction,
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
resultStage: 'match3d-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: (payload) => payload.action === 'match3d_compile_draft',
|
||||
resolveErrorMessage: resolveMatch3DErrorMessage,
|
||||
errorMessages: {
|
||||
open: '开启抓大鹅共创工作台失败。',
|
||||
restoreMissingSession: '这份抓大鹅草稿缺少会话信息,请重新开始创作。',
|
||||
restore: '读取抓大鹅创作草稿失败。',
|
||||
submit: '发送抓大鹅共创消息失败。',
|
||||
execute: '执行抓大鹅操作失败。',
|
||||
},
|
||||
enterCreateTab,
|
||||
setSelectionStage,
|
||||
onSessionOpened: () => {
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
onActionComplete: ({ response, setSession }) => {
|
||||
setSession(response.session);
|
||||
},
|
||||
});
|
||||
|
||||
const puzzleFlow = usePlatformCreationAgentFlowController<
|
||||
PuzzleAgentSessionSnapshot,
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
@@ -1196,6 +1262,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
|
||||
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
|
||||
|
||||
const match3dSession = match3dFlow.session;
|
||||
const match3dError = match3dFlow.error;
|
||||
const setMatch3DSession = match3dFlow.setSession;
|
||||
const setMatch3DError = match3dFlow.setError;
|
||||
const isMatch3DBusy = match3dFlow.isBusy;
|
||||
const streamingMatch3DReplyText = match3dFlow.streamingReplyText;
|
||||
const setStreamingMatch3DReplyText = match3dFlow.setStreamingReplyText;
|
||||
const isStreamingMatch3DReply = match3dFlow.isStreamingReply;
|
||||
const setIsStreamingMatch3DReply = match3dFlow.setIsStreamingReply;
|
||||
|
||||
const puzzleSession = puzzleFlow.session;
|
||||
const puzzleError = puzzleFlow.error;
|
||||
const setPuzzleError = puzzleFlow.setError;
|
||||
@@ -1219,6 +1295,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
await bigFishFlow.openWorkspace();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const openMatch3DAgentWorkspace = useCallback(async () => {
|
||||
setMatch3DSession(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
await match3dFlow.openWorkspace();
|
||||
}, [
|
||||
match3dFlow,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DSession,
|
||||
setStreamingMatch3DReplyText,
|
||||
]);
|
||||
|
||||
const openPuzzleAgentWorkspace = useCallback(async () => {
|
||||
setPuzzleRun(null);
|
||||
setPuzzleOperation(null);
|
||||
@@ -1276,6 +1366,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishRuntimeReturnStage('platform');
|
||||
setBigFishGenerationState(null);
|
||||
setBigFishError(null);
|
||||
setMatch3DSession(null);
|
||||
setMatch3DError(null);
|
||||
setStreamingMatch3DReplyText('');
|
||||
setIsStreamingMatch3DReply(false);
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleWorks([]);
|
||||
setSelectedPuzzleDetail(null);
|
||||
@@ -1310,10 +1404,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
resetRpgSessionViewState,
|
||||
selectionStage,
|
||||
setBigFishError,
|
||||
setIsStreamingMatch3DReply,
|
||||
setMatch3DError,
|
||||
setMatch3DSession,
|
||||
setPuzzleError,
|
||||
setRpgCustomWorldError,
|
||||
setRpgGeneratedCustomWorldProfile,
|
||||
setSelectionStage,
|
||||
setStreamingMatch3DReplyText,
|
||||
]);
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
@@ -1340,6 +1438,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'match3d') {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'puzzle') {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
@@ -1348,6 +1453,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
openBigFishAgentWorkspace,
|
||||
openMatch3DAgentWorkspace,
|
||||
openPuzzleAgentWorkspace,
|
||||
prepareCreationLaunch,
|
||||
runProtectedAction,
|
||||
@@ -1364,6 +1470,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
bigFishFlow.leaveFlow();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const leaveMatch3DFlow = useCallback(() => {
|
||||
match3dFlow.leaveFlow();
|
||||
}, [match3dFlow]);
|
||||
|
||||
const leavePuzzleFlow = useCallback(() => {
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
@@ -1374,10 +1484,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const submitBigFishMessage = bigFishFlow.submitMessage;
|
||||
|
||||
const submitMatch3DMessage = match3dFlow.submitMessage;
|
||||
|
||||
const submitPuzzleMessage = puzzleFlow.submitMessage;
|
||||
|
||||
const executeBigFishAction = bigFishFlow.executeAction;
|
||||
|
||||
const executeMatch3DAction = match3dFlow.executeAction;
|
||||
|
||||
const executePuzzleAction = puzzleFlow.executeAction;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1391,6 +1505,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'match3d-result' && !match3dSession?.draft) {
|
||||
setSelectionStage(
|
||||
match3dSession ? 'match3d-agent-workspace' : 'platform',
|
||||
);
|
||||
}
|
||||
}, [match3dSession, selectionStage, setSelectionStage]);
|
||||
|
||||
const startBigFishRun = useCallback(() => {
|
||||
if (!bigFishSession) {
|
||||
return;
|
||||
@@ -2796,11 +2918,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
: (platformBootstrap.platformError ??
|
||||
sessionController.agentWorkspaceRestoreError ??
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
puzzleError)
|
||||
}
|
||||
onRetry={() => {
|
||||
platformBootstrap.setPlatformError(null);
|
||||
setBigFishError(null);
|
||||
setMatch3DError(null);
|
||||
setPuzzleError(null);
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
@@ -2813,11 +2937,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshPuzzleShelf();
|
||||
}}
|
||||
createError={
|
||||
sessionController.creationTypeError ?? bigFishError ?? puzzleError
|
||||
sessionController.creationTypeError ??
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
puzzleError
|
||||
}
|
||||
createBusy={
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
@@ -2977,7 +3105,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformWorkDetailView
|
||||
entry={selectedPublicWorkDetail}
|
||||
authorAvatarUrl={selectedPublicWorkAuthor?.avatarUrl ?? null}
|
||||
isBusy={isPublicWorkDetailBusy || isPuzzleBusy || isBigFishBusy}
|
||||
isBusy={
|
||||
isPublicWorkDetailBusy ||
|
||||
isPuzzleBusy ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy
|
||||
}
|
||||
error={publicWorkDetailError}
|
||||
onBack={() => {
|
||||
setPublicWorkDetailError(null);
|
||||
@@ -3264,6 +3397,58 @@ export function PlatformEntryFlowShellImpl({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-agent-workspace' && (
|
||||
<motion.div
|
||||
key="match3d-agent-workspace"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅共创工作区..." />}
|
||||
>
|
||||
<Match3DAgentWorkspace
|
||||
session={match3dSession}
|
||||
streamingReplyText={streamingMatch3DReplyText}
|
||||
isStreamingReply={isStreamingMatch3DReply}
|
||||
isBusy={isMatch3DBusy || isStreamingMatch3DReply}
|
||||
error={match3dError}
|
||||
onBack={leaveMatch3DFlow}
|
||||
onSubmitMessage={(payload) => {
|
||||
void submitMatch3DMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executeMatch3DAction(payload);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'match3d-result' && match3dSession?.draft && (
|
||||
<motion.div
|
||||
key="match3d-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载抓大鹅结果..." />}
|
||||
>
|
||||
<Match3DDraftReadyView
|
||||
session={match3dSession}
|
||||
isBusy={isMatch3DBusy}
|
||||
error={match3dError}
|
||||
onBack={() => {
|
||||
setSelectionStage('match3d-agent-workspace');
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'puzzle-agent-workspace' && (
|
||||
<motion.div
|
||||
key="puzzle-agent-workspace"
|
||||
@@ -3701,15 +3886,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy ||
|
||||
isPuzzleBusy
|
||||
}
|
||||
error={
|
||||
bigFishError ?? puzzleError ?? sessionController.creationTypeError
|
||||
bigFishError ??
|
||||
match3dError ??
|
||||
puzzleError ??
|
||||
sessionController.creationTypeError
|
||||
}
|
||||
onClose={() => {
|
||||
if (
|
||||
sessionController.isCreatingAgentSession ||
|
||||
isBigFishBusy ||
|
||||
isMatch3DBusy ||
|
||||
isPuzzleBusy
|
||||
) {
|
||||
return;
|
||||
@@ -3726,6 +3916,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
void openBigFishAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectMatch3D={() => {
|
||||
runProtectedAction(() => {
|
||||
void openMatch3DAgentWorkspace();
|
||||
});
|
||||
}}
|
||||
onSelectPuzzle={() => {
|
||||
runProtectedAction(() => {
|
||||
void openPuzzleAgentWorkspace();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type PlatformCreationTypeId =
|
||||
| 'rpg'
|
||||
| 'big-fish'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'airp'
|
||||
| 'visual-novel';
|
||||
@@ -56,6 +57,13 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '抓大鹅',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
locked: false,
|
||||
},
|
||||
{
|
||||
id: 'airp',
|
||||
title: 'AIRP',
|
||||
|
||||
@@ -22,6 +22,8 @@ export type SelectionStage =
|
||||
| 'big-fish-generating'
|
||||
| 'big-fish-result'
|
||||
| 'big-fish-runtime'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'match3d-result'
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'puzzle-generating'
|
||||
| 'puzzle-result'
|
||||
|
||||
Reference in New Issue
Block a user