抓大鹅F3实现
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-30 21:01:36 +08:00
parent 22f6e6f4e7
commit 08815d98bc
39 changed files with 5891 additions and 18 deletions

View File

@@ -0,0 +1,61 @@
import { useCallback, useRef, useState } from 'react';
import type {
Match3DClickItemRequest,
Match3DRunSnapshot,
} from '../packages/shared/src/contracts/match3dRuntime';
import { Match3DRuntimeShell } from './components/match3d-runtime';
import {
confirmLocalMatch3DClick,
resolveLocalMatch3DTimer,
startLocalMatch3DRun,
} from './services/match3d-runtime';
function buildInitialRun() {
return startLocalMatch3DRun(12);
}
export default function Match3DPlaygroundApp() {
const [run, setRun] = useState<Match3DRunSnapshot>(buildInitialRun);
const authorityRunRef = useRef(run);
const syncRun = useCallback((nextRun: Match3DRunSnapshot) => {
setRun(nextRun);
}, []);
const handleClickItem = useCallback(async (payload: Match3DClickItemRequest) => {
const result = await confirmLocalMatch3DClick(authorityRunRef.current, payload);
authorityRunRef.current = result.run;
setRun(result.run);
return result;
}, []);
const handleRestart = useCallback(() => {
const nextRun = buildInitialRun();
authorityRunRef.current = nextRun;
setRun(nextRun);
}, []);
const handleExit = useCallback(() => {
window.location.assign('/');
}, []);
const handleTimeExpired = useCallback(() => {
const nextRun = resolveLocalMatch3DTimer(authorityRunRef.current);
authorityRunRef.current = nextRun;
setRun(nextRun);
}, []);
return (
<Match3DRuntimeShell
run={run}
onBack={handleExit}
onRestart={handleRestart}
onOptimisticRunChange={syncRun}
onClickItem={handleClickItem}
onTimeExpired={handleTimeExpired}
error={null}
isBusy={false}
/>
);
}

View File

@@ -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}`}
>

View File

@@ -0,0 +1,215 @@
import { useState } from 'react';
import type {
ExecuteMatch3DActionRequest,
Match3DAnchorItemResponse,
Match3DAgentSessionSnapshot,
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;

View 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.clearCount}
</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;

View 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));
});

View 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;

View File

@@ -0,0 +1 @@
export { Match3DRuntimeShell } from './Match3DRuntimeShell';

View File

@@ -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();
}

View File

@@ -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,12 @@ export function PlatformEntryFlowShellImpl({
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
const match3dSession = match3dFlow.session;
const match3dError = match3dFlow.error;
const isMatch3DBusy = match3dFlow.isBusy;
const streamingMatch3DReplyText = match3dFlow.streamingReplyText;
const isStreamingMatch3DReply = match3dFlow.isStreamingReply;
const puzzleSession = puzzleFlow.session;
const puzzleError = puzzleFlow.error;
const setPuzzleError = puzzleFlow.setError;
@@ -1219,6 +1291,14 @@ export function PlatformEntryFlowShellImpl({
await bigFishFlow.openWorkspace();
}, [bigFishFlow]);
const openMatch3DAgentWorkspace = useCallback(async () => {
match3dFlow.setSession(null);
match3dFlow.setError(null);
match3dFlow.setStreamingReplyText('');
match3dFlow.setIsStreamingReply(false);
await match3dFlow.openWorkspace();
}, [match3dFlow]);
const openPuzzleAgentWorkspace = useCallback(async () => {
setPuzzleRun(null);
setPuzzleOperation(null);
@@ -1276,6 +1356,10 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeReturnStage('platform');
setBigFishGenerationState(null);
setBigFishError(null);
match3dFlow.setSession(null);
match3dFlow.setError(null);
match3dFlow.setStreamingReplyText('');
match3dFlow.setIsStreamingReply(false);
setPuzzleOperation(null);
setPuzzleWorks([]);
setSelectedPuzzleDetail(null);
@@ -1304,6 +1388,7 @@ export function PlatformEntryFlowShellImpl({
}
}, [
authUi?.user,
match3dFlow,
platformBootstrap.canReadProtectedData,
persistRpgAgentUiState,
resetAutoSaveTrackingToIdle,
@@ -1340,6 +1425,13 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (type === 'match3d') {
runProtectedAction(() => {
void openMatch3DAgentWorkspace();
});
return;
}
if (type === 'puzzle') {
runProtectedAction(() => {
void openPuzzleAgentWorkspace();
@@ -1348,6 +1440,7 @@ export function PlatformEntryFlowShellImpl({
},
[
openBigFishAgentWorkspace,
openMatch3DAgentWorkspace,
openPuzzleAgentWorkspace,
prepareCreationLaunch,
runProtectedAction,
@@ -1364,6 +1457,10 @@ export function PlatformEntryFlowShellImpl({
bigFishFlow.leaveFlow();
}, [bigFishFlow]);
const leaveMatch3DFlow = useCallback(() => {
match3dFlow.leaveFlow();
}, [match3dFlow]);
const leavePuzzleFlow = useCallback(() => {
setPuzzleOperation(null);
setPuzzleRun(null);
@@ -1374,10 +1471,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(() => {
@@ -3264,6 +3365,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 +3854,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 +3884,11 @@ export function PlatformEntryFlowShellImpl({
void openBigFishAgentWorkspace();
});
}}
onSelectMatch3D={() => {
runProtectedAction(() => {
void openMatch3DAgentWorkspace();
});
}}
onSelectPuzzle={() => {
runProtectedAction(() => {
void openPuzzleAgentWorkspace();

View File

@@ -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',

View File

@@ -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'

View File

@@ -21,6 +21,12 @@ describe('matchAppRoute', () => {
});
});
it('routes match3d playground path to the standalone Match3D runtime', () => {
expect(matchAppRoute('/MATCH3D/')).toEqual({
kind: 'match3d-playground',
});
});
it('routes former standalone editor paths back to the main game', () => {
expect(matchAppRoute('/item-editor/tools')).toEqual({
kind: 'game',

View File

@@ -15,6 +15,9 @@ export type AppRouteMatch =
| {
kind: 'big-fish-playground';
}
| {
kind: 'match3d-playground';
}
| {
kind: 'game';
};
@@ -29,6 +32,7 @@ export type ResolvedAppRoute = {
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
const BigFishPlaygroundApp = lazy(() => import('../BigFishPlaygroundApp')) as AppRouteComponent;
const Match3DPlaygroundApp = lazy(() => import('../Match3DPlaygroundApp')) as AppRouteComponent;
const PuzzlePlaygroundApp = lazy(() => import('../PuzzlePlaygroundApp')) as AppRouteComponent;
function normalizeRoutePath(pathname: string) {
@@ -50,6 +54,12 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
};
}
if (normalizedPath === '/match3d') {
return {
kind: 'match3d-playground',
};
}
return {
kind: 'game',
};
@@ -76,6 +86,15 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
};
}
if (matchedRoute.kind === 'match3d-playground') {
return {
kind: 'match3d-playground',
loadingEyebrow: '正在载入抓大鹅',
loadingText: '正在进入消除关卡...',
Component: Match3DPlaygroundApp,
};
}
return {
kind: 'game',
loadingEyebrow: '正在载入游戏',

View File

@@ -0,0 +1,7 @@
export {
createMatch3DCreationSession,
executeMatch3DCreationAction,
getMatch3DCreationSession,
match3dCreationClient,
streamMatch3DCreationMessage,
} from './match3dCreationClient';

View File

@@ -0,0 +1,361 @@
import type {
CreateMatch3DSessionRequest,
ExecuteMatch3DActionRequest,
Match3DActionResponse,
Match3DAnchorItemResponse,
Match3DAgentMessageResponse,
Match3DAgentSessionSnapshot,
Match3DCreatorConfig,
Match3DSessionResponse,
SendMatch3DMessageRequest,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { TextStreamOptions } from '../aiTypes';
const MOCK_RESPONSE_DELAY_MS = 180;
const MATCH3D_SESSION_PREFIX = 'match3d-session';
const DEFAULT_MATCH3D_CONFIG: Match3DCreatorConfig = {
themeText: '缤纷玩具',
clearCount: 12,
difficulty: 4,
};
let match3dSessionCounter = 0;
const mockSessions = new Map<string, Match3DAgentSessionSnapshot>();
function delay(ms = MOCK_RESPONSE_DELAY_MS) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms));
}
function nowIso() {
return new Date().toISOString();
}
function createMessage(
sessionId: string,
role: Match3DAgentMessageResponse['role'],
text: string,
kind: Match3DAgentMessageResponse['kind'] = 'chat',
): Match3DAgentMessageResponse {
return {
id: `${sessionId}-message-${Date.now()}-${Math.random().toString(16).slice(2)}`,
role,
kind,
text,
createdAt: nowIso(),
};
}
function buildAnchor(
key: string,
label: string,
value: string,
): Match3DAnchorItemResponse {
return {
key,
label,
value,
status: value.trim() ? 'confirmed' : 'missing',
};
}
function buildAnchorPack(config: Partial<Match3DCreatorConfig>) {
return {
theme: buildAnchor('theme', '题材主题', config.themeText ?? ''),
clearCount: buildAnchor(
'clearCount',
'需要消除次数',
typeof config.clearCount === 'number' ? String(config.clearCount) : '',
),
difficulty: buildAnchor(
'difficulty',
'难度',
typeof config.difficulty === 'number' ? String(config.difficulty) : '',
),
};
}
function normalizePositiveInteger(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const normalized = Math.floor(value);
return normalized > 0 ? normalized : null;
}
function normalizeDifficulty(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
return Math.max(1, Math.min(10, Math.round(value)));
}
function buildConfigFromPartial(
partial: Partial<Match3DCreatorConfig>,
): Match3DCreatorConfig | null {
const themeText = partial.themeText?.trim();
const clearCount = normalizePositiveInteger(partial.clearCount);
const difficulty = normalizeDifficulty(partial.difficulty);
if (!themeText || !clearCount || !difficulty) {
return null;
}
return {
themeText,
referenceImageSrc: partial.referenceImageSrc ?? null,
clearCount,
difficulty,
};
}
function parseConfigFromText(
text: string,
current: Partial<Match3DCreatorConfig>,
): Partial<Match3DCreatorConfig> {
const next = { ...current };
const trimmedText = text.trim();
const themeMatch =
trimmedText.match(/(?:|)[:\s]*([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})/u) ??
trimmedText.match(/(?:|||使)([\u4e00-\u9fa5A-Za-z0-9_-]{2,24})(?:|)/u);
const clearCountMatch =
trimmedText.match(/(?:|)[:\s]*(\d+)/u) ??
trimmedText.match(/(\d+)\s*(?:|)/u);
const difficultyMatch =
trimmedText.match(/(?:)[:\s]*(10|[1-9])/u) ??
trimmedText.match(/(?:|)/u);
if (themeMatch?.[1]) {
next.themeText = themeMatch[1].trim();
}
if (clearCountMatch?.[1]) {
next.clearCount = Number(clearCountMatch[1]);
}
if (difficultyMatch?.[1]) {
next.difficulty = Number(difficultyMatch[1]);
} else if (difficultyMatch?.[0]) {
next.difficulty = 7;
}
if (!next.themeText && trimmedText.length >= 2 && trimmedText.length <= 24) {
next.themeText = trimmedText;
}
return next;
}
function resolveSessionProgress(config: Partial<Match3DCreatorConfig>) {
const completed = [
Boolean(config.themeText?.trim()),
Boolean(normalizePositiveInteger(config.clearCount)),
Boolean(normalizeDifficulty(config.difficulty)),
].filter(Boolean).length;
return Math.round((completed / 3) * 100);
}
function buildAssistantReply(config: Partial<Match3DCreatorConfig>) {
const missing: string[] = [];
if (!config.themeText?.trim()) {
missing.push('题材主题');
}
if (!normalizePositiveInteger(config.clearCount)) {
missing.push('需要消除次数');
}
if (!normalizeDifficulty(config.difficulty)) {
missing.push('难度');
}
if (missing.length === 0) {
const readyConfig = buildConfigFromPartial(config) ?? DEFAULT_MATCH3D_CONFIG;
return `已确认:${readyConfig.themeText}题材,消除 ${readyConfig.clearCount} 次,共 ${readyConfig.clearCount * 3} 件物品,难度 ${readyConfig.difficulty}。可以生成结果页。`;
}
return `还需要确认:${missing.join('、')}`;
}
function updateSessionConfig(
session: Match3DAgentSessionSnapshot,
partialConfig: Partial<Match3DCreatorConfig>,
) {
const progressPercent = resolveSessionProgress(partialConfig);
const config = buildConfigFromPartial(partialConfig);
return {
...session,
progressPercent,
stage: progressPercent >= 100 ? 'ready_to_compile' : 'collecting',
anchorPack: buildAnchorPack(partialConfig),
config,
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
}
function ensureMockSession(sessionId: string) {
const session = mockSessions.get(sessionId);
if (!session) {
throw new Error('抓大鹅创作会话不存在,请重新开始创作。');
}
return session;
}
function buildDraft(config: Match3DCreatorConfig) {
return {
gameName: `${config.themeText}抓大鹅`,
themeText: config.themeText,
summaryText: `${config.themeText}题材的经典三消收纳关卡。`,
tags: [config.themeText, '抓大鹅', '消除'].slice(0, 3),
coverImageSrc: config.referenceImageSrc ?? null,
clearCount: config.clearCount,
difficulty: config.difficulty,
totalItemCount: config.clearCount * 3,
};
}
export async function createMatch3DCreationSession(
payload: CreateMatch3DSessionRequest = {},
): Promise<Match3DSessionResponse> {
await delay();
match3dSessionCounter += 1;
const sessionId = `${MATCH3D_SESSION_PREFIX}-${match3dSessionCounter}`;
const partialConfig: Partial<Match3DCreatorConfig> = {
themeText: payload.themeText ?? payload.seedText,
referenceImageSrc: payload.referenceImageSrc ?? null,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
};
const now = nowIso();
const session: Match3DAgentSessionSnapshot = updateSessionConfig(
{
sessionId,
currentTurn: 0,
progressPercent: 0,
stage: 'collecting',
anchorPack: buildAnchorPack(partialConfig),
config: null,
draft: null,
messages: [
createMessage(
sessionId,
'assistant',
'先确认题材、需要消除次数和难度。也可以直接说“自动配置”。',
),
],
lastAssistantReply: null,
updatedAt: now,
},
partialConfig,
);
mockSessions.set(sessionId, session);
return { session };
}
export async function getMatch3DCreationSession(sessionId: string) {
await delay(80);
return { session: ensureMockSession(sessionId) };
}
export async function streamMatch3DCreationMessage(
sessionId: string,
payload: SendMatch3DMessageRequest,
options: TextStreamOptions = {},
): Promise<Match3DAgentSessionSnapshot> {
await delay(120);
const session = ensureMockSession(sessionId);
const text = payload.text.trim();
const currentConfig = session.config ?? {
themeText: session.anchorPack.theme.value,
clearCount: Number(session.anchorPack.clearCount.value) || undefined,
difficulty: Number(session.anchorPack.difficulty.value) || undefined,
};
const nextConfig =
payload.quickFillRequested || //u.test(text)
? {
...DEFAULT_MATCH3D_CONFIG,
themeText: currentConfig.themeText || DEFAULT_MATCH3D_CONFIG.themeText,
}
: parseConfigFromText(text, currentConfig);
const userMessage = {
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text,
createdAt: nowIso(),
} satisfies Match3DAgentMessageResponse;
const assistantReply = buildAssistantReply(nextConfig);
options.onUpdate?.(assistantReply.slice(0, Math.ceil(assistantReply.length / 2)));
await delay(80);
options.onUpdate?.(assistantReply);
await delay(80);
const nextSession = updateSessionConfig(
{
...session,
currentTurn: session.currentTurn + 1,
messages: [
...session.messages,
userMessage,
createMessage(sessionId, 'assistant', assistantReply),
],
lastAssistantReply: assistantReply,
},
{
...nextConfig,
referenceImageSrc:
payload.referenceImageSrc ?? currentConfig.referenceImageSrc ?? null,
},
);
mockSessions.set(sessionId, nextSession);
return nextSession;
}
export async function executeMatch3DCreationAction(
sessionId: string,
payload: ExecuteMatch3DActionRequest,
): Promise<Match3DActionResponse> {
await delay(220);
const session = ensureMockSession(sessionId);
if (payload.action !== 'match3d_compile_draft') {
throw new Error('未知抓大鹅创作操作。');
}
const config = session.config ?? buildConfigFromPartial(DEFAULT_MATCH3D_CONFIG);
if (!config) {
throw new Error('请先确认题材、需要消除次数和难度。');
}
const nextSession = {
...session,
stage: 'draft_compiled',
progressPercent: 100,
config,
draft: buildDraft(config),
lastAssistantReply: '抓大鹅草稿已准备完成。',
messages: [
...session.messages,
createMessage(sessionId, 'assistant', '抓大鹅草稿已准备完成。', 'summary'),
],
updatedAt: nowIso(),
} satisfies Match3DAgentSessionSnapshot;
mockSessions.set(sessionId, nextSession);
return { session: nextSession };
}
export const match3dCreationClient = {
createSession: createMatch3DCreationSession,
getSession: getMatch3DCreationSession,
streamMessage: streamMatch3DCreationMessage,
executeAction: executeMatch3DCreationAction,
};

View File

@@ -0,0 +1,8 @@
export {
buildLocalMatch3DOptimisticRun,
confirmLocalMatch3DClick,
MATCH3D_VISUAL_SEEDS,
resolveLocalMatch3DTimer,
startLocalMatch3DRun,
stopLocalMatch3DRun,
} from './match3dLocalRuntime';

View File

@@ -0,0 +1,409 @@
import type {
Match3DClickItemRequest,
Match3DClickItemResult,
Match3DItemSnapshot,
Match3DRunSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_LOCAL_DURATION_MS = 600_000;
type Match3DVisualSeed = {
itemTypeId: string;
visualKey: string;
colorClassName: string;
label: string;
};
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
{
itemTypeId: 'apple',
visualKey: 'apple-red',
colorClassName: 'from-rose-400 to-red-600',
label: '苹',
},
{
itemTypeId: 'banana',
visualKey: 'banana-yellow',
colorClassName: 'from-yellow-300 to-amber-500',
label: '蕉',
},
{
itemTypeId: 'grape',
visualKey: 'grape-purple',
colorClassName: 'from-violet-400 to-purple-700',
label: '萄',
},
{
itemTypeId: 'melon',
visualKey: 'melon-green',
colorClassName: 'from-emerald-300 to-green-600',
label: '瓜',
},
{
itemTypeId: 'berry',
visualKey: 'berry-blue',
colorClassName: 'from-sky-300 to-blue-600',
label: '莓',
},
{
itemTypeId: 'peach',
visualKey: 'peach-pink',
colorClassName: 'from-pink-300 to-orange-400',
label: '桃',
},
{
itemTypeId: 'plum',
visualKey: 'plum-indigo',
colorClassName: 'from-indigo-300 to-indigo-700',
label: '李',
},
{
itemTypeId: 'lime',
visualKey: 'lime-lime',
colorClassName: 'from-lime-300 to-lime-600',
label: '柠',
},
{
itemTypeId: 'orange',
visualKey: 'orange-orange',
colorClassName: 'from-orange-300 to-orange-600',
label: '橙',
},
{
itemTypeId: 'candy',
visualKey: 'candy-cyan',
colorClassName: 'from-cyan-300 to-teal-600',
label: '糖',
},
];
function createEmptyTray(): Match3DTraySlot[] {
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
slotIndex,
}));
}
function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
if (run.status !== 'Running') {
return run;
}
const elapsedMs = Math.max(0, nowMs - run.startedAtMs);
const remainingMs = Math.max(0, run.durationLimitMs - elapsedMs);
if (remainingMs > 0) {
return {
...run,
serverNowMs: nowMs,
remainingMs,
};
}
return {
...run,
status: 'Failed' as const,
serverNowMs: nowMs,
remainingMs: 0,
failureReason: 'TimeUp' as const,
snapshotVersion: run.snapshotVersion + 1,
};
}
function buildItem(
seed: Match3DVisualSeed,
index: number,
copyIndex: number,
): Match3DItemSnapshot {
const ring = Math.floor(index / 6);
const angle = index * 0.86 + copyIndex * 0.22;
const spread = 0.16 + (ring % 4) * 0.085;
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
const y = 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
const radius = 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
return {
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
itemTypeId: seed.itemTypeId,
visualKey: seed.visualKey,
x: Math.max(0.18, Math.min(0.82, x)),
y: Math.max(0.18, Math.min(0.82, y)),
radius,
layer: index + 1,
state: 'InBoard',
clickable: true,
};
}
function recomputeClickable(items: Match3DItemSnapshot[]) {
const boardItems = items.filter((item) => item.state === 'InBoard');
return items.map((item) => {
if (item.state !== 'InBoard') {
return {
...item,
clickable: false,
};
}
const coveredByHigherLayer = boardItems.some((other) => {
if (other.itemInstanceId === item.itemInstanceId || other.layer <= item.layer) {
return false;
}
const distance = Math.hypot(other.x - item.x, other.y - item.y);
return distance < Math.min(item.radius, other.radius) * 0.78;
});
return {
...item,
clickable: !coveredByHigherLayer,
};
});
}
function findNextTrayIndex(traySlots: Match3DTraySlot[]) {
return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1;
}
function countClearedItems(items: Match3DItemSnapshot[]) {
return items.filter((item) => item.state === 'Cleared').length;
}
function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
const clearedItemCount = countClearedItems(run.items);
if (clearedItemCount >= run.totalItemCount) {
return {
...run,
status: 'Won',
clearedItemCount,
remainingMs: Math.max(0, run.remainingMs),
};
}
const trayIsFull = run.traySlots.every((slot) => Boolean(slot.itemInstanceId));
if (trayIsFull) {
return {
...run,
status: 'Failed',
clearedItemCount,
failureReason: 'TrayFull',
};
}
return {
...run,
status: 'Running',
failureReason: undefined,
clearedItemCount,
};
}
function settleMatchedTrayItems(run: Match3DRunSnapshot) {
const slotsByType = new Map<string, Match3DTraySlot[]>();
for (const slot of run.traySlots) {
if (!slot.itemTypeId || !slot.itemInstanceId) {
continue;
}
slotsByType.set(slot.itemTypeId, [
...(slotsByType.get(slot.itemTypeId) ?? []),
slot,
]);
}
const matchedSlots = [...slotsByType.values()].find((slots) => slots.length >= 3);
if (!matchedSlots) {
return {
run,
clearedItemInstanceIds: [] as string[],
};
}
const clearedItemInstanceIds = matchedSlots
.slice(0, 3)
.map((slot) => slot.itemInstanceId)
.filter((itemInstanceId): itemInstanceId is string => Boolean(itemInstanceId));
const clearedSet = new Set(clearedItemInstanceIds);
const nextRun = {
...run,
traySlots: run.traySlots.map((slot) =>
slot.itemInstanceId && clearedSet.has(slot.itemInstanceId)
? { slotIndex: slot.slotIndex }
: slot,
),
items: run.items.map((item) =>
clearedSet.has(item.itemInstanceId)
? {
...item,
state: 'Cleared' as const,
clickable: false,
}
: item,
),
};
return {
run: nextRun,
clearedItemInstanceIds,
};
}
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
const normalizedClearCount = Math.max(1, Math.round(clearCount));
const typeCount = Math.min(MATCH3D_VISUAL_SEEDS.length, normalizedClearCount);
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
Array.from({ length: 3 }, (_, copyOffset) => {
const seed = MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? MATCH3D_VISUAL_SEEDS[0]!;
return buildItem(seed, clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset);
}),
).flat();
const nowMs = Date.now();
return {
runId: `local-match3d-run-${nowMs}`,
profileId: 'local-match3d-profile',
status: 'Running',
snapshotVersion: 1,
startedAtMs: nowMs,
durationLimitMs: MATCH3D_LOCAL_DURATION_MS,
serverNowMs: nowMs,
remainingMs: MATCH3D_LOCAL_DURATION_MS,
clearCount: normalizedClearCount,
totalItemCount: items.length,
clearedItemCount: 0,
traySlots: createEmptyTray(),
items: recomputeClickable(items),
};
}
export function resolveLocalMatch3DTimer(run: Match3DRunSnapshot) {
return normalizeRemainingMs(run);
}
export function buildLocalMatch3DOptimisticRun(
run: Match3DRunSnapshot,
itemInstanceId: string,
): Match3DRunSnapshot {
const targetItem = run.items.find((item) => item.itemInstanceId === itemInstanceId);
const nextTrayIndex = findNextTrayIndex(run.traySlots);
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
return run;
}
return {
...run,
items: run.items.map((item) =>
item.itemInstanceId === itemInstanceId
? {
...item,
state: 'Flying' as const,
clickable: false,
}
: item,
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === nextTrayIndex
? {
slotIndex: slot.slotIndex,
itemInstanceId: targetItem.itemInstanceId,
itemTypeId: targetItem.itemTypeId,
visualKey: targetItem.visualKey,
}
: slot,
),
};
}
export async function confirmLocalMatch3DClick(
run: Match3DRunSnapshot,
request: Match3DClickItemRequest,
): Promise<Match3DClickItemResult> {
// 中文注释F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
await new Promise((resolve) => window.setTimeout(resolve, 180));
const timedRun = normalizeRemainingMs(run);
if (timedRun.status !== 'Running') {
return {
status: 'RunFinished',
run: timedRun,
clearedItemInstanceIds: [],
failureReason: timedRun.failureReason,
};
}
if (request.clientSnapshotVersion !== run.snapshotVersion) {
return {
status: 'VersionConflict',
run: timedRun,
clearedItemInstanceIds: [],
};
}
const targetItem = run.items.find(
(item) => item.itemInstanceId === request.itemInstanceId,
);
if (!targetItem || targetItem.state !== 'InBoard') {
return {
status: 'RejectedAlreadyMoved',
run: timedRun,
clearedItemInstanceIds: [],
};
}
if (!targetItem.clickable) {
return {
status: 'RejectedNotClickable',
run: timedRun,
clearedItemInstanceIds: [],
};
}
const nextTrayIndex = findNextTrayIndex(run.traySlots);
if (nextTrayIndex < 0) {
const failedRun = {
...timedRun,
status: 'Failed' as const,
failureReason: 'TrayFull' as const,
snapshotVersion: run.snapshotVersion + 1,
};
return {
status: 'RejectedTrayFull',
run: failedRun,
clearedItemInstanceIds: [],
failureReason: 'TrayFull',
};
}
const movedRun: Match3DRunSnapshot = {
...timedRun,
snapshotVersion: run.snapshotVersion + 1,
items: timedRun.items.map((item) =>
item.itemInstanceId === targetItem.itemInstanceId
? {
...item,
state: 'InTray' as const,
clickable: false,
}
: item,
),
traySlots: timedRun.traySlots.map((slot) =>
slot.slotIndex === nextTrayIndex
? {
slotIndex: slot.slotIndex,
itemInstanceId: targetItem.itemInstanceId,
itemTypeId: targetItem.itemTypeId,
visualKey: targetItem.visualKey,
}
: slot,
),
};
const settled = settleMatchedTrayItems(movedRun);
const nextRun = resolveRunStatus({
...settled.run,
items: recomputeClickable(settled.run.items),
});
return {
status: 'Accepted',
run: nextRun,
acceptedItemInstanceId: targetItem.itemInstanceId,
clearedItemInstanceIds: settled.clearedItemInstanceIds,
failureReason: nextRun.failureReason,
};
}
export function stopLocalMatch3DRun(run: Match3DRunSnapshot): Match3DRunSnapshot {
if (run.status !== 'Running') {
return run;
}
return {
...run,
status: 'Stopped',
snapshotVersion: run.snapshotVersion + 1,
};
}