Merge remote-tracking branch 'origin/master' into codex/bark-battle
This commit is contained in:
+11
-1
@@ -32,6 +32,16 @@ const RpgRuntimeApp = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function RuntimeLoadingFallback() {
|
||||
return (
|
||||
<div className="platform-ui-shell platform-viewport-shell platform-theme platform-theme--dark flex h-screen items-center justify-center bg-[image:var(--platform-body-fill)] p-4 font-sans text-[var(--platform-text-strong)]">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
|
||||
正在启动
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isRpgRuntimeRoute(pathname: string) {
|
||||
const normalizedPath = normalizeAppPath(pathname);
|
||||
return (
|
||||
@@ -126,7 +136,7 @@ export default function App() {
|
||||
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<RuntimeLoadingFallback />}>
|
||||
<RpgRuntimeApp
|
||||
initialIntent={runtimeIntent}
|
||||
onExitRuntime={() => {
|
||||
|
||||
@@ -22,7 +22,7 @@ const PLACEHOLDER_PUZZLE_IMAGE =
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fef3c7" />
|
||||
<stop offset="0.45" stop-color="#fb7185" />
|
||||
<stop offset="0.45" stop-color="#c7653d" />
|
||||
<stop offset="1" stop-color="#312e81" />
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="42%" cy="34%" r="46%">
|
||||
|
||||
@@ -173,7 +173,7 @@ function ImageFrame({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(255,96,147,0.92),rgba(255,146,109,0.84))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(204,117,76,0.9),rgba(223,127,64,0.82))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||||
>
|
||||
{src ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -238,7 +238,7 @@ function PendingEntityCard({
|
||||
</div>
|
||||
<div className="platform-progress-track mt-3 h-2.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_100%)] transition-[width] duration-300"
|
||||
className="h-full bg-[var(--platform-button-primary-solid)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -313,7 +313,7 @@ function OpeningCgPreview({
|
||||
</div>
|
||||
{isGenerating ? (
|
||||
<div className="platform-progress-track h-2 overflow-hidden rounded-full">
|
||||
<div className="h-full w-2/3 animate-pulse bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]" />
|
||||
<div className="h-full w-2/3 animate-pulse bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_52%,#eaccb3_100%)]" />
|
||||
</div>
|
||||
) : null}
|
||||
{openingCg?.status === 'failed' && openingCg.errorMessage ? (
|
||||
@@ -437,7 +437,7 @@ function CatalogCard({
|
||||
<div
|
||||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||||
isSelected
|
||||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||||
? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] text-[var(--platform-button-danger-text)]'
|
||||
: 'platform-subpanel text-[var(--platform-text-soft)]'
|
||||
}`}
|
||||
>
|
||||
@@ -453,7 +453,7 @@ function CatalogCard({
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-disabled={disabled}
|
||||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${
|
||||
isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel'
|
||||
isSelected ? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]' : 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 xl:gap-3.5">
|
||||
@@ -491,7 +491,7 @@ function CatalogCard({
|
||||
onClick={disabled ? undefined : onClick}
|
||||
aria-disabled={disabled}
|
||||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||||
isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel'
|
||||
isSelected ? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]' : 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -899,7 +899,7 @@ export function CustomWorldEntityCatalog({
|
||||
ref={scrollContainerRef}
|
||||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2 2xl:space-y-5 2xl:pr-3"
|
||||
>
|
||||
<div className="px-1 pb-1 text-center xl:flex xl:items-end xl:justify-between xl:gap-6 xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-3 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm 2xl:px-7">
|
||||
<div className="px-1 pb-1 text-center xl:flex xl:items-end xl:justify-between xl:gap-6 xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-3 xl:text-left xl:shadow-[0_18px_70px_rgba(112,57,30,0.08)] xl:backdrop-blur-sm 2xl:px-7">
|
||||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||||
世界档案
|
||||
</div>
|
||||
@@ -913,7 +913,7 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(255,79,139,0.08)]">
|
||||
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(112,57,30,0.08)]">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
|
||||
{RESULT_TABS.map((tab) => (
|
||||
<div key={tab.id}>
|
||||
|
||||
@@ -225,7 +225,7 @@ export function CustomWorldGenerationView({
|
||||
|
||||
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full xl:mt-5 xl:h-5">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
|
||||
className="h-full bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_52%,#eaccb3_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
/>
|
||||
@@ -283,9 +283,9 @@ export function CustomWorldGenerationView({
|
||||
)}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
? 'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)]'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
? 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)]'
|
||||
: 'platform-subpanel'
|
||||
} custom-world-generation-step`}
|
||||
initial={
|
||||
@@ -317,9 +317,9 @@ export function CustomWorldGenerationView({
|
||||
<motion.div
|
||||
className={`h-full rounded-full ${
|
||||
step.status === 'completed'
|
||||
? 'bg-emerald-300'
|
||||
? 'bg-[var(--platform-success-text)]'
|
||||
: step.status === 'active'
|
||||
? 'bg-[linear-gradient(90deg,#7dd3fc_0%,#fcd34d_100%)]'
|
||||
? 'bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_56%,#eaccb3_100%)]'
|
||||
: 'bg-white/18'
|
||||
}`}
|
||||
animate={{ width: `${stepProgress}%` }}
|
||||
@@ -335,7 +335,7 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="mt-4 rounded-2xl border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-3 text-sm leading-6 text-[var(--platform-button-danger-text)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -364,7 +364,7 @@ export function CustomWorldGenerationView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
|
||||
className="rounded-full border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-2 text-sm text-[var(--platform-button-danger-text)] transition-colors hover:text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{interruptLabel}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
||||
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
|
||||
import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView';
|
||||
@@ -286,6 +287,40 @@ function ResultViewHarness() {
|
||||
);
|
||||
}
|
||||
|
||||
function ResultViewRehydratingHarness() {
|
||||
const [profile, setProfile] = useState(baseProfile);
|
||||
const [rehydrated, setRehydrated] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="rehydrated">{rehydrated ? 'yes' : 'no'}</div>
|
||||
<RpgCreationResultView
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
isGenerating={false}
|
||||
progress={0}
|
||||
progressLabel=""
|
||||
error={null}
|
||||
onBack={() => {}}
|
||||
onProfileChange={(nextProfile) => {
|
||||
setProfile(nextProfile);
|
||||
if (!nextProfile.openingCg) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const normalized = normalizeCustomWorldProfileRecord(nextProfile);
|
||||
if (normalized) {
|
||||
setProfile(normalized);
|
||||
}
|
||||
setRehydrated(true);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -385,6 +420,40 @@ test('world tab generates opening cg only after manual click and writes it back
|
||||
});
|
||||
});
|
||||
|
||||
test('world tab keeps opening cg visible after parent rehydrates normalized profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
|
||||
id: 'opening-cg-1',
|
||||
status: 'ready',
|
||||
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
|
||||
storyboardAssetId: 'storyboard-1',
|
||||
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
|
||||
videoAssetId: 'video-1',
|
||||
imageModel: 'gpt-image-2',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2k',
|
||||
videoResolution: '480p',
|
||||
durationSeconds: 15,
|
||||
pointCost: 80,
|
||||
estimatedWaitMinutes: 10,
|
||||
updatedAt: '2026-05-03T00:00:00Z',
|
||||
});
|
||||
|
||||
render(<ResultViewRehydratingHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('rehydrated').textContent).toBe('yes');
|
||||
});
|
||||
expect(
|
||||
document.querySelector(
|
||||
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
||||
const user = userEvent.setup();
|
||||
const profile = {
|
||||
|
||||
@@ -260,7 +260,7 @@ function ThemeOptionCard({
|
||||
onClick={onClick}
|
||||
className={`platform-subpanel w-full rounded-[1.5rem] p-4 text-left transition ${
|
||||
active
|
||||
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(255,91,132,0.14)]'
|
||||
? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,0.14)]'
|
||||
: 'hover:border-[var(--platform-surface-hover-border)]'
|
||||
}`}
|
||||
>
|
||||
@@ -534,8 +534,8 @@ export function AccountModal({
|
||||
<ThemeOptionCard
|
||||
active={platformTheme === 'light'}
|
||||
title="亮色主题"
|
||||
detail="暖白底面板,粉橘强调。"
|
||||
previewClassName="bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,#fff8fb_0%,#ffe9ee_52%,#ffd8cb_100%)] border border-white/70"
|
||||
detail="暖白底面板,陶土橙强调。"
|
||||
previewClassName="bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.32),transparent_30%),linear-gradient(135deg,#fffdf9_0%,#f4e5d7_52%,#eaccb3_100%)] border border-white/70"
|
||||
onClick={() => onPlatformThemeChange('light')}
|
||||
/>
|
||||
<ThemeOptionCard
|
||||
|
||||
@@ -60,7 +60,7 @@ export function BindPhoneScreen({
|
||||
<div className={`platform-theme platform-theme--${platformTheme} min-h-screen bg-[var(--platform-body-fill)] px-4 py-6 text-[var(--platform-text-strong)] sm:py-8`}>
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(204,117,76,0.18),rgba(240,203,169,0.16))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">陶泥儿</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
|
||||
@@ -181,7 +181,7 @@ export function BarkBattleConfigEditor({
|
||||
value={title}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
|
||||
maxLength={40}
|
||||
aria-label="作品标题"
|
||||
/>
|
||||
@@ -193,7 +193,7 @@ export function BarkBattleConfigEditor({
|
||||
value={description}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
|
||||
maxLength={160}
|
||||
placeholder=""
|
||||
aria-label="简介"
|
||||
@@ -211,7 +211,7 @@ export function BarkBattleConfigEditor({
|
||||
event.target.value as BarkBattleDifficultyPreset,
|
||||
)
|
||||
}
|
||||
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
|
||||
aria-label="难度预设"
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((option) => (
|
||||
|
||||
@@ -143,3 +143,196 @@ test('creative image input panel confirms before removing uploaded image', () =>
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
|
||||
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel supports a preview-only main image mode', () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
mainImageMode="preview"
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/ui/background.png"
|
||||
uploadedImageAlt="UI背景预览"
|
||||
mainImageInputId="background-image-upload-input"
|
||||
promptTextareaId="background-prompt-input"
|
||||
prompt="雨夜猫街竖屏拼图UI背景"
|
||||
promptLabel="UI背景提示词"
|
||||
aiRedraw={false}
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="生成UI背景"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: 'UI背景预览',
|
||||
uploadImage: '上传拼图图片',
|
||||
replaceImage: '更换拼图图片',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除拼图图片',
|
||||
removeImageConfirmTitle: '移除拼图图片?',
|
||||
removeImageConfirmBody: '移除后需要重新上传图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('UI背景预览').getAttribute('src')).toBe(
|
||||
'/generated-puzzle-assets/session/ui/background.png',
|
||||
);
|
||||
expect(screen.queryByLabelText('上传拼图图片')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.getByLabelText('UI背景提示词')).toHaveProperty(
|
||||
'value',
|
||||
'雨夜猫街竖屏拼图UI背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canRemoveMainImage={false}
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面AI重绘要求(提示词)"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('拼图关卡图')).toBeTruthy();
|
||||
expect(screen.queryByText('上传图片/填写画面描述')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '移除参考图' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creative image input panel can show an image without exposing AI redraw controls', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canToggleAiRedraw={false}
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('拼图关卡图')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.getByLabelText('画面描述')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creative image input panel can upload prompt references while showing a main image', () => {
|
||||
const onPromptReferenceFilesSelect = vi.fn();
|
||||
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canUploadPromptReferences
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[
|
||||
{
|
||||
id: 'prompt-ref-1',
|
||||
label: '描述参考图 1',
|
||||
imageSrc: 'data:image/png;base64,prompt-ref-1',
|
||||
},
|
||||
]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传主图',
|
||||
replaceImage: '更换主图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除主图',
|
||||
removeImageConfirmTitle: '移除主图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const promptReferenceInput = screen.getByLabelText('上传参考图', {
|
||||
selector: 'input',
|
||||
});
|
||||
fireEvent.change(promptReferenceInput, {
|
||||
target: {
|
||||
files: [new File(['a'], 'prompt-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([
|
||||
expect.any(File),
|
||||
]);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '预览参考图 描述参考图 1' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
assetObjectId?: string | null;
|
||||
};
|
||||
|
||||
export type CreativeImageInputPanelLabels = {
|
||||
@@ -34,13 +35,20 @@ export type CreativeImageInputPanelProps = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
canRemoveMainImage?: boolean;
|
||||
canToggleAiRedraw?: boolean;
|
||||
canUploadPromptReferences?: boolean;
|
||||
uploadedImageSrc: string;
|
||||
uploadedImageAlt: string;
|
||||
uploadedImageRefreshKey?: string | number | null;
|
||||
mainImageMeta?: ReactNode;
|
||||
mainImageInputId: string;
|
||||
mainImageAccept?: string;
|
||||
promptTextareaId: string;
|
||||
prompt: string;
|
||||
promptLabel: string;
|
||||
promptAriaLabel?: string;
|
||||
promptRows?: number;
|
||||
aiRedraw: boolean;
|
||||
promptReferenceImages: CreativeImageInputReferenceImage[];
|
||||
@@ -69,13 +77,20 @@ export function CreativeImageInputPanel({
|
||||
className = '',
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
mainImageMode = 'edit',
|
||||
canRemoveMainImage = true,
|
||||
canToggleAiRedraw = true,
|
||||
canUploadPromptReferences,
|
||||
uploadedImageSrc,
|
||||
uploadedImageAlt,
|
||||
uploadedImageRefreshKey = null,
|
||||
mainImageMeta = null,
|
||||
mainImageInputId,
|
||||
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
|
||||
promptTextareaId,
|
||||
prompt,
|
||||
promptLabel,
|
||||
promptAriaLabel,
|
||||
promptRows = 2,
|
||||
aiRedraw,
|
||||
promptReferenceImages,
|
||||
@@ -100,9 +115,12 @@ export function CreativeImageInputPanel({
|
||||
useState<CreativeImageInputReferenceImage | null>(null);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const showPrompt = !uploadedImageSrc || aiRedraw;
|
||||
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
||||
const shouldShowPromptReferences =
|
||||
canUploadPromptReferences ?? !uploadedImageSrc;
|
||||
const promptReferenceUploadDisabled =
|
||||
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
||||
const canEditMainImage = mainImageMode === 'edit';
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedImageSrc) {
|
||||
@@ -143,34 +161,41 @@ export function CreativeImageInputPanel({
|
||||
{labels.imageField}
|
||||
</div>
|
||||
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
|
||||
<input
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full">
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
@@ -182,12 +207,12 @@ export function CreativeImageInputPanel({
|
||||
</span>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
|
||||
{onHistoryClick ? (
|
||||
{canEditMainImage && onHistoryClick ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onHistoryClick}
|
||||
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${
|
||||
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] ${
|
||||
disabled ? 'cursor-not-allowed opacity-55' : ''
|
||||
}`}
|
||||
aria-label={labels.history ?? '选择历史图片'}
|
||||
@@ -197,7 +222,7 @@ export function CreativeImageInputPanel({
|
||||
<span>历史</span>
|
||||
</button>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
|
||||
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
|
||||
<span>AI重绘</span>
|
||||
<input
|
||||
@@ -212,7 +237,7 @@ export function CreativeImageInputPanel({
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative h-5 w-9 rounded-full transition ${
|
||||
aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
|
||||
aiRedraw ? 'bg-[var(--platform-accent)]' : 'bg-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -223,21 +248,21 @@ export function CreativeImageInputPanel({
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
{canEditMainImage && uploadedImageSrc && canRemoveMainImage ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsRemoveImageConfirmOpen(true)}
|
||||
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label={labels.removeImage}
|
||||
title={labels.removeImage}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
) : canEditMainImage && !uploadedImageSrc ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: 'cursor-pointer'
|
||||
@@ -245,9 +270,10 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
{labels.emptyImageHint}
|
||||
</label>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{mainImageMeta ? <div className="mt-3 shrink-0">{mainImageMeta}</div> : null}
|
||||
</div>
|
||||
|
||||
{showPrompt ? (
|
||||
@@ -267,12 +293,12 @@ export function CreativeImageInputPanel({
|
||||
placeholder=""
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
|
||||
aria-label={promptLabel}
|
||||
aria-label={promptAriaLabel ?? promptLabel}
|
||||
/>
|
||||
{imageModelPicker}
|
||||
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
|
||||
{shouldShowPromptReferences && onPromptReferenceFilesSelect ? (
|
||||
<label
|
||||
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[#ff4056] ${
|
||||
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[var(--platform-accent)] ${
|
||||
promptReferenceUploadDisabled
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: 'cursor-pointer'
|
||||
@@ -299,7 +325,7 @@ export function CreativeImageInputPanel({
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{!uploadedImageSrc && promptReferenceImages.length > 0 ? (
|
||||
{shouldShowPromptReferences && promptReferenceImages.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{promptReferenceImages.map((reference) => (
|
||||
<div
|
||||
@@ -326,7 +352,7 @@ export function CreativeImageInputPanel({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPromptReferenceRemove(reference.id)}
|
||||
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[#ff4056] disabled:opacity-55"
|
||||
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[var(--platform-accent)] disabled:opacity-55"
|
||||
aria-label={`移除参考图 ${reference.label}`}
|
||||
title="移除参考图"
|
||||
>
|
||||
|
||||
@@ -377,7 +377,7 @@ export function SquareImageCropModal({
|
||||
className="h-full w-full object-fill"
|
||||
/>
|
||||
<div
|
||||
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
|
||||
className={`absolute border-2 border-[var(--platform-accent)] shadow-[0_0_0_9999px_rgba(61,31,16,0.34)] outline outline-1 outline-[rgba(74,34,15,0.35)] ${
|
||||
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
|
||||
}`}
|
||||
style={previewStyle}
|
||||
@@ -410,13 +410,13 @@ export function SquareImageCropModal({
|
||||
onPointerUp={stopCropDrag}
|
||||
onPointerCancel={stopCropDrag}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)]" />
|
||||
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-[var(--platform-accent)] shadow-[0_0_0_3px_rgba(204,117,76,0.32)]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
|
||||
<div className="mt-4 rounded-2xl border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-3 py-2 text-sm text-[var(--platform-button-danger-text)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -136,7 +136,7 @@ function CreationAgentOperationBanner({
|
||||
: 'platform-banner--success';
|
||||
const progress = normalizeCreationAgentProgress(visibleOperation.progress);
|
||||
const progressFillStyle = isFailed
|
||||
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
|
||||
? { background: 'linear-gradient(90deg, #c7653d 0%, #a6402f 100%)' }
|
||||
: isRunning
|
||||
? { background: 'var(--platform-button-primary-fill)' }
|
||||
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
|
||||
|
||||
@@ -171,7 +171,7 @@ export function CreativeAgentWorkspace({
|
||||
{targetBinding ? (
|
||||
<section className="platform-subpanel flex flex-col gap-3 rounded-[1.35rem] p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-emerald-100 text-emerald-700">
|
||||
<span className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-[var(--platform-success-bg)] text-[var(--platform-success-text)]">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
@@ -203,7 +203,7 @@ export function CreativeAgentWorkspace({
|
||||
key={message.id}
|
||||
className={`max-w-[86%] rounded-[1.15rem] px-4 py-3 text-sm leading-6 ${
|
||||
message.role === 'user'
|
||||
? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]'
|
||||
? 'ml-auto bg-[var(--platform-button-primary-solid)] text-[var(--platform-button-primary-text)]'
|
||||
: 'platform-subpanel text-[var(--platform-text-base)]'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,17 @@ const testEntryConfig = {
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '文字冒险',
|
||||
subtitle: '经典 RPG 体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/rpg.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
@@ -290,14 +301,18 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
const match3dButton = screen.getByRole('button', {
|
||||
name: /抓大鹅.*3D 消除关卡/u,
|
||||
});
|
||||
const rpgButton = screen.getByRole('button', {
|
||||
name: /文字冒险.*经典 RPG 体验/u,
|
||||
});
|
||||
expect(puzzleButton).toBeTruthy();
|
||||
expect(match3dButton).toBeTruthy();
|
||||
expect(rpgButton).toBeTruthy();
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /方洞挑战/u })).toBeNull();
|
||||
expect(screen.queryByText('反直觉形状分拣')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /文字冒险/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
|
||||
await user.click(match3dButton);
|
||||
|
||||
@@ -19,6 +19,17 @@ const testEntryConfig = {
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '文字冒险',
|
||||
subtitle: '经典 RPG 体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/rpg.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
@@ -124,7 +135,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('拼图关卡创作');
|
||||
expect(html).toContain('抓大鹅');
|
||||
expect(html).toContain('3D 消除关卡');
|
||||
expect(html).not.toContain('文字冒险');
|
||||
expect(html).toContain('文字冒险');
|
||||
expect(html).toContain('经典 RPG 体验');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
type Encounter,
|
||||
type SceneHostileNpc,
|
||||
} from '../../types';
|
||||
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
|
||||
import {
|
||||
GameCanvasEntityLayer,
|
||||
getCombatFloatingNumberPresentation,
|
||||
} from './GameCanvasEntityLayer';
|
||||
import {
|
||||
CHARACTER_COMBAT_HP_TOP_PX,
|
||||
ENTITY_CONTAINER_REM,
|
||||
@@ -125,6 +128,21 @@ function renderEntityLayer(effectNpcId: string | null) {
|
||||
}
|
||||
|
||||
describe('GameCanvasEntityLayer', () => {
|
||||
it('keeps combat floating numbers readable on dark noisy battle backgrounds', () => {
|
||||
const damage = getCombatFloatingNumberPresentation(false);
|
||||
const healing = getCombatFloatingNumberPresentation(true);
|
||||
|
||||
expect(damage.toneClass).toContain('bg-rose-950/72');
|
||||
expect(damage.toneClass).toContain('text-rose-50');
|
||||
expect(damage.textStyle.WebkitTextStroke).toContain('rgba(127, 29, 29');
|
||||
expect(damage.textStyle.textShadow).toContain('rgba(0, 0, 0');
|
||||
|
||||
expect(healing.toneClass).toContain('bg-emerald-950/70');
|
||||
expect(healing.toneClass).toContain('text-emerald-50');
|
||||
expect(healing.textStyle.WebkitTextStroke).toContain('rgba(6, 78, 59');
|
||||
expect(healing.textStyle.textShadow).toContain('rgba(0, 0, 0');
|
||||
});
|
||||
|
||||
it('uses mirrored stage anchors for player and opponent containers', () => {
|
||||
expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%');
|
||||
expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {motion} from 'motion/react';
|
||||
import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {getCharacterById} from '../../data/characterPresets';
|
||||
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
|
||||
@@ -130,6 +130,45 @@ function getSceneTransitionMotionConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function getCombatFloatingNumberPresentation(isHealing: boolean): {
|
||||
toneClass: string;
|
||||
textStyle: CSSProperties;
|
||||
} {
|
||||
const textShadow = [
|
||||
'0 1px 0 rgba(0, 0, 0, 0.98)',
|
||||
'0 0 8px rgba(0, 0, 0, 0.92)',
|
||||
'0 0 16px rgba(0, 0, 0, 0.72)',
|
||||
].join(', ');
|
||||
|
||||
if (isHealing) {
|
||||
return {
|
||||
toneClass: [
|
||||
'border-emerald-100/70',
|
||||
'bg-emerald-950/70',
|
||||
'text-emerald-50',
|
||||
'shadow-[0_0_18px_rgba(52,211,153,0.55)]',
|
||||
].join(' '),
|
||||
textStyle: {
|
||||
WebkitTextStroke: '1.45px rgba(6, 78, 59, 0.95)',
|
||||
textShadow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toneClass: [
|
||||
'border-rose-100/75',
|
||||
'bg-rose-950/72',
|
||||
'text-rose-50',
|
||||
'shadow-[0_0_20px_rgba(248,113,113,0.68)]',
|
||||
].join(' '),
|
||||
textStyle: {
|
||||
WebkitTextStroke: '1.55px rgba(127, 29, 29, 0.98)',
|
||||
textShadow,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function CombatFloatingNumber({
|
||||
event,
|
||||
onDone,
|
||||
@@ -139,23 +178,20 @@ function CombatFloatingNumber({
|
||||
}) {
|
||||
const isHealing = event.delta > 0;
|
||||
const deltaText = `${isHealing ? '+' : ''}${event.delta}`;
|
||||
const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200';
|
||||
const glowClass = isHealing
|
||||
? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]'
|
||||
: 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]';
|
||||
const presentation = getCombatFloatingNumberPresentation(isHealing);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
initial={{opacity: 0, y: 10, scale: 0.76}}
|
||||
animate={{opacity: [0, 1, 1, 0], y: [10, -12, -31, -50], scale: [0.76, 1.22, 1, 0.9]}}
|
||||
initial={{opacity: 0, y: 8, scale: 0.72}}
|
||||
animate={{opacity: [0, 1, 1, 0], y: [8, -14, -36, -58], scale: [0.72, 1.18, 1.04, 0.92]}}
|
||||
transition={{duration: 0.92, ease: 'easeOut'}}
|
||||
onAnimationComplete={() => onDone(event.id)}
|
||||
className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`}
|
||||
className={`pointer-events-none absolute -top-[4.65rem] left-1/2 z-[38] flex min-w-[2.4rem] -translate-x-1/2 select-none items-center justify-center rounded-full border px-1.5 py-0.5 text-[1.45rem] font-black leading-none tracking-[-0.04em] sm:text-[1.6rem] ${presentation.toneClass}`}
|
||||
data-testid={`combat-feedback-${event.targetKey}`}
|
||||
aria-label={`战斗数值 ${deltaText}`}
|
||||
>
|
||||
<span className="[-webkit-text-stroke:1px_rgba(24,24,27,0.76)]">
|
||||
<span style={presentation.textStyle}>
|
||||
{deltaText}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { ArrowLeft, Loader2, Send } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDifficulty,
|
||||
JumpHopSessionResponse,
|
||||
JumpHopStylePreset,
|
||||
JumpHopWorkspaceCreateRequest,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
|
||||
|
||||
type JumpHopWorkspaceProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onSubmitted: (
|
||||
result: JumpHopSessionResponse,
|
||||
payload: JumpHopWorkspaceCreateRequest,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type JumpHopWorkspaceFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
themeTags: string;
|
||||
difficulty: JumpHopDifficulty;
|
||||
stylePreset: JumpHopStylePreset;
|
||||
characterPrompt: string;
|
||||
tilePrompt: string;
|
||||
endMoodPrompt: string;
|
||||
};
|
||||
|
||||
const DEFAULT_FORM_STATE: JumpHopWorkspaceFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
themeTags: '',
|
||||
difficulty: 'easy',
|
||||
stylePreset: 'minimal-blocks',
|
||||
characterPrompt: '',
|
||||
tilePrompt: '',
|
||||
endMoodPrompt: '',
|
||||
};
|
||||
|
||||
export function JumpHopWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onSubmitted,
|
||||
}: JumpHopWorkspaceProps) {
|
||||
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
formState.workTitle.trim() &&
|
||||
formState.workDescription.trim() &&
|
||||
formState.themeTags.trim() &&
|
||||
formState.characterPrompt.trim() &&
|
||||
formState.tilePrompt.trim(),
|
||||
),
|
||||
[formState],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || isSubmitting || isBusy) {
|
||||
setLocalError('请先补全输入。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const payload: JumpHopWorkspaceCreateRequest = {
|
||||
templateId: 'jump-hop',
|
||||
workTitle: formState.workTitle.trim(),
|
||||
workDescription: formState.workDescription.trim(),
|
||||
themeTags: formState.themeTags
|
||||
.split(/[,,、\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
difficulty: formState.difficulty,
|
||||
stylePreset: formState.stylePreset,
|
||||
characterPrompt: formState.characterPrompt.trim(),
|
||||
tilePrompt: formState.tilePrompt.trim(),
|
||||
endMoodPrompt: formState.endMoodPrompt.trim() || null,
|
||||
};
|
||||
const response = await jumpHopClient.createSession(payload);
|
||||
onSubmitted(response, payload);
|
||||
} catch (caughtError) {
|
||||
setLocalError(
|
||||
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标题
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品简介
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
主题标签
|
||||
</span>
|
||||
<input
|
||||
value={formState.themeTags}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
themeTags: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
难度
|
||||
</span>
|
||||
<select
|
||||
value={formState.difficulty}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
difficulty: event.target.value as JumpHopDifficulty,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
>
|
||||
<option value="easy">easy</option>
|
||||
<option value="standard">standard</option>
|
||||
<option value="advanced">advanced</option>
|
||||
<option value="challenge">challenge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
风格
|
||||
</span>
|
||||
<select
|
||||
value={formState.stylePreset}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
stylePreset: event.target.value as JumpHopStylePreset,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
>
|
||||
<option value="minimal-blocks">minimal-blocks</option>
|
||||
<option value="paper-toy">paper-toy</option>
|
||||
<option value="neon-glass">neon-glass</option>
|
||||
<option value="forest-stone">forest-stone</option>
|
||||
<option value="future-metal">future-metal</option>
|
||||
<option value="custom">custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
角色提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.characterPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
characterPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
地块提示词
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.tilePrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
tilePrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
终点氛围
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.endMoodPrompt}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
endMoodPrompt: event.target.value,
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{localError || error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{localError ?? error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting || isBusy}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || isSubmitting || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopWorkspace;
|
||||
@@ -0,0 +1,468 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Send,
|
||||
Shuffle,
|
||||
} from 'lucide-react';
|
||||
import { type CSSProperties, useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
JumpHopDraftResponse,
|
||||
JumpHopPath,
|
||||
JumpHopPlatform,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type JumpHopResultViewProps = {
|
||||
profile:
|
||||
| (JumpHopDraftResponse & {
|
||||
characterImageSrc?: string | null;
|
||||
tileAtlasImageSrc?: string | null;
|
||||
pathPreviewImageSrc?: string | null;
|
||||
})
|
||||
| (JumpHopWorkProfileResponse & {
|
||||
characterImageSrc?: string | null;
|
||||
tileAtlasImageSrc?: string | null;
|
||||
pathPreviewImageSrc?: string | null;
|
||||
});
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEdit: () => void;
|
||||
onStartTestRun: () => void;
|
||||
onPublish: () => void;
|
||||
onRegenerateCharacter: () => void;
|
||||
onRegenerateTiles: () => void;
|
||||
};
|
||||
|
||||
function isJumpHopWorkProfile(
|
||||
profile: JumpHopResultViewProps['profile'],
|
||||
): profile is JumpHopWorkProfileResponse {
|
||||
return 'summary' in profile;
|
||||
}
|
||||
|
||||
type MiniMapPlatform = {
|
||||
platform: JumpHopPlatform;
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isStart: boolean;
|
||||
isFinish: boolean;
|
||||
};
|
||||
|
||||
const difficultyToneByValue: Record<
|
||||
JumpHopPath['difficulty'],
|
||||
{ accent: string; soft: string; label: string }
|
||||
> = {
|
||||
advanced: {
|
||||
accent: '#df7f40',
|
||||
soft: 'rgba(249, 115, 22, 0.16)',
|
||||
label: '进阶',
|
||||
},
|
||||
challenge: {
|
||||
accent: '#b64a35',
|
||||
soft: 'rgba(182, 98, 63, 0.16)',
|
||||
label: '挑战',
|
||||
},
|
||||
easy: {
|
||||
accent: '#14b8a6',
|
||||
soft: 'rgba(20, 184, 166, 0.16)',
|
||||
label: '轻松',
|
||||
},
|
||||
standard: {
|
||||
accent: '#2563eb',
|
||||
soft: 'rgba(37, 99, 235, 0.16)',
|
||||
label: '标准',
|
||||
},
|
||||
};
|
||||
|
||||
const tileToneByType: Record<string, string> = {
|
||||
accent: '#c4b5fd',
|
||||
bonus: '#fde68a',
|
||||
finish: '#86efac',
|
||||
normal: '#e0f2fe',
|
||||
start: '#bae6fd',
|
||||
target: '#fecdd3',
|
||||
};
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
|
||||
const platforms = path?.platforms ?? [];
|
||||
if (platforms.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const coordinatePlatforms = platforms.filter(
|
||||
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
|
||||
);
|
||||
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
|
||||
const xValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.x)
|
||||
: [];
|
||||
const yValues = shouldUseCoordinates
|
||||
? coordinatePlatforms.map((platform) => platform.y)
|
||||
: [];
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
const xRange = Math.max(maxX - minX, 1);
|
||||
const yRange = Math.max(maxY - minY, 1);
|
||||
const denominator = Math.max(platforms.length - 1, 1);
|
||||
|
||||
return platforms.map((platform, index): MiniMapPlatform => {
|
||||
const sequenceRatio = index / denominator;
|
||||
const hasCoordinates =
|
||||
shouldUseCoordinates &&
|
||||
isFiniteNumber(platform.x) &&
|
||||
isFiniteNumber(platform.y);
|
||||
const x = hasCoordinates
|
||||
? 12 + ((platform.x - minX) / xRange) * 76
|
||||
: 12 + sequenceRatio * 76;
|
||||
const y = hasCoordinates
|
||||
? 14 + ((platform.y - minY) / yRange) * 72
|
||||
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
|
||||
|
||||
return {
|
||||
platform,
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
width: Math.min(Math.max(platform.width || 54, 42), 82),
|
||||
height: Math.min(Math.max(platform.height || 42, 34), 68),
|
||||
isStart: index === 0 || platform.tileType === 'start',
|
||||
isFinish:
|
||||
index === path?.finishIndex ||
|
||||
platform.tileType === 'finish' ||
|
||||
platform.tileType === 'target',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
|
||||
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
|
||||
const tone =
|
||||
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
|
||||
const pathPoints = platforms
|
||||
.map((platform) => `${platform.x},${platform.y}`)
|
||||
.join(' ');
|
||||
|
||||
if (platforms.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
|
||||
style={
|
||||
{
|
||||
'--jump-hop-path-accent': tone.accent,
|
||||
'--jump-hop-path-soft': tone.soft,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-soft)"
|
||||
strokeWidth="11"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points={pathPoints}
|
||||
fill="none"
|
||||
stroke="var(--jump-hop-path-accent)"
|
||||
strokeWidth="2.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</svg>
|
||||
{platforms.map((item) => {
|
||||
const tileTone =
|
||||
tileToneByType[item.platform.tileType] ?? tileToneByType.normal;
|
||||
const scoreBoost =
|
||||
isFiniteNumber(item.platform.scoreValue) &&
|
||||
item.platform.scoreValue > 1;
|
||||
const style = {
|
||||
left: `${item.x}%`,
|
||||
top: `${item.y}%`,
|
||||
width: `${item.width}%`,
|
||||
height: `${item.height}%`,
|
||||
background: tileTone,
|
||||
borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)',
|
||||
zIndex: 10 + item.index,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
item.platform.platformId ||
|
||||
`${item.index}-${item.platform.tileType}`
|
||||
}
|
||||
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
|
||||
style={style}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
item.isStart || item.isFinish ? tone.accent : '#ffffff',
|
||||
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
|
||||
}}
|
||||
/>
|
||||
{item.isStart || item.isFinish ? (
|
||||
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
|
||||
{item.isStart ? '起' : '终'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
|
||||
{tone.label}
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
|
||||
{platforms.length}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JumpHopResultView({
|
||||
profile,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onEdit,
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
onRegenerateCharacter,
|
||||
onRegenerateTiles,
|
||||
}: JumpHopResultViewProps) {
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const isWorkProfile = isJumpHopWorkProfile(profile);
|
||||
const draft = isWorkProfile ? profile.draft : profile;
|
||||
const safeDraft = draft as JumpHopDraftResponse & {
|
||||
characterAsset: NonNullable<JumpHopDraftResponse['characterAsset']>;
|
||||
tileAtlasAsset: NonNullable<JumpHopDraftResponse['tileAtlasAsset']>;
|
||||
path: NonNullable<JumpHopDraftResponse['path']>;
|
||||
};
|
||||
const path = isWorkProfile ? profile.path : safeDraft.path;
|
||||
const characterAsset = isWorkProfile
|
||||
? profile.characterAsset
|
||||
: safeDraft.characterAsset;
|
||||
const tileAtlasAsset = isWorkProfile
|
||||
? profile.tileAtlasAsset
|
||||
: safeDraft.tileAtlasAsset;
|
||||
const titleSource = isWorkProfile
|
||||
? profile.summary.workTitle
|
||||
: profile.workTitle;
|
||||
const summarySource = isWorkProfile
|
||||
? profile.summary.workDescription
|
||||
: profile.workDescription;
|
||||
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
|
||||
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
|
||||
const pathPlatforms = normalizePathPlatforms(path);
|
||||
const canRenderPathMiniMap = pathPlatforms.length > 0;
|
||||
const hasAssets = Boolean(
|
||||
profile.characterImageSrc?.trim() ||
|
||||
profile.tileAtlasImageSrc?.trim() ||
|
||||
profile.pathPreviewImageSrc?.trim() ||
|
||||
characterAsset?.imageSrc?.trim() ||
|
||||
tileAtlasAsset?.imageSrc?.trim() ||
|
||||
canRenderPathMiniMap,
|
||||
);
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
await Promise.resolve(onPublish());
|
||||
} finally {
|
||||
setIsPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateCharacter}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
角色
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRegenerateTiles}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
|
||||
>
|
||||
<Shuffle className="h-4 w-4" />
|
||||
地块
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{summary}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.characterImageSrc || characterAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('characterImageSrc' in profile
|
||||
? profile.characterImageSrc
|
||||
: null) ??
|
||||
characterAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="角色图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
角色
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
('tileAtlasImageSrc' in profile
|
||||
? profile.tileAtlasImageSrc
|
||||
: null) ??
|
||||
tileAtlasAsset?.imageSrc ??
|
||||
''
|
||||
}
|
||||
alt="地块图"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
地块
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
|
||||
{path && canRenderPathMiniMap ? (
|
||||
<JumpHopPathMiniMap path={path} />
|
||||
) : 'pathPreviewImageSrc' in profile &&
|
||||
profile.pathPreviewImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={profile.pathPreviewImageSrc}
|
||||
alt="路径预览"
|
||||
className="aspect-[1/1] w-full object-cover"
|
||||
/>
|
||||
) : path ? (
|
||||
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
|
||||
<div>
|
||||
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
|
||||
{path.platforms.length}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
|
||||
{path.difficulty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
|
||||
路径
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!hasAssets ? (
|
||||
<div className="platform-banner platform-banner--neutral mt-3 rounded-2xl text-sm leading-6">
|
||||
生成资源尚未准备完成。
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="platform-subpanel flex flex-col rounded-[1.25rem] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
结果操作
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto grid gap-2 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--ghost min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartTestRun}
|
||||
disabled={isBusy}
|
||||
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
试玩
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handlePublish();
|
||||
}}
|
||||
disabled={isBusy || isPublishing}
|
||||
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 text-sm"
|
||||
>
|
||||
{isPublishing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JumpHopResultView;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,9 +80,9 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
|
||||
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
|
||||
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
|
||||
expect(screen.getByText('2D素材风格')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '自定义' })).toBeNull();
|
||||
expect(screen.getByText('消耗10泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
|
||||
expect(screen.queryByText('参考图')).toBeNull();
|
||||
@@ -107,54 +107,12 @@ test('match3d workspace submits derived entry form payload instead of agent chat
|
||||
referenceImageSrc: null,
|
||||
clearCount: 16,
|
||||
difficulty: 6,
|
||||
assetStyleId: 'flat-icon',
|
||||
assetStyleLabel: '扁平图标',
|
||||
assetStylePrompt:
|
||||
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
|
||||
generateClickSound: false,
|
||||
});
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('match3d workspace supports custom 2d asset style prompt', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<Match3DAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '海底甜品店' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
|
||||
fireEvent.change(screen.getByLabelText('自定义2D素材风格描述'), {
|
||||
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
seedText: '海底甜品店题材,消除12次,难度4',
|
||||
themeText: '海底甜品店',
|
||||
clearCount: 12,
|
||||
difficulty: 4,
|
||||
assetStyleId: 'custom',
|
||||
assetStyleLabel: '自定义风格',
|
||||
assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('match3d workspace submits strict pixel-retro style prompt', () => {
|
||||
test('match3d workspace omits legacy asset style fields from entry payload', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -169,22 +127,13 @@ test('match3d workspace submits strict pixel-retro style prompt', () => {
|
||||
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
|
||||
target: { value: '复古水果铺' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '像素复古' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetStyleId: 'pixel-retro',
|
||||
assetStyleLabel: '像素复古',
|
||||
assetStylePrompt: expect.stringContaining('64x64'),
|
||||
}),
|
||||
);
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetStylePrompt: expect.stringContaining('禁止抗锯齿'),
|
||||
}),
|
||||
);
|
||||
const payload = onCreateFromForm.mock.calls[0]?.[0] ?? {};
|
||||
expect('assetStyleId' in payload).toBe(false);
|
||||
expect('assetStyleLabel' in payload).toBe(false);
|
||||
expect('assetStylePrompt' in payload).toBe(false);
|
||||
});
|
||||
|
||||
test('match3d workspace keeps click sound generation disabled from entry form', () => {
|
||||
@@ -231,11 +180,8 @@ test('match3d workspace falls back to compile action when restored from the lega
|
||||
expect(
|
||||
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '赛璐璐卡通' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(screen.queryByText('2D素材风格')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '赛璐璐卡通' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成抓大鹅草稿/u }));
|
||||
confirmMatch3DPointCost();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react';
|
||||
import { Loader2, Sparkles, WandSparkles } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -24,15 +24,11 @@ type Match3DAgentWorkspaceProps = {
|
||||
type Match3DFormState = {
|
||||
themeText: string;
|
||||
difficultyOptionId: Match3DDifficultyOptionId;
|
||||
assetStyleId: Match3DAssetStyleOptionId;
|
||||
customAssetStylePrompt: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: Match3DFormState = {
|
||||
themeText: '',
|
||||
difficultyOptionId: 'standard',
|
||||
assetStyleId: 'flat-icon',
|
||||
customAssetStylePrompt: '',
|
||||
};
|
||||
|
||||
// 中文注释:入口页只暴露难度选项,消除次数和难度数值由选项稳定派生给后端。
|
||||
@@ -46,60 +42,6 @@ const MATCH3D_DIFFICULTY_OPTIONS = [
|
||||
type Match3DDifficultyOptionId =
|
||||
(typeof MATCH3D_DIFFICULTY_OPTIONS)[number]['id'];
|
||||
|
||||
const MATCH3D_ASSET_STYLE_OPTIONS = [
|
||||
{
|
||||
id: 'flat-icon',
|
||||
label: '扁平图标',
|
||||
imageSrc: '/match3d-style-references/flat-icon.png',
|
||||
prompt:
|
||||
'干净扁平的 2D 游戏道具图标风格,正面视角,色块清楚,边缘硬朗,高可读性,适合移动端休闲游戏素材。',
|
||||
},
|
||||
{
|
||||
id: 'cel-cartoon',
|
||||
label: '赛璐璐卡通',
|
||||
imageSrc: '/match3d-style-references/cel-cartoon.png',
|
||||
prompt:
|
||||
'明亮赛璐璐卡通2D游戏道具风格,清晰线稿,硬边阴影,饱和配色,轮廓醒目。',
|
||||
},
|
||||
{
|
||||
id: 'pixel-retro',
|
||||
label: '像素复古',
|
||||
imageSrc: '/match3d-style-references/pixel-retro.png',
|
||||
prompt:
|
||||
'64x64 复古像素 2D 游戏道具 sprite 风格,限制调色板,硬像素边缘,清晰正面剪影,禁止抗锯齿,禁止柔光渐变,透明背景。',
|
||||
},
|
||||
{
|
||||
id: 'watercolor',
|
||||
label: '手绘水彩',
|
||||
imageSrc: '/match3d-style-references/watercolor.png',
|
||||
prompt:
|
||||
'手绘水彩2D道具素材风格',
|
||||
},
|
||||
{
|
||||
id: 'sticker-outline',
|
||||
label: '贴纸描边',
|
||||
imageSrc: '/match3d-style-references/sticker-outline.png',
|
||||
prompt:
|
||||
'贴纸描边2D游戏道具素材风格,粗白边与深色外轮廓',
|
||||
},
|
||||
{
|
||||
id: 'painterly-icon',
|
||||
label: '厚涂图标',
|
||||
imageSrc: '/match3d-style-references/painterly-icon.png',
|
||||
prompt:
|
||||
'厚涂2D游戏道具图标风格,笔触细腻,体积光影明确,中心构图,保持图标级清晰剪影。',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
label: '自定义',
|
||||
imageSrc: null,
|
||||
prompt: '',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Match3DAssetStyleOptionId =
|
||||
(typeof MATCH3D_ASSET_STYLE_OPTIONS)[number]['id'];
|
||||
|
||||
function normalizeDifficulty(value: number) {
|
||||
return Math.max(1, Math.min(10, Math.round(value)));
|
||||
}
|
||||
@@ -137,27 +79,6 @@ function getDifficultyOption(optionId: Match3DDifficultyOptionId) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAssetStyleOption(optionId: Match3DAssetStyleOptionId) {
|
||||
return (
|
||||
MATCH3D_ASSET_STYLE_OPTIONS.find((option) => option.id === optionId) ??
|
||||
MATCH3D_ASSET_STYLE_OPTIONS[0]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAssetStyleOptionId(
|
||||
assetStyleId: string | null | undefined,
|
||||
assetStylePrompt: string | null | undefined,
|
||||
): Match3DAssetStyleOptionId {
|
||||
const matchedOption = MATCH3D_ASSET_STYLE_OPTIONS.find(
|
||||
(option) => option.id === assetStyleId,
|
||||
);
|
||||
if (matchedOption) {
|
||||
return matchedOption.id;
|
||||
}
|
||||
|
||||
return assetStylePrompt?.trim() ? 'custom' : 'flat-icon';
|
||||
}
|
||||
|
||||
function resolveInitialFormState(
|
||||
session: Match3DAgentSessionSnapshot | null,
|
||||
initialFormPayload: CreateMatch3DSessionRequest | null = null,
|
||||
@@ -173,17 +94,11 @@ function resolveInitialFormState(
|
||||
initialFormPayload?.clearCount ?? config?.clearCount ?? null;
|
||||
const difficulty =
|
||||
initialFormPayload?.difficulty ?? config?.difficulty ?? null;
|
||||
const assetStyleId =
|
||||
initialFormPayload?.assetStyleId ?? config?.assetStyleId ?? null;
|
||||
const assetStylePrompt =
|
||||
initialFormPayload?.assetStylePrompt ?? config?.assetStylePrompt ?? '';
|
||||
|
||||
return {
|
||||
...EMPTY_FORM_STATE,
|
||||
themeText,
|
||||
difficultyOptionId: resolveDifficultyOptionId(difficulty, clearCount),
|
||||
assetStyleId: resolveAssetStyleOptionId(assetStyleId, assetStylePrompt),
|
||||
customAssetStylePrompt: assetStylePrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,9 +120,7 @@ export function Match3DAgentWorkspace({
|
||||
const [formState, setFormState] = useState<Match3DFormState>(() =>
|
||||
resolveInitialFormState(session, initialFormPayload),
|
||||
);
|
||||
const [isCustomStylePanelOpen, setIsCustomStylePanelOpen] = useState(false);
|
||||
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
|
||||
const [draftCustomStylePrompt, setDraftCustomStylePrompt] = useState('');
|
||||
const appliedInitialFormKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -219,30 +132,14 @@ export function Match3DAgentWorkspace({
|
||||
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setIsCustomStylePanelOpen(false);
|
||||
setIsPointCostConfirmOpen(false);
|
||||
setDraftCustomStylePrompt('');
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const themeText = formState.themeText.trim();
|
||||
const selectedDifficultyOption = getDifficultyOption(
|
||||
formState.difficultyOptionId,
|
||||
);
|
||||
const selectedAssetStyleOption = getAssetStyleOption(formState.assetStyleId);
|
||||
const assetStylePrompt =
|
||||
formState.assetStyleId === 'custom'
|
||||
? formState.customAssetStylePrompt.trim()
|
||||
: selectedAssetStyleOption.prompt;
|
||||
const assetStyleLabel =
|
||||
formState.assetStyleId === 'custom'
|
||||
? '自定义风格'
|
||||
: selectedAssetStyleOption.label;
|
||||
const canSubmit = Boolean(
|
||||
themeText &&
|
||||
!isBusy &&
|
||||
(formState.assetStyleId !== 'custom' ||
|
||||
formState.customAssetStylePrompt.trim()),
|
||||
);
|
||||
const canSubmit = Boolean(themeText && !isBusy);
|
||||
const formPayload = useMemo<CreateMatch3DSessionRequest>(
|
||||
() => ({
|
||||
seedText: themeText
|
||||
@@ -252,34 +149,11 @@ export function Match3DAgentWorkspace({
|
||||
referenceImageSrc: null,
|
||||
clearCount: selectedDifficultyOption.clearCount,
|
||||
difficulty: selectedDifficultyOption.difficulty,
|
||||
assetStyleId: formState.assetStyleId,
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
generateClickSound: false,
|
||||
}),
|
||||
[
|
||||
assetStyleLabel,
|
||||
assetStylePrompt,
|
||||
formState.assetStyleId,
|
||||
selectedDifficultyOption,
|
||||
themeText,
|
||||
],
|
||||
[selectedDifficultyOption, themeText],
|
||||
);
|
||||
|
||||
const openCustomStylePanel = () => {
|
||||
setDraftCustomStylePrompt(formState.customAssetStylePrompt);
|
||||
setIsCustomStylePanelOpen(true);
|
||||
};
|
||||
|
||||
const applyCustomStylePrompt = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
assetStyleId: 'custom',
|
||||
customAssetStylePrompt: draftCustomStylePrompt.trim(),
|
||||
}));
|
||||
setIsCustomStylePanelOpen(false);
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
@@ -330,7 +204,7 @@ export function Match3DAgentWorkspace({
|
||||
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
{title}
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
<span className="rounded-full border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] px-3 py-1 text-[11px] font-black text-[var(--platform-warm-text)]">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
@@ -356,82 +230,12 @@ export function Match3DAgentWorkspace({
|
||||
themeText: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100 sm:min-h-[9rem] lg:min-h-[14rem]"
|
||||
className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)] sm:min-h-[9rem] lg:min-h-[14rem]"
|
||||
aria-label="想做一个什么题材的抓大鹅?"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="min-h-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/52 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
2D素材风格
|
||||
</div>
|
||||
<div
|
||||
className="flex snap-x gap-2.5 overflow-x-auto overscroll-x-contain pb-0.5 scrollbar-hide touch-pan-x [-webkit-overflow-scrolling:touch]"
|
||||
aria-label="2D素材风格"
|
||||
>
|
||||
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => {
|
||||
const selected = formState.assetStyleId === option.id;
|
||||
const isCustom = option.id === 'custom';
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (isCustom) {
|
||||
openCustomStylePanel();
|
||||
return;
|
||||
}
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
assetStyleId: option.id,
|
||||
}));
|
||||
}}
|
||||
className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:h-[5.45rem] sm:w-[6.4rem] ${
|
||||
selected
|
||||
? 'border-rose-300 bg-white shadow-[0_8px_18px_rgba(190,18,60,0.10)] ring-2 ring-rose-100'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={option.label}
|
||||
>
|
||||
{option.imageSrc ? (
|
||||
<img
|
||||
src={option.imageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover transition duration-200 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 bg-[radial-gradient(circle_at_32%_24%,rgba(255,255,255,0.98),transparent_30%),linear-gradient(135deg,rgba(255,247,250,0.98),rgba(255,236,241,0.92))]" />
|
||||
)}
|
||||
<span className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.02)_0%,rgba(255,255,255,0.18)_44%,rgba(255,255,255,0.82)_100%)]" />
|
||||
{selected ? (
|
||||
<span className="absolute right-1.5 top-1.5 h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_0_3px_rgba(255,255,255,0.84)]" />
|
||||
) : null}
|
||||
{isCustom ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-rose-500">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-white/82 shadow-[0_6px_18px_rgba(190,18,60,0.12)]">
|
||||
<Plus className="h-5 w-5" />
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`absolute inset-x-2 bottom-1.5 truncate rounded-full px-1.5 py-0.5 text-center text-[11px] font-black shadow-[0_3px_10px_rgba(15,23,42,0.10)] ${
|
||||
selected
|
||||
? 'bg-rose-50/95 text-rose-700'
|
||||
: 'bg-white/88 text-[var(--platform-text-strong)]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/44 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.7)]">
|
||||
<div className="mb-1.5 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
难度
|
||||
@@ -450,10 +254,10 @@ export function Match3DAgentWorkspace({
|
||||
difficultyOptionId: option.id,
|
||||
}))
|
||||
}
|
||||
className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:min-h-11 ${
|
||||
className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] sm:min-h-11 ${
|
||||
selected
|
||||
? 'border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-[var(--platform-surface-hover-border)] hover:bg-white'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
@@ -498,60 +302,6 @@ export function Match3DAgentWorkspace({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustomStylePanelOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-custom-style-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div
|
||||
id="match3d-custom-style-title"
|
||||
className="text-base font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
自定义风格
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭自定义风格"
|
||||
onClick={() => setIsCustomStylePanelOpen(false)}
|
||||
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={draftCustomStylePrompt}
|
||||
onChange={(event) =>
|
||||
setDraftCustomStylePrompt(event.target.value)
|
||||
}
|
||||
rows={4}
|
||||
className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
|
||||
aria-label="自定义2D素材风格描述"
|
||||
/>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCustomStylePanelOpen(false)}
|
||||
className="platform-button platform-button--secondary justify-center"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!draftCustomStylePrompt.trim()}
|
||||
onClick={applyCustomStylePrompt}
|
||||
className={`platform-button platform-button--primary justify-center ${!draftCustomStylePrompt.trim() ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPointCostConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -173,7 +173,7 @@ const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
|
||||
const MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE = 1.9;
|
||||
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 25;
|
||||
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 20;
|
||||
const MATCH3D_SELECTED_MODEL_SCALE = 1.1;
|
||||
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
|
||||
new Set(['ring', 'arch']);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user