This commit is contained in:
2026-05-05 14:40:41 +08:00
parent e847fcea6f
commit 07e777fef8
76 changed files with 4246 additions and 444 deletions

View File

@@ -18,12 +18,14 @@ import {
AnimationState,
type Character,
type CustomWorldProfile,
type CustomWorldOpeningCgProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import { ResolvedAssetVideo } from './ResolvedAssetVideo';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
@@ -50,6 +52,10 @@ interface CustomWorldEntityCatalogProps {
createActionLabel?: string;
onCreateAction?: () => void;
createActionDisabled?: boolean;
openingCgGenerating?: boolean;
openingCgPhaseLabel?: string | null;
openingCgGenerateDisabled?: boolean;
onGenerateOpeningCg?: () => void;
pendingGeneratedEntity?: PendingGeneratedEntity | null;
recentGeneratedIds?: RecentGeneratedIds;
readOnly?: boolean;
@@ -240,6 +246,85 @@ function PendingEntityCard({
);
}
function OpeningCgPreview({
openingCg,
isGenerating,
phaseLabel,
generateDisabled,
readOnly,
onGenerate,
}: {
openingCg?: CustomWorldOpeningCgProfile | null;
isGenerating: boolean;
phaseLabel?: string | null;
generateDisabled?: boolean;
readOnly: boolean;
onGenerate?: () => void;
}) {
const hasVideo = Boolean(openingCg?.videoSrc?.trim());
const buttonLabel = hasVideo ? '重新生成' : '生成';
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-black/35 aspect-video">
{hasVideo ? (
<ResolvedAssetVideo
src={openingCg?.videoSrc}
className="h-full w-full object-cover"
controls
playsInline
preload="metadata"
/>
) : openingCg?.storyboardImageSrc ? (
<ResolvedAssetImage
src={openingCg.storyboardImageSrc}
alt="开局 CG 故事板"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm font-semibold tracking-[0.18em] text-zinc-500">
CG
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
80
</span>
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
10
</span>
{hasVideo ? (
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
</span>
) : null}
{!readOnly && onGenerate ? (
<div className="ml-auto">
<SmallButton
onClick={onGenerate}
tone="sky"
disabled={isGenerating || generateDisabled}
>
{isGenerating ? (phaseLabel ?? '生成中') : buttonLabel}
</SmallButton>
</div>
) : null}
</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>
) : null}
{openingCg?.status === 'failed' && openingCg.errorMessage ? (
<div className="platform-banner platform-banner--danger rounded-2xl px-3 py-2 text-xs leading-5">
{openingCg.errorMessage}
</div>
) : null}
</div>
);
}
function buildSceneActParticipantText(
act: SceneActBlueprint,
roleById: Map<
@@ -557,6 +642,10 @@ export function CustomWorldEntityCatalog({
createActionLabel,
onCreateAction,
createActionDisabled = false,
openingCgGenerating = false,
openingCgPhaseLabel = null,
openingCgGenerateDisabled = false,
onGenerateOpeningCg,
pendingGeneratedEntity = null,
recentGeneratedIds = {
playable: [],
@@ -916,6 +1005,17 @@ export function CustomWorldEntityCatalog({
</div>
</Section>
<Section title="开局 CG">
<OpeningCgPreview
openingCg={profile.openingCg}
isGenerating={openingCgGenerating}
phaseLabel={openingCgPhaseLabel}
generateDisabled={openingCgGenerateDisabled}
readOnly={readOnly}
onGenerate={onGenerateOpeningCg}
/>
</Section>
<Section
title="世界概述"
actions={

View File

@@ -15,6 +15,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
const generateLandmark = vi.fn();
const generateSceneImage = vi.fn();
const generateSceneNpc = vi.fn();
const generateOpeningCg = vi.fn();
return {
rpgCreationAssetClient: {
@@ -23,6 +24,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
generateLandmark,
generateSceneImage,
generateSceneNpc,
generateOpeningCg,
},
generateCustomWorldPlayableNpc: generatePlayableNpc,
generateCustomWorldStoryNpc: generateStoryNpc,
@@ -343,6 +345,46 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
expect(screen.getByText(/线/u)).toBeTruthy();
});
test('world tab generates opening cg only after manual click and writes it back to 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(<ResultViewHarness />);
expect(mockedRpgCreationAssetClient.generateOpeningCg).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(mockedRpgCreationAssetClient.generateOpeningCg).toHaveBeenCalledTimes(
1,
);
});
await waitFor(() => {
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 = {

View File

@@ -0,0 +1,30 @@
import type { VideoHTMLAttributes } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
type ResolvedAssetVideoProps = Omit<
VideoHTMLAttributes<HTMLVideoElement>,
'src'
> & {
src?: string | null;
fallbackSrc?: string | null;
refreshKey?: string | number | null;
};
export function ResolvedAssetVideo({
src,
fallbackSrc,
refreshKey,
...rest
}: ResolvedAssetVideoProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
refreshKey,
});
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
if (!finalSrc) {
return null;
}
return <video {...rest} src={finalSrc} />;
}

View File

@@ -57,6 +57,13 @@ describe('PublishShareModal', () => {
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
expect(
within(dialog).getByTestId('share-channel-logo-wechat'),
).toBeTruthy();
expect(within(dialog).getByTestId('share-channel-logo-qq')).toBeTruthy();
expect(
within(dialog).getByTestId('share-channel-logo-douyin'),
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));

View File

@@ -1,4 +1,4 @@
import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
import { Check, Copy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
@@ -15,26 +15,74 @@ type PublishShareModalProps = {
onClose: () => void;
};
type ShareChannelId = 'wechat' | 'qq' | 'douyin';
type ShareChannel = {
id: ShareChannelId;
label: string;
iconClassName: string;
};
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。
const SHARE_CHANNEL_ICON_PATHS: Record<ShareChannelId, string> = {
wechat:
'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
qq: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
douyin:
'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
};
const SHARE_CHANNELS = [
{
id: 'wechat',
label: '微信',
icon: MessageCircle,
className: 'bg-emerald-500 text-white',
iconClassName: 'bg-[#07c160] text-white',
},
{
id: 'qq',
label: 'QQ',
icon: MessageCircle,
className: 'bg-sky-500 text-white',
iconClassName: 'bg-[#12b7f5] text-white',
},
{
id: 'douyin',
label: '抖音',
icon: Music2,
className: 'bg-slate-950 text-white',
iconClassName: 'bg-black text-white',
},
] as const;
] as const satisfies readonly ShareChannel[];
function ShareChannelLogo({ channel }: { channel: ShareChannel }) {
const iconPath = SHARE_CHANNEL_ICON_PATHS[channel.id];
if (channel.id === 'douyin') {
return (
<svg
viewBox="-1 -1 26 26"
aria-hidden="true"
focusable="false"
className="h-6 w-6 overflow-visible"
data-share-channel-logo={channel.id}
data-testid={`share-channel-logo-${channel.id}`}
>
<path d={iconPath} fill="#25f4ee" transform="translate(-0.75 0.45)" />
<path d={iconPath} fill="#fe2c55" transform="translate(0.75 -0.45)" />
<path d={iconPath} fill="currentColor" />
</svg>
);
}
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
className="h-6 w-6"
data-share-channel-logo={channel.id}
data-testid={`share-channel-logo-${channel.id}`}
>
<path d={iconPath} fill="currentColor" />
</svg>
);
}
/**
* 发布完成后的分享弹窗。
@@ -98,8 +146,6 @@ export function PublishShareModal({
footer={
<div className="grid w-full grid-cols-3 gap-3">
{SHARE_CHANNELS.map((channel) => {
const Icon = channel.icon;
return (
<button
key={channel.id}
@@ -110,9 +156,9 @@ export function PublishShareModal({
title={channel.label}
>
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.className}`}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
>
<Icon className="h-5 w-5" />
<ShareChannelLogo channel={channel} />
</span>
<span>{channel.label}</span>
</button>

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
@@ -104,20 +104,12 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
const rpgButton = screen.getByRole('button', { name: //u });
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', { name: //u });
expect(
rpgButton.compareDocumentPosition(puzzleButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(
within(match3dButton).getAllByText('经典消除玩法').length,
).toBeGreaterThan(0);
expect(puzzleButton).toBeTruthy();
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
rerender(
<CustomWorldCreationHub

View File

@@ -42,10 +42,11 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('角色扮演');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).not.toContain('角色扮演');
expect(html).not.toContain('大鱼吃小鱼');
expect(html).not.toContain('抓大鹅');
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {

View File

@@ -14,8 +14,8 @@ import {
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
getHostileNpcSceneBottomOffsetPx,
getMonsterWorldLeft,
getMirroredStageEntityLeft,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneNpcVisualBottomOffsetPx,
MONSTER_COMBAT_HP_TOP_PX,
@@ -387,6 +387,53 @@ describe('GameCanvasEntityLayer', () => {
expect(html).toContain('查看后排乙详情');
});
it('hides opposite scene actors while the player exits for a scene transition', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
]}
currentScenePreset={null}
sceneTransitionToken={1}
isSceneTransitionEntering={false}
isSceneTransitionExiting={true}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="exiting"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.RUN}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[createHostileNpc({ name: '旧场景敌人' })]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).not.toContain('查看旧场景敌人详情');
expect(html).not.toContain('查看主角色详情');
expect(html).not.toContain('查看后排甲详情');
});
it('keeps hostile combatant identity stable while attack position changes', () => {
const sideAnchor = '15%';
const cameraAnchorX = 0;

View File

@@ -114,6 +114,22 @@ function addCssPxOffset(value: string, offsetPx: number) {
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
}
function getSceneTransitionMotionConfig(
isEntering: boolean,
isExiting: boolean,
transitionSweepPx: number,
durationS: number,
) {
return {
initial: isEntering ? {x: -transitionSweepPx} : false,
animate: {x: isExiting ? transitionSweepPx : 0},
transition: {
duration: isExiting ? durationS : isEntering ? durationS : 0.18,
ease: 'linear' as const,
},
};
}
function CombatFloatingNumber({
event,
onDone,
@@ -451,7 +467,9 @@ export function GameCanvasEntityLayer({
</div>
</motion.div>
{sceneCombatants.map((hostileNpc, index) => {
{sceneTransitionPhase === 'exiting'
? null
: sceneCombatants.map((hostileNpc, index) => {
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
const hostileRenderKey = [
hostileNpc.id,
@@ -465,9 +483,15 @@ export function GameCanvasEntityLayer({
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
: null;
const npcSceneSpriteFacing =
npcCharacter
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
isSceneTransitionEntering
? 'right'
: npcCharacter
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const hostileNpcAnimation =
isSceneTransitionEntering
? ('move' as const)
: hostileNpc.animation;
const npcCombatHpTop = getNpcCombatHpTop(
npcCharacter ? npcEncounter?.characterId : null,
npcCharacter ? null : npcEncounter?.monsterPresetId,
@@ -498,10 +522,20 @@ export function GameCanvasEntityLayer({
)
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
const motionConfig = getSceneTransitionMotionConfig(
isSceneTransitionEntering,
isSceneTransitionExiting,
transitionSweepPx,
sceneTransitionEntryDurationS,
);
return (
<div
<motion.div
key={hostileRenderKey}
className="absolute"
initial={motionConfig.initial}
animate={motionConfig.animate}
transition={motionConfig.transition}
style={{
left: getHostileNpcOuterLeft(hostileNpc),
bottom: entityBottom,
@@ -526,7 +560,11 @@ export function GameCanvasEntityLayer({
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
{npcCharacter ? (
<RoleCharacterSprite
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
state={
isSceneTransitionEntering
? AnimationState.RUN
: hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)
}
character={npcCharacter}
facing={npcSceneSpriteFacing}
/>
@@ -534,8 +572,8 @@ export function GameCanvasEntityLayer({
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
<HostileNpcAnimator
hostileNpc={npcMonsterConfig}
animation={hostileNpc.animation}
flip={hostileNpc.facing === 'right'}
animation={hostileNpcAnimation}
flip={npcSceneSpriteFacing === 'right'}
className="scale-[1.82] origin-bottom"
/>
</div>
@@ -561,11 +599,11 @@ export function GameCanvasEntityLayer({
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
</motion.div>
);
})}
{shouldRenderPeacefulEncounter &&
{sceneTransitionPhase !== 'exiting' && shouldRenderPeacefulEncounter &&
(() => {
if (!encounter) {
return null;
@@ -594,11 +632,23 @@ export function GameCanvasEntityLayer({
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
: stageLiftPx + peacefulHostileBottomOffsetPx;
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
const peacefulNpcSpriteFacing = isSceneTransitionEntering
? 'right'
: towardPeacefulPlayer;
const motionConfig = getSceneTransitionMotionConfig(
isSceneTransitionEntering,
isSceneTransitionExiting,
transitionSweepPx,
sceneTransitionEntryDurationS,
);
return (
<div
<motion.div
className="absolute"
initial={motionConfig.initial}
animate={motionConfig.animate}
transition={motionConfig.transition}
style={{
left: getMonsterWorldLeft(
sideAnchor,
@@ -639,7 +689,7 @@ export function GameCanvasEntityLayer({
!encounter.visual &&
!encounter.imageSrc?.trim() ? (
<RoleCharacterSprite
state={AnimationState.IDLE}
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
character={peacefulResolvedCharacter}
facing={peacefulNpcSpriteFacing}
/>
@@ -647,13 +697,13 @@ export function GameCanvasEntityLayer({
<HostileNpcAnimator
hostileNpc={peacefulMonsterConfig}
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
flip={towardPeacefulPlayer === 'right'}
flip={peacefulNpcSpriteFacing === 'right'}
className="scale-[1.82] origin-bottom"
/>
) : (
<SceneEncounterNpcSprite
encounter={encounter}
state={AnimationState.IDLE}
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
facing={peacefulNpcSpriteFacing}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
/>
@@ -672,11 +722,12 @@ export function GameCanvasEntityLayer({
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
</motion.div>
);
})()}
{!inBattle &&
sceneTransitionPhase !== 'exiting' &&
sceneActAmbientEncounters.map((ambientEncounter, index) => {
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
if (ambientOffsetPx === undefined) {
@@ -708,6 +759,9 @@ export function GameCanvasEntityLayer({
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
playerX,
);
const ambientSpriteFacing = isSceneTransitionEntering
? 'right'
: ambientFacing;
const ambientBottom = ambientEncounter.characterId
? getEncounterCharacterOpponentBottom(
groundBottom,
@@ -717,10 +771,20 @@ export function GameCanvasEntityLayer({
)
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
const motionConfig = getSceneTransitionMotionConfig(
isSceneTransitionEntering,
isSceneTransitionExiting,
transitionSweepPx,
sceneTransitionEntryDurationS,
);
return (
<div
<motion.div
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
className="absolute"
initial={motionConfig.initial}
animate={motionConfig.animate}
transition={motionConfig.transition}
style={{
left: getMonsterWorldLeft(
sideAnchor,
@@ -751,22 +815,22 @@ export function GameCanvasEntityLayer({
!ambientEncounter.visual &&
!ambientEncounter.imageSrc?.trim() ? (
<RoleCharacterSprite
state={AnimationState.IDLE}
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
character={ambientResolvedCharacter}
facing={ambientFacing}
facing={ambientSpriteFacing}
/>
) : ambientMonsterConfig ? (
<HostileNpcAnimator
hostileNpc={ambientMonsterConfig}
animation="idle"
flip={ambientFacing === 'right'}
flip={ambientSpriteFacing === 'right'}
className="scale-[1.82] origin-bottom"
/>
) : (
<SceneEncounterNpcSprite
encounter={ambientEncounter}
state={AnimationState.IDLE}
facing={ambientFacing}
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
facing={ambientSpriteFacing}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
/>
)}
@@ -777,7 +841,7 @@ export function GameCanvasEntityLayer({
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
</motion.div>
);
})}
</>

View File

@@ -659,15 +659,12 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
function buildPuzzleCompileActionFromFormPayload(
payload: CreatePuzzleAgentSessionRequest | null,
): PuzzleAgentActionRequest {
const workTitle = payload?.workTitle?.trim() || payload?.seedText?.trim();
const workDescription = payload?.workDescription?.trim();
const pictureDescription = payload?.pictureDescription?.trim();
const pictureDescription =
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
return {
action: 'compile_puzzle_draft',
promptText: pictureDescription || workTitle,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
promptText: pictureDescription,
...(pictureDescription ? { pictureDescription } : {}),
referenceImageSrc: payload?.referenceImageSrc || null,
imageModel: payload?.imageModel ?? null,
@@ -679,28 +676,15 @@ function buildPuzzleFormPayloadFromSession(
session: PuzzleAgentSessionSnapshot,
): CreatePuzzleAgentSessionRequest {
const formDraft = session.draft?.formDraft;
const workTitle =
formDraft?.workTitle?.trim() ||
session.draft?.workTitle?.trim() ||
session.draft?.levelName?.trim() ||
session.anchorPack.themePromise.value.trim() ||
session.seedText?.trim() ||
'';
const workDescription =
formDraft?.workDescription?.trim() ||
session.draft?.workDescription?.trim() ||
session.draft?.summary?.trim() ||
'';
const pictureDescription =
formDraft?.pictureDescription?.trim() ||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
session.anchorPack.visualSubject.value.trim() ||
session.seedText?.trim() ||
'';
return {
seedText: workTitle,
workTitle,
workDescription,
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: null,
imageModel: null,
@@ -723,9 +707,9 @@ function buildPuzzleFormPayloadFromAction(
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
return {
seedText: workTitle,
workTitle,
workDescription,
seedText: pictureDescription,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
pictureDescription,
referenceImageSrc:
payload.action === 'compile_puzzle_draft'
@@ -920,6 +904,10 @@ export function PlatformEntryFlowShellImpl({
initialPublicWorkCode,
}: PlatformEntryFlowShellProps) {
const authUi = useAuthUi();
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
@@ -1662,7 +1650,10 @@ export function PlatformEntryFlowShellImpl({
setPuzzleFormDraftPayload(formPayload);
}
if (payload.action === 'publish_puzzle_work') {
if (
payload.action === 'publish_puzzle_work' ||
payload.action === 'generate_puzzle_tags'
) {
await Promise.allSettled([
refreshPuzzleShelf(),
refreshPuzzleGallery(),
@@ -1836,8 +1827,6 @@ export function PlatformEntryFlowShellImpl({
const response = await executePuzzleAgentAction(session.sessionId, {
action: 'save_puzzle_form_draft',
promptText: payload.pictureDescription ?? null,
workTitle: payload.workTitle ?? payload.seedText ?? '',
workDescription: payload.workDescription ?? '',
pictureDescription: payload.pictureDescription ?? '',
imageModel: payload.imageModel ?? null,
});
@@ -5057,9 +5046,7 @@ export function PlatformEntryFlowShellImpl({
}
isBusy={isPuzzleBusy}
error={puzzleError}
onBack={() => {
setSelectionStage('puzzle-agent-workspace');
}}
onBack={leavePuzzleFlow}
onExecuteAction={(payload) => {
void executePuzzleAction(payload);
}}
@@ -5463,6 +5450,8 @@ export function PlatformEntryFlowShellImpl({
closeDisabled={Boolean(deletingCreationWorkId)}
closeOnBackdrop={!deletingCreationWorkId}
size="sm"
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
panelClassName="platform-remap-surface rounded-[1.75rem]"
footer={
<>
<button

View File

@@ -28,13 +28,15 @@ test('platform creation types are derived from new work entry config', () => {
test('new work entry config controls visibility and open order', () => {
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
expect(visibleIds).not.toContain('rpg');
expect(visibleIds).not.toContain('big-fish');
expect(visibleIds[0]).toBe('rpg');
expect(visibleIds).not.toContain('match3d');
expect(visibleIds[0]).toBe('puzzle');
expect(visibleIds).toEqual([
'rpg',
'puzzle',
'match3d',
'airp',
'visual-novel',
]);

View File

@@ -83,21 +83,18 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
/>,
);
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
expect(screen.queryByLabelText('作品名称')).toBeNull();
expect(screen.queryByLabelText('作品描述')).toBeNull();
expect(screen.getByText('创建拼图')).toBeTruthy();
expect(screen.queryByText('try')).toBeNull();
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '暖灯猫街',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
@@ -107,6 +104,35 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace applies a creation template prompt', () => {
const onCreateFromForm = vi.fn();
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '宠物可爱拼图模板' }));
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
);
expect(screen.getAllByText('宠物可爱拼图').length).toBeGreaterThan(1);
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
pictureDescription:
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
}),
);
});
test('puzzle workspace falls back to compile action for restored sessions', () => {
const onExecuteAction = vi.fn();
const onCreateFromForm = vi.fn();
@@ -126,10 +152,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
expect(onCreateFromForm).not.toHaveBeenCalled();
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'compile_puzzle_draft',
promptText: '潮雾中的灯塔与断桥',
workTitle: '雾港遗迹拼图',
workDescription: '雾港遗迹拼图',
pictureDescription: '潮雾中的灯塔与断桥',
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
candidateCount: 1,
@@ -149,12 +173,6 @@ test('puzzle workspace switches the image model from the description box', () =>
/>,
);
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
@@ -175,8 +193,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
const onAutoSaveForm = vi.fn();
const formDraftSession: PuzzleAgentSessionSnapshot = {
...baseSession,
seedText:
'作品名称:旧街拼图\n作品描述旧街雨夜的拼图草稿。\n画面描述旧街灯牌下的猫。',
seedText: '画面描述:旧街灯牌下的猫。',
draft: {
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
@@ -204,8 +221,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
},
],
formDraft: {
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫。',
},
},
@@ -221,12 +236,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
/>,
);
expect((screen.getByLabelText('作品名称') as HTMLInputElement).value).toBe(
'旧街拼图',
);
expect((screen.getByLabelText('作品描述') as HTMLTextAreaElement).value).toBe(
'旧街雨夜的拼图草稿。',
);
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
'旧街灯牌下的猫。',
);
@@ -240,9 +249,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
});
expect(onAutoSaveForm).toHaveBeenCalledWith({
seedText: '旧街拼图',
workTitle: '旧街拼图',
workDescription: '旧街雨夜的拼图草稿。',
seedText: '旧街灯牌下的猫和发光雨伞。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',

View File

@@ -8,6 +8,7 @@ import type {
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { PUZZLE_CREATION_TEMPLATES } from './puzzleCreationTemplates';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -28,8 +29,6 @@ type PuzzleAgentWorkspaceProps = {
};
type PuzzleFormState = {
workTitle: string;
workDescription: string;
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
@@ -37,8 +36,6 @@ type PuzzleFormState = {
};
const EMPTY_FORM_STATE: PuzzleFormState = {
workTitle: '',
workDescription: '',
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
@@ -52,8 +49,6 @@ function resolveInitialFormState(
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
workTitle: formDraft.workTitle ?? '',
workDescription: formDraft.workDescription ?? '',
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
@@ -65,10 +60,10 @@ function resolveInitialFormState(
if (initialFormPayload) {
return {
workTitle:
initialFormPayload.workTitle ?? initialFormPayload.seedText ?? '',
workDescription: initialFormPayload.workDescription ?? '',
pictureDescription: initialFormPayload.pictureDescription ?? '',
pictureDescription:
initialFormPayload.pictureDescription ??
initialFormPayload.seedText ??
'',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
@@ -82,19 +77,12 @@ function resolveInitialFormState(
}
return {
workTitle:
session.draft?.workTitle ||
session.draft?.levelName ||
session.seedText ||
session.anchorPack.themePromise.value ||
session.messages.find((message) => message.role === 'user')?.text ||
'',
workDescription:
session.draft?.workDescription ||
session.anchorPack.themePromise.value ||
'',
pictureDescription:
session.draft?.summary || session.anchorPack.visualSubject.value || '',
session.draft?.formDraft?.pictureDescription ||
session.draft?.levels?.[0]?.pictureDescription ||
session.anchorPack.visualSubject.value ||
session.seedText ||
'',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -121,6 +109,9 @@ export function PuzzleAgentWorkspace({
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [selectedTemplateId, setSelectedTemplateId] = useState(
PUZZLE_CREATION_TEMPLATES[0]?.id ?? '',
);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
@@ -148,18 +139,13 @@ export function PuzzleAgentWorkspace({
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
}, [initialFormPayload, session?.sessionId]);
}, [initialFormPayload, session]);
const workTitle = formState.workTitle.trim();
const workDescription = formState.workDescription.trim();
const pictureDescription = formState.pictureDescription.trim();
const canSubmit =
Boolean(workTitle && workDescription && pictureDescription) && !isBusy;
const canSubmit = Boolean(pictureDescription) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: workTitle,
workTitle,
workDescription,
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
@@ -168,13 +154,9 @@ export function PuzzleAgentWorkspace({
formState.referenceImageSrc,
formState.imageModel,
pictureDescription,
workDescription,
workTitle,
],
);
const autosaveSignature = JSON.stringify([
autosavePayload.workTitle,
autosavePayload.workDescription,
autosavePayload.pictureDescription,
autosavePayload.imageModel,
]);
@@ -189,7 +171,7 @@ export function PuzzleAgentWorkspace({
autosaveSessionIdRef.current = currentSessionId;
lastAutosaveSignatureRef.current = autosaveSignature;
}, [autosaveSignature, session?.sessionId]);
}, [autosaveSignature, session]);
useEffect(() => {
if (
@@ -214,7 +196,7 @@ export function PuzzleAgentWorkspace({
onAutoSaveForm,
session?.draft?.formDraft,
session?.stage,
session?.sessionId,
session,
]);
const handleReferenceImageChange = async (
@@ -243,15 +225,28 @@ export function PuzzleAgentWorkspace({
}
};
const applyTemplatePrompt = (templateId: string) => {
const template = PUZZLE_CREATION_TEMPLATES.find(
(item) => item.id === templateId,
);
if (!template) {
return;
}
setSelectedTemplateId(template.id);
setFormState((current) => ({
...current,
pictureDescription: template.prompt,
}));
};
const submitForm = () => {
if (!canSubmit) {
return;
}
const payload = {
seedText: workTitle,
workTitle,
workDescription,
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
@@ -265,8 +260,6 @@ export function PuzzleAgentWorkspace({
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: pictureDescription,
workTitle,
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
@@ -275,7 +268,7 @@ export function PuzzleAgentWorkspace({
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
@@ -291,61 +284,107 @@ export function PuzzleAgentWorkspace({
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="space-y-5">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
<div className="mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-5xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
</div>
<section className="platform-subpanel overflow-hidden rounded-[1.5rem] p-4 sm:p-5">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3 sm:p-4">
<div className="mb-3 flex min-h-6 items-center justify-between gap-3">
<span className="text-xs font-black text-[var(--platform-text-soft)]">
Template
</span>
<span className="max-w-[11rem] truncate text-xs font-black text-[var(--platform-text-strong)]">
{PUZZLE_CREATION_TEMPLATES.find(
(item) => item.id === selectedTemplateId,
)?.title ?? PUZZLE_CREATION_TEMPLATES[0]?.title}
</span>
</div>
<div
className="flex gap-3 overflow-x-auto pb-2"
aria-label="拼图创作模板"
>
{PUZZLE_CREATION_TEMPLATES.map((template) => {
const selected = template.id === selectedTemplateId;
return (
<button
key={template.id}
type="button"
disabled={isBusy}
onClick={() => applyTemplatePrompt(template.id)}
className={`min-h-[10.2rem] w-[7.45rem] shrink-0 rounded-[1rem] border p-2 text-left transition ${
selected
? 'border-emerald-300 bg-emerald-50/86 shadow-[0_0_0_1px_rgba(16,185,129,0.18)]'
: 'border-[var(--platform-subpanel-border)] bg-white/82 hover:bg-white'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
aria-label={`${template.title}模板`}
>
<span className="block aspect-square overflow-hidden rounded-[0.8rem] bg-[var(--platform-subpanel-fill)]">
<img
src={template.imageSrc}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
</span>
<span className="mt-2 block min-h-8 overflow-hidden text-ellipsis text-xs font-black leading-4 text-[var(--platform-text-strong)]">
{template.title}
</span>
{selected ? (
<span className="mt-2 inline-flex max-w-full rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-black text-emerald-700">
</span>
) : null}
</button>
);
})}
</div>
</div>
<div className="mt-4 space-y-4">
<label
className={`inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm transition hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span>
{formState.referenceImageSrc ? '更换参考图' : '上传参考图'}
</span>
<input
value={formState.workTitle}
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
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-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="作品名称"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
disabled={isBusy}
rows={4}
onChange={(event) =>
setFormState((current) => ({
...current,
workDescription: event.target.value,
}))
}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="作品描述"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<div className="relative mt-2">
<span className="sr-only"></span>
<div className="relative">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={10}
placeholder="一只猫在雨夜灯牌下回头,霓虹反光清晰,街角有花店和小伞,适合切成拼图。"
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
className="min-h-[18rem] w-full resize-none rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[20rem]"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
@@ -358,26 +397,6 @@ export function PuzzleAgentWorkspace({
}))
}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={
formState.referenceImageSrc ? '更换参考图' : '添加参考图'
}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
</div>
</label>

View File

@@ -0,0 +1,94 @@
export type PuzzleCreationTemplate = {
id: string;
title: string;
imageSrc: string;
prompt: string;
};
// 中文注释:模板只服务入口快速填词,正式作品信息仍在结果页补全。
export const PUZZLE_CREATION_TEMPLATES: PuzzleCreationTemplate[] = [
{
id: 'couple-memory',
title: '情侣合照拼图',
imageSrc: '/puzzle-creation-templates/couple-memory.webp',
prompt:
'温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确。',
},
{
id: 'family-keepsake',
title: '家庭纪念拼图',
imageSrc: '/puzzle-creation-templates/family-keepsake.webp',
prompt:
'三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。',
},
{
id: 'friends-party',
title: '朋友聚会拼图',
imageSrc: '/puzzle-creation-templates/friends-party.webp',
prompt:
'朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹。',
},
{
id: 'festival-card',
title: '节日贺卡拼图',
imageSrc: '/puzzle-creation-templates/festival-card.webp',
prompt:
'节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨。',
},
{
id: 'knowledge-summary',
title: '知识总结拼图',
imageSrc: '/puzzle-creation-templates/knowledge-summary.webp',
prompt:
'一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确。',
},
{
id: 'product-detail',
title: '商品细节拼图',
imageSrc: '/puzzle-creation-templates/product-detail.webp',
prompt:
'精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净。',
},
{
id: 'healing-landscape',
title: '治愈风景拼图',
imageSrc: '/puzzle-creation-templates/healing-landscape.webp',
prompt:
'治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和。',
},
{
id: 'cute-pet',
title: '宠物可爱拼图',
imageSrc: '/puzzle-creation-templates/cute-pet.webp',
prompt:
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净。',
},
{
id: 'hot-topic-poster',
title: '热点海报拼图',
imageSrc: '/puzzle-creation-templates/hot-topic-poster.webp',
prompt:
'电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字。',
},
{
id: 'event-invitation',
title: '活动邀请拼图',
imageSrc: '/puzzle-creation-templates/event-invitation.webp',
prompt:
'活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字。',
},
{
id: 'daily-challenge',
title: '每日挑战拼图',
imageSrc: '/puzzle-creation-templates/daily-challenge.webp',
prompt:
'每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解。',
},
{
id: 'children-learning',
title: '儿童认知拼图',
imageSrc: '/puzzle-creation-templates/children-learning.webp',
prompt:
'儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字。',
},
];

View File

@@ -104,11 +104,12 @@ function createSession(
stage: 'ready_to_publish',
anchorPack,
draft: {
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
workTitle: overrides.draft?.workTitle ?? '暖灯猫街作品',
workDescription:
overrides.draft?.workDescription ?? '一套雨夜猫街主题拼图。',
levelName: level.levelName,
summary: level.pictureDescription,
themeTags: ['猫咪', '雨夜', '暖灯'],
themeTags: overrides.draft?.themeTags ?? ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
@@ -119,6 +120,7 @@ function createSession(
generationStatus: 'ready',
levels: [level],
metadata: null,
...overrides.draft,
},
messages: [],
lastAssistantReply: null,
@@ -199,7 +201,7 @@ describe('PuzzleResultView', () => {
workTitle: '暖灯猫街合集',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levels: expect.arrayContaining([
expect.objectContaining({
@@ -250,7 +252,7 @@ describe('PuzzleResultView', () => {
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一只猫在雨夜灯牌下回头。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
@@ -280,7 +282,7 @@ describe('PuzzleResultView', () => {
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
summary: '一只猫在雨夜灯牌下回头。',
summary: '一套雨夜猫街主题拼图。',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
@@ -386,7 +388,7 @@ describe('PuzzleResultView', () => {
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '新关卡里有一座发光钟楼。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
@@ -427,7 +429,7 @@ describe('PuzzleResultView', () => {
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
}),
);
@@ -440,6 +442,57 @@ describe('PuzzleResultView', () => {
]);
});
test('generates six tags after work title and description are filled', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession({
draft: {
...createSession().draft!,
workTitle: '雨夜猫街',
workDescription: '',
themeTags: [],
},
resultPreview: {
draft: createSession().draft!,
publishReady: false,
blockers: [
{
id: 'invalid-tag-count',
code: 'INVALID_TAG_COUNT',
message: '正式标签数量必须在 3 到 6 之间',
},
],
qualityFindings: [],
},
})}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
expect(screen.getByText('请先填写作品名称和作品描述。')).toBeTruthy();
expect(onExecuteAction).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_tags',
workTitle: '雨夜猫街',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '一套雨夜猫街主题拼图。',
themeTags: [],
levelsJson: expect.any(String),
});
});
test('selects a history puzzle asset as reference image for the selected level', async () => {
const onExecuteAction = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
@@ -496,7 +549,7 @@ describe('PuzzleResultView', () => {
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '屋檐下的猫与暖灯街角。',
summary: '一套雨夜猫街主题拼图。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});

View File

@@ -129,7 +129,7 @@ function syncDraftFromEditState(
const primaryLevel = levels[0] ?? buildFallbackLevelFromDraft(draft);
return {
...draft,
workTitle: editState.workTitle.trim() || draft.workTitle,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
levelName: primaryLevel.levelName,
summary: editState.workDescription.trim(),
@@ -145,8 +145,8 @@ function syncDraftFromEditState(
function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
return {
workTitle: draft.workTitle || draft.levelName,
workDescription: draft.workDescription || '',
workTitle: draft.workTitle ?? '',
workDescription: draft.workDescription ?? '',
themeTags: normalizeThemeTagInput(draft.themeTags.join('')),
levels: normalizeDraftLevels(draft),
};
@@ -219,16 +219,7 @@ function buildPublishReady(
return {
blockers: [...new Set(blockers.filter(Boolean))],
publishReady:
Boolean(session.resultPreview?.publishReady) &&
Boolean(editState.workTitle.trim()) &&
Boolean(editState.workDescription.trim()) &&
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
levels.length > 0 &&
levels.every(
(level) => level.levelName.trim() && resolveLevelFormalImageSrc(level),
),
publishReady: blockers.filter(Boolean).length === 0,
};
}
@@ -308,11 +299,15 @@ function PuzzleResultTabs({
function PuzzleThemeTagEditor({
editState,
isBusy,
error,
onChange,
onGenerateTags,
}: {
editState: DraftEditState;
isBusy: boolean;
error: string | null;
onChange: (nextState: DraftEditState) => void;
onGenerateTags: () => void;
}) {
const [newTagText, setNewTagText] = useState('');
const [isAddingTag, setIsAddingTag] = useState(false);
@@ -339,18 +334,34 @@ function PuzzleThemeTagEditor({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{!isAddingTag ? (
<div className="flex items-center gap-2">
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
onClick={onGenerateTags}
className="platform-icon-button h-9 w-9"
aria-label="新增作品标签"
title="新增作品标签"
aria-label="AI生成作品标签"
title="AI生成作品标签"
>
<Plus className="h-4 w-4" />
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</button>
) : null}
{!isAddingTag ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsAddingTag(true)}
className="platform-icon-button h-9 w-9"
aria-label="新增作品标签"
title="新增作品标签"
>
<Plus className="h-4 w-4" />
</button>
) : null}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
@@ -430,6 +441,11 @@ function PuzzleThemeTagEditor({
</div>
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</section>
);
}
@@ -1191,12 +1207,16 @@ function PuzzleLevelListTab({
function PuzzleWorkInfoTab({
editState,
tagGenerationError,
isBusy,
onChange,
onGenerateTags,
}: {
editState: DraftEditState;
tagGenerationError: string | null;
isBusy: boolean;
onChange: (nextState: DraftEditState) => void;
onGenerateTags: () => void;
}) {
return (
<div className="space-y-3">
@@ -1233,8 +1253,10 @@ function PuzzleWorkInfoTab({
<PuzzleThemeTagEditor
editState={editState}
error={tagGenerationError}
isBusy={isBusy}
onChange={onChange}
onGenerateTags={onGenerateTags}
/>
</div>
);
@@ -1304,6 +1326,9 @@ export function PuzzleResultView({
const [autoSaveState, setAutoSaveState] =
useState<PuzzleAutoSaveState>('idle');
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
null,
);
const savedEditStateRef = useRef<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
);
@@ -1314,6 +1339,7 @@ export function PuzzleResultView({
setActiveLevelId(null);
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
return;
}
const nextState = createDraftEditState(draft);
@@ -1327,6 +1353,7 @@ export function PuzzleResultView({
);
setAutoSaveState('idle');
setAutoSaveError(null);
setTagGenerationError(null);
}, [draft]);
const syncedDraft = useMemo(() => {
@@ -1445,7 +1472,7 @@ export function PuzzleResultView({
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
...syncedDraft,
levelName: level.levelName,
summary: level.pictureDescription,
summary: editState.workDescription.trim(),
candidates: level.candidates,
selectedCandidateId: level.selectedCandidateId,
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
@@ -1498,8 +1525,28 @@ export function PuzzleResultView({
) : (
<PuzzleWorkInfoTab
editState={editState}
tagGenerationError={tagGenerationError}
isBusy={isBusy}
onChange={setEditState}
onGenerateTags={() => {
const workTitle = editState.workTitle.trim();
const workDescription = editState.workDescription.trim();
if (!workTitle || !workDescription) {
setTagGenerationError('请先填写作品名称和作品描述。');
return;
}
setTagGenerationError(null);
const firstLevel = editState.levels[0] ?? null;
onExecuteAction({
action: 'generate_puzzle_tags',
workTitle,
workDescription,
levelName: firstLevel?.levelName.trim(),
summary: workDescription,
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});
}}
/>
)}
</div>

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Character, CustomWorldProfile } from '../../types';
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
import {
CustomWorldEntityCatalog,
type ResultTab,
@@ -91,10 +92,19 @@ export function RpgCreationResultView({
qualityFindings = [],
}: RpgCreationResultViewProps) {
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const [openingCgGenerating, setOpeningCgGenerating] = useState(false);
const [openingCgGenerationError, setOpeningCgGenerationError] = useState<
string | null
>(null);
const latestProfileRef = useRef(profile);
const assetDebugEnabled = useMemo(
() => shouldEnableRpgCreationAssetDebugPanel(),
[],
);
useEffect(() => {
latestProfileRef.current = profile;
}, [profile]);
const {
closeEditorTarget,
createLabel,
@@ -133,6 +143,32 @@ export function RpgCreationResultView({
}
: handleDeleteLandmarks;
const handleGenerateOpeningCg = async () => {
if (readOnly || isGenerating || openingCgGenerating) {
return;
}
setOpeningCgGenerating(true);
setOpeningCgGenerationError(null);
try {
const openingCg = await rpgCreationAssetClient.generateOpeningCg({
profile: latestProfileRef.current,
});
onProfileChange({
...latestProfileRef.current,
openingCg,
});
} catch (generationError) {
setOpeningCgGenerationError(
generationError instanceof Error
? generationError.message
: '生成开局 CG 失败',
);
} finally {
setOpeningCgGenerating(false);
}
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
<RpgCreationResultHeader
@@ -152,6 +188,14 @@ export function RpgCreationResultView({
onProfileChange={onProfileChange}
onDeleteStoryNpcs={deleteStoryNpcs}
onDeleteLandmarks={deleteLandmarks}
openingCgGenerating={openingCgGenerating}
openingCgPhaseLabel={
openingCgGenerating ? '正在生成开局 CG' : null
}
openingCgGenerateDisabled={isGenerating}
onGenerateOpeningCg={
readOnly ? undefined : () => void handleGenerateOpeningCg()
}
createActionLabel={
readOnly || (compactAgentResultMode && !onGenerateEntity)
? undefined
@@ -227,6 +271,11 @@ export function RpgCreationResultView({
{localGenerationError}
</div>
) : null}
{!error && openingCgGenerationError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{openingCgGenerationError}
</div>
) : null}
{assetDebugEnabled ? (
<RpgCreationAssetDebugPanel profile={profile} />
) : null}

View File

@@ -138,7 +138,9 @@ async function clickFirstAsyncButtonByName(
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('角色扮演')).toBeTruthy();
expect(
await screen.findByRole('button', { name: /.*/u }),
).toBeTruthy();
}
async function openExistingRpgDraft(
@@ -1867,7 +1869,7 @@ beforeEach(() => {
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
});
test('create hub opens RPG while keeping AIRP and visual novel locked', async () => {
test('create hub hides RPG and Match3D while keeping AIRP and visual novel locked', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
@@ -1881,15 +1883,10 @@ test('create hub opens RPG while keeping AIRP and visual novel locked', async ()
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('platform create hub does not prefetch hidden big fish platform data', async () => {
@@ -1900,7 +1897,7 @@ test('platform create hub does not prefetch hidden big fish platform data', asyn
await openCreationHub(user);
expect(
await screen.findByRole('button', { name: //u }),
await screen.findByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(listBigFishWorks).not.toHaveBeenCalled();
@@ -2643,7 +2640,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
});
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
test('selecting puzzle creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2658,12 +2655,15 @@ test('selecting RPG creation while logged out routes through requireAuth', async
);
await openCreationHub(user);
const rpgButton = await screen.findByRole('button', { name: //u });
const puzzleButton = await screen.findByRole('button', {
name: /.*/u,
});
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
@@ -2772,10 +2772,10 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
).toBeTruthy();
});
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
test('new puzzle creation entry maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
new ApiClientError({
message: '缺少 Authorization Bearer Token',
status: 401,
@@ -2786,13 +2786,15 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
render(<TestWrapper withAuth />);
await openCreationHub(user);
const rpgButton = screen.getByRole('button', { name: //u });
const puzzleButton = screen.getByRole('button', {
name: /.*/u,
});
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleButton);
expect(listPuzzleWorks).toHaveBeenCalled();
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(
await within(getPlatformTabPanel('create')).findByText(
'当前登录状态已失效,请重新登录后继续。',
@@ -2839,7 +2841,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
expect(screen.queryByText(//u)).toBeNull();
});
test('match3d creation card opens workspace even when public galleries fail', async () => {
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
const user = userEvent.setup();
const match3dSession = buildMockMatch3DAgentSession();
@@ -2858,20 +2860,13 @@ test('match3d creation card opens workspace even when public galleries fail', as
await openCreationHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
const button = screen.getByRole('button', {
name: /.*/u,
});
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
await user.click(button);
await waitFor(() => {
expect(match3dCreationClient.createSession).toHaveBeenCalledWith({});
});
expect(await screen.findByText('抓大鹅工作区match3d-agent-session-1')).toBeTruthy();
expect(
screen.queryByRole('button', { name: /.*/u }),
).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('puzzle draft card restores the bound agent session and opens the result view', async () => {
test('puzzle draft result back button returns to creation hub', async () => {
const user = userEvent.setup();
vi.mocked(listPuzzleWorks).mockResolvedValue({
@@ -2913,9 +2908,12 @@ test('puzzle draft card restores the bound agent session and opens the result vi
await user.click(screen.getByRole('button', { name: '返回' }));
expect(
await screen.findByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
await screen.findByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.queryByText('拼图玩法共创')).toBeNull();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
test('published puzzle work card restores its source session for editing', async () => {
@@ -4361,7 +4359,9 @@ test('agent draft result back button returns to creation hub without syncing res
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByText('角色扮演')).toBeTruthy();
expect(
screen.getByRole('button', { name: /.*/u }),
).toBeTruthy();
});
expect(
@@ -4677,13 +4677,17 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('角色扮演')).toBeTruthy();
expect(
await screen.findByRole('button', { name: /.*/u }),
).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText('角色扮演'),
within(getPlatformTabPanel('create')).getByRole('button', {
name: /.*/u,
}),
).toBeTruthy();
});
@@ -5045,9 +5049,22 @@ test('creation hub published work card keeps delete action guarded by detail flo
render(<TestWrapper withAuth />);
await openCreationHub(user);
await clickFirstButtonByName(user, '创作');
expect(await screen.findByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');
expect(dialog.parentElement?.className).toContain('!items-center');
expect(dialog.className).toContain('platform-modal-shell');
expect(dialog.className).toContain('platform-remap-surface');
expect(dialog.className).toContain('rounded-[1.75rem]');
expect(
within(dialog).getByText('确认删除《潮雾列岛》吗?'),
).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: '确认删除' }),
).toBeTruthy();
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
});

View File

@@ -151,6 +151,7 @@ export function useRpgRuntimeShellViewModel(
gameState,
currentStory,
openingCampSceneId,
onDeferredAutoChoice: (option) => handleChoice(option),
});
const {
visibleGameState,
@@ -222,12 +223,24 @@ export function useRpgRuntimeShellViewModel(
const handleSceneTransitionChoice = useCallback(
(option: StoryOption) => {
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
if (transitionMode) {
const shouldBeginTransition =
transitionMode &&
(option.functionId !== 'story_continue_adventure' ||
Boolean(
currentStory?.deferredAutoChoice ||
currentStory?.deferredRuntimeState,
));
if (shouldBeginTransition) {
beginSceneTransition(transitionMode);
}
handleChoice(option);
},
[beginSceneTransition, handleChoice],
[
beginSceneTransition,
currentStory?.deferredAutoChoice,
currentStory?.deferredRuntimeState,
handleChoice,
],
);
return {

View File

@@ -0,0 +1,205 @@
/* @vitest-environment jsdom */
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
AnimationState,
type GameState,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import { useRpgSceneTransitionModel } from './useRpgSceneTransitionModel';
function createGameState(actId: string): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: {
id: 'hero',
name: '测试主角',
title: '游侠',
description: '测试角色',
backstory: '测试背景',
avatar: '',
portrait: '',
assetFolder: '',
assetVariant: '',
attributes: {
strength: 10,
agility: 10,
intelligence: 10,
spirit: 10,
},
personality: '沉稳',
skills: [],
adventureOpenings: {},
},
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [{ text: '旧幕', options: [] }],
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'scene-1',
chapterId: 'chapter-1',
currentActId: actId,
currentActIndex: actId === 'act-1' ? 0 : 1,
completedActIds: actId === 'act-1' ? [] : ['act-1'],
visitedActIds: [actId],
},
},
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: {
id: 'scene-1',
name: '断桥旧哨',
description: '测试场景',
imageSrc: '/scene.png',
treasureHints: [],
npcs: [],
},
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function createStory(
text: string,
options: StoryOption[] = [],
deferredAutoChoice?: StoryOption,
): StoryMoment {
return {
text,
options,
deferredAutoChoice,
};
}
describe('useRpgSceneTransitionModel', () => {
afterEach(() => {
vi.useRealTimers();
});
it('fires deferred auto choice only after entry and through the latest callback', () => {
vi.useFakeTimers();
const autoChoice: StoryOption = {
functionId: 'npc_preview_talk',
actionText: '与新角色交谈',
text: '与新角色交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
const firstCallback = vi.fn();
const latestCallback = vi.fn();
const initialState = createGameState('act-1');
const initialStory = createStory('旧幕收束', [
{
functionId: 'story_continue_adventure',
actionText: '继续冒险',
text: '继续冒险',
visuals: autoChoice.visuals,
},
]);
const nextStory = createStory('新幕入口', [autoChoice], autoChoice);
const { result, rerender } = renderHook(
(props: {
gameState: GameState;
currentStory: StoryMoment;
onDeferredAutoChoice: (option: StoryOption) => void;
}) =>
useRpgSceneTransitionModel({
gameState: props.gameState,
currentStory: props.currentStory,
openingCampSceneId: null,
onDeferredAutoChoice: props.onDeferredAutoChoice,
}),
{
initialProps: {
gameState: initialState,
currentStory: initialStory,
onDeferredAutoChoice: firstCallback,
},
},
);
act(() => {
result.current.setSceneTransitionDurations({ exitMs: 20, entryMs: 30 });
});
act(() => {
result.current.beginSceneTransition('content-change');
});
expect(result.current.sceneTransitionPhase).toBe('exiting');
rerender({
gameState: createGameState('act-2'),
currentStory: nextStory,
onDeferredAutoChoice: latestCallback,
});
act(() => {
vi.advanceTimersByTime(20);
});
expect(result.current.sceneTransitionPhase).toBe('entering');
expect(latestCallback).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(30);
});
expect(result.current.sceneTransitionPhase).toBe('idle');
expect(firstCallback).not.toHaveBeenCalled();
expect(latestCallback).toHaveBeenCalledWith(autoChoice);
});
});

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameState, StoryMoment } from '../../types';
import type { GameState, StoryMoment, StoryOption } from '../../types';
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
@@ -18,6 +18,7 @@ const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
Record<string, SceneTransitionTriggerMode>
> = {
story_continue_adventure: 'content-change',
idle_travel_next_scene: 'scene-change',
camp_travel_home_scene: 'scene-change',
idle_explore_forward: 'content-change',
@@ -29,6 +30,9 @@ function buildSceneTransitionContentKey(
currentStory: StoryMoment | null,
) {
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
const sceneActId =
gameState.storyEngineMemory?.currentSceneActState?.currentActId ??
'act:none';
const encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
@@ -39,9 +43,9 @@ function buildSceneTransitionContentKey(
)
.join('|');
const storyKey = currentStory
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}:${currentStory.options.map((option) => option.functionId).join('|')}:${currentStory.deferredAutoChoice?.functionId ?? 'auto:none'}`
: 'story:none';
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
return [sceneId, sceneActId, encounterKey, monsterKey, storyKey].join('::');
}
/**
@@ -52,8 +56,14 @@ export function useRpgSceneTransitionModel(params: {
gameState: GameState;
currentStory: StoryMoment | null;
openingCampSceneId: string | null;
onDeferredAutoChoice?: ((option: StoryOption) => void) | null;
}) {
const { gameState, currentStory, openingCampSceneId } = params;
const {
gameState,
currentStory,
openingCampSceneId,
onDeferredAutoChoice = null,
} = params;
const [renderGameState, setRenderGameState] = useState(gameState);
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
const [sceneTransitionPhase, setSceneTransitionPhase] =
@@ -73,6 +83,13 @@ export function useRpgSceneTransitionModel(params: {
});
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
const pendingDeferredAutoChoiceRef =
useRef<StoryOption | null>(null);
const onDeferredAutoChoiceRef = useRef(onDeferredAutoChoice);
useEffect(() => {
onDeferredAutoChoiceRef.current = onDeferredAutoChoice;
}, [onDeferredAutoChoice]);
useEffect(() => {
return () => {
@@ -81,6 +98,7 @@ export function useRpgSceneTransitionModel(params: {
);
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
pendingDeferredAutoChoiceRef.current = null;
};
}, []);
@@ -98,6 +116,15 @@ export function useRpgSceneTransitionModel(params: {
const entryTimerId = window.setTimeout(() => {
setSceneTransitionPhase('idle');
const autoChoice =
payload.currentStory?.deferredAutoChoice ??
pendingDeferredAutoChoiceRef.current;
if (autoChoice) {
pendingDeferredAutoChoiceRef.current = null;
// 中文注释:入场计时器可能跨过一次 currentStory/gameState 更新,
// 必须读取最新回调,避免用点击“继续冒险”前的旧状态自动开聊。
onDeferredAutoChoiceRef.current?.(autoChoice);
}
}, sceneTransitionDurations.entryMs);
sceneTransitionTimerIdsRef.current.push(entryTimerId);
},
@@ -109,6 +136,7 @@ export function useRpgSceneTransitionModel(params: {
if (sceneTransitionPhase !== 'idle') return;
pendingScenePayloadRef.current = { gameState, currentStory };
pendingDeferredAutoChoiceRef.current = null;
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
window.clearTimeout(timerId),
);
@@ -170,6 +198,8 @@ export function useRpgSceneTransitionModel(params: {
: buildSceneTransitionContentKey(gameState, currentStory) !==
request.baselineContentKey;
if (isReady) {
pendingDeferredAutoChoiceRef.current =
currentStory?.deferredAutoChoice ?? null;
startSceneEntering({ gameState, currentStory });
}
return;

View File

@@ -19,7 +19,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
title: '角色扮演',
subtitle: '敬请期待',
badge: '敬请期待',
visible: true,
visible: false,
open: true,
},
{
@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
visible: true,
visible: false,
open: true,
},
{

View File

@@ -398,6 +398,88 @@ describe('createStoryChoiceActions', () => {
});
});
it('keeps the deferred auto choice for the scene transition model to trigger after entry', async () => {
const state = {
...createBaseState(),
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const autoChoice = createBattleOption('npc_preview_talk');
const continueOption: StoryOption = {
functionId: 'story_continue_adventure',
actionText: '继续冒险',
text: '继续冒险',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const currentStory: StoryMoment = {
text: '对话已经完成',
options: [continueOption],
deferredOptions: [autoChoice],
deferredAutoChoice: autoChoice,
};
const setCurrentStory = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState: vi.fn(),
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState: vi.fn(),
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction: vi.fn(),
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(
(option: StoryOption) =>
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(continueOption);
expect(setCurrentStory).toHaveBeenCalledWith(
expect.objectContaining({
options: [autoChoice],
deferredOptions: undefined,
deferredAutoChoice: autoChoice,
}),
);
});
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');

View File

@@ -1,5 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
import {
@@ -185,8 +186,13 @@ export function createStoryChoiceActions({
currentStory?.deferredOptions?.length &&
isContinueAdventureOption(option)
) {
const deferredAutoChoice =
currentStory.deferredAutoChoice &&
currentStory.deferredOptions.includes(currentStory.deferredAutoChoice)
? currentStory.deferredAutoChoice
: undefined;
if (currentStory.deferredRuntimeState) {
setGameState({
const restoredState = ensureSceneEncounterPreview({
...gameState,
currentEncounter: null,
npcInteractionActive: false,
@@ -202,12 +208,15 @@ export function createStoryChoiceActions({
currentStory.deferredRuntimeState.storyEngineMemory ??
gameState.storyEngineMemory,
});
setGameState(restoredState);
}
setCurrentStory({
...currentStory,
options: currentStory.deferredOptions,
deferredOptions: undefined,
deferredRuntimeState: undefined,
deferredAutoChoice,
});
return;
}

View File

@@ -25,6 +25,7 @@ import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
type RpgRuntimeInteractionFlowParams = {
gameState: GameState;
isLoading: boolean;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
interactionConfig: StoryInteractionCoordinatorConfig;
runtimeSupport: StoryRuntimeSupport;
buildResolvedChoiceState: (
@@ -76,6 +77,7 @@ export function createClearStoryInteractionUi(params: {
export function useRpgRuntimeInteractionFlow({
gameState,
isLoading,
sceneTransitionPhase = 'idle',
interactionConfig,
runtimeSupport,
buildResolvedChoiceState,
@@ -117,7 +119,15 @@ export function useRpgRuntimeInteractionFlow({
});
useEffect(() => {
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
const pendingAutoChoice =
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice;
if (
isLoading ||
sceneTransitionPhase !== 'idle' ||
pendingAutoChoice ||
gameState.inBattle ||
gameState.npcInteractionActive
) {
return;
}
@@ -134,8 +144,10 @@ export function useRpgRuntimeInteractionFlow({
gameState.currentEncounter,
gameState.inBattle,
gameState.npcInteractionActive,
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice,
isLoading,
isNpcEncounter,
sceneTransitionPhase,
]);
const choiceRuntimeController: Parameters<

View File

@@ -25,8 +25,8 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
import {
advanceSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveSceneActProgression,
resolveLimitedPrimaryNpcChatState,
resolveSceneActProgression,
} from '../../services/customWorldSceneActRuntime';
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
@@ -1730,6 +1730,14 @@ export function createStoryNpcEncounterActions({
deferredOptions: progressionResult?.options,
deferredRuntimeState:
progressionResult?.deferredRuntimeState ?? undefined,
deferredAutoChoice:
progressionResult?.options.find(
(option) => option.functionId === 'npc_preview_talk',
) ??
progressionResult?.options.find(
(option) => option.functionId === 'npc_chat',
) ??
undefined,
});
return true;
}

View File

@@ -56,11 +56,13 @@ export type {
export function useRpgRuntimeStory({
gameState,
setGameState,
sceneTransitionPhase = 'idle',
buildResolvedChoiceState,
playResolvedChoice,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
@@ -108,6 +110,7 @@ export function useRpgRuntimeStory({
} = useRpgRuntimeStoryFlow({
gameState,
setGameState,
sceneTransitionPhase,
buildResolvedChoiceState,
playResolvedChoice,
getStoryGenerationHostileNpcs,

View File

@@ -14,6 +14,7 @@ import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
type RpgRuntimeStoryFlowParams = {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
@@ -61,6 +62,7 @@ type RpgRuntimeStoryFlowParams = {
export function useRpgRuntimeStoryFlow({
gameState,
setGameState,
sceneTransitionPhase = 'idle',
buildResolvedChoiceState,
playResolvedChoice,
getStoryGenerationHostileNpcs,
@@ -148,6 +150,7 @@ export function useRpgRuntimeStoryFlow({
} = useRpgRuntimeInteractionFlow({
gameState,
isLoading,
sceneTransitionPhase,
interactionConfig,
runtimeSupport,
buildResolvedChoiceState,

View File

@@ -7,6 +7,46 @@ import {
} from './miniGameDraftGenerationProgress';
describe('miniGameDraftGenerationProgress', () => {
test('puzzle draft generation follows picture-only creation steps', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
expect(progress?.steps.map((step) => step.label)).toEqual([
'编译首关草稿',
'生成首关画面',
'写入正式草稿',
]);
expect(progress?.phaseLabel).toBe('编译首关草稿');
expect(progress?.steps[0]?.detail).toBe(
'根据画面描述生成首关名称和结果页草稿。',
);
});
test('puzzle ready copy points to result page work info completion', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'ready',
startedAtMs: 1000,
completedAssetCount: 1,
totalAssetCount: 1,
error: null,
};
const progress = buildMiniGameDraftGenerationProgress(state, 2000);
expect(progress?.phaseDetail).toBe(
'首关草稿与正式图已准备完成,可进入结果页补作品信息。',
);
});
test('big fish draft generation exposes multiple draft steps', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',
@@ -111,24 +151,12 @@ describe('miniGameDraftGenerationProgress', () => {
resultPreview: null,
updatedAt: '2026-04-29T00:00:00.000Z',
}, {
seedText: '表单作品名',
workTitle: '暖灯猫街',
workDescription: '一套雨夜猫街主题拼图。',
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
});
expect(entries).toEqual([
{
id: 'puzzle-title',
label: '作品名称',
value: '暖灯猫街',
},
{
id: 'work-description',
label: '作品描述',
value: '一套雨夜猫街主题拼图。',
},
{
id: 'picture-description',
label: '画面描述',

View File

@@ -47,20 +47,20 @@ type MiniGameAnchorSource = {
const PUZZLE_STEPS = [
{
id: 'compile',
label: '编译拼图草稿',
detail: '整理主题、主体、构图与标签。',
label: '编译首关草稿',
detail: '根据画面描述生成首关名称和结果页草稿。',
weight: 34,
},
{
id: 'puzzle-images',
label: '生成拼图图片',
detail: '根据草稿生成候选图。',
label: '生成首关画面',
detail: '按画面描述和参考图生成第一张拼图图。',
weight: 33,
},
{
id: 'puzzle-select-image',
label: '确认正式图片',
detail: '选择候选图写入结果页。',
label: '写入正式草稿',
detail: '把首图设为正式图并同步到结果页。',
weight: 33,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -211,7 +211,7 @@ export function buildMiniGameDraftGenerationProgress(
(normalizedState.phase === 'ready'
? normalizedState.kind === 'big-fish'
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
: '完整草稿与资产已准备完成。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
overallProgress: clampProgress(overallProgress),
@@ -238,28 +238,12 @@ export function buildPuzzleGenerationAnchorEntries(
}
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'puzzle-title',
label: '作品名称',
value:
formPayload?.workTitle?.trim() ||
formPayload?.seedText?.trim() ||
session.draft?.workTitle ||
session.anchorPack.themePromise.value,
},
{
key: 'work-description',
label: '作品描述',
value:
formPayload?.workDescription?.trim() ||
session.draft?.workDescription ||
'',
},
{
key: 'picture-description',
label: '画面描述',
value:
formPayload?.pictureDescription?.trim() ||
formPayload?.seedText?.trim() ||
session.draft?.levels?.[0]?.pictureDescription ||
session.anchorPack.visualSubject.value,
},

View File

@@ -7,6 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
}));
import {
generateRpgWorldOpeningCg,
generateRpgWorldLandmark,
generateRpgWorldSceneImage,
generateRpgWorldSceneNpc,
@@ -23,6 +24,11 @@ describe('rpgCreationAssetClient', () => {
entity: { id: 'landmark-1', name: '雾港' },
imageSrc: '/generated-custom-world-scenes/profile/scene/image.webp',
npc: { id: 'npc-1', name: '守灯人' },
openingCg: {
id: 'opening-cg-1',
status: 'ready',
videoSrc: '/generated-custom-world-scenes/profile/opening.mp4',
},
});
});
@@ -89,4 +95,24 @@ describe('rpgCreationAssetClient', () => {
'生成场景 NPC 失败',
);
});
it('posts opening cg generation to the runtime custom world asset route', async () => {
const openingCg = await generateRpgWorldOpeningCg({
profile: {
id: 'profile-1',
name: '雾海群岛',
} as never,
});
expect(openingCg.videoSrc).toBe(
'/generated-custom-world-scenes/profile/opening.mp4',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/opening-cg',
expect.objectContaining({
method: 'POST',
}),
'生成开局 CG 失败',
);
});
});

View File

@@ -2,6 +2,7 @@ import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldOpeningCgProfile,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../../types';
@@ -132,6 +133,20 @@ export async function generateRpgWorldLandmark(payload: {
return response.entity;
}
export async function generateRpgWorldOpeningCg(payload: {
profile: CustomWorldProfile;
}) {
const response = await requestRpgCreationPostJson<{
openingCg: CustomWorldOpeningCgProfile;
}>(
`${RPG_CREATION_ASSET_API_BASE}/opening-cg`,
payload,
'生成开局 CG 失败',
);
return response.openingCg;
}
/**
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
@@ -143,6 +158,7 @@ export const rpgCreationAssetClient = {
generatePlayableNpc: generateRpgWorldPlayableNpc,
generateStoryNpc: generateRpgWorldStoryNpc,
generateLandmark: generateRpgWorldLandmark,
generateOpeningCg: generateRpgWorldOpeningCg,
generateCoverImage: generateCustomWorldCoverImage,
uploadCoverImage: uploadCustomWorldCoverImage,
};

View File

@@ -65,7 +65,8 @@ function createRuntimeProjection(
overrides: RuntimeProjectionOverrides = {},
): StoryRuntimeProjectionResponse {
const storySession = createStorySession(overrides.storySession);
const serverVersion = overrides.serverVersion ?? storySession.version;
const serverVersion =
overrides.serverVersion ?? storySession.version ?? 1;
return {
storySession,

View File

@@ -46,6 +46,37 @@ export interface CustomWorldCoverCropRect {
height: number;
}
export type CustomWorldOpeningCgStatus =
| 'not_started'
| 'storyboard_generating'
| 'video_generating'
| 'ready'
| 'failed';
export interface CustomWorldOpeningCgProfile {
id: string;
status: CustomWorldOpeningCgStatus;
storyboardImageSrc?: string | null;
storyboardAssetId?: string | null;
videoSrc?: string | null;
videoAssetId?: string | null;
posterImageSrc?: string | null;
posterAssetId?: string | null;
storyboardPrompt?: string | null;
videoPrompt?: string | null;
imageModel: 'gpt-image-2';
videoModel: string;
aspectRatio: '16:9';
imageSize: '2k';
videoResolution: '480p';
durationSeconds: 15;
pointCost: 80;
estimatedWaitMinutes: 10;
generatedAt?: string | null;
updatedAt: string;
errorMessage?: string | null;
}
export interface CreatorFactionSeed {
id: string;
name: string;
@@ -411,6 +442,7 @@ export interface CustomWorldProfile {
*/
playerPremise?: string | null;
cover?: CustomWorldCoverProfile | null;
openingCg?: CustomWorldOpeningCgProfile | null;
templateWorldType: WorldTemplateType;
compatibilityTemplateWorldType?: WorldTemplateType | null;
majorFactions: string[];

View File

@@ -166,6 +166,8 @@ export interface StoryMoment {
currentScenePreset?: ScenePresetInfo | null;
storyEngineMemory?: StoryEngineMemoryState;
};
// 中文注释:用于“继续冒险”过场完成后自动执行下一幕入口,避免角色尚未走到位就开聊。
deferredAutoChoice?: StoryOption;
historyRole?: StoryHistoryRole;
npcChatState?: StoryNpcChatState;
npcAffinityEffect?: StoryNpcAffinityEffect | null;