Fix DashScope env loading for scene image generation

This commit is contained in:
2026-04-06 15:01:15 +08:00
parent fcd8d727b0
commit d678929064
23 changed files with 4943 additions and 138 deletions

View File

@@ -1,6 +1,10 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import {
resolveRoleCombatStats,
type RoleCombatStats,
} from '../data/attributeCombat';
import {
formatAttributeList,
resolveAttributeSchema,
@@ -260,6 +264,10 @@ function formatAttributeMetricValue(value: number) {
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function formatAttributePercentValue(value: number) {
return `${formatAttributeMetricValue(value * 100)}%`;
}
function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
@@ -270,6 +278,29 @@ function getAttributeBonusPillClassName(bonus: number) {
return 'border-white/10 bg-black/20 text-zinc-500';
}
function getAttributeEffectText(
slotId: string,
combatStats: RoleCombatStats,
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
) {
switch (slotId) {
case 'axis_a':
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
case 'axis_b':
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
case 'axis_c':
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
case 'axis_d':
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
case 'axis_e':
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
case 'axis_f':
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
default:
return '提升战斗表现';
}
}
function buildLeaderEquipmentRows(
playerCharacter: Character,
playerEquipment: EquipmentLoadout,
@@ -461,19 +492,26 @@ export function CharacterPanel({
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
: [];
const selectedAttributeRows = useMemo(
const selectedMemberAttributeProfile = useMemo(
() =>
selectedMember
? resolveCharacterAttributeProfile(
selectedMember.character,
worldType,
customWorldProfile,
)
: null,
[customWorldProfile, selectedMember, worldType],
);
const selectedAttributeRows = useMemo(
() =>
selectedMemberAttributeProfile
? formatAttributeList(
resolveCharacterAttributeProfile(
selectedMember.character,
worldType,
customWorldProfile,
),
selectedMemberAttributeProfile,
selectedAttributeSchema,
)
: [],
[customWorldProfile, selectedAttributeSchema, selectedMember, worldType],
[selectedAttributeSchema, selectedMemberAttributeProfile],
);
const selectedAttributeBonusBySlot = useMemo(
() =>
@@ -493,20 +531,67 @@ export function CharacterPanel({
) as Record<string, number>,
[selectedAttributeSchema, selectedBuildBreakdown],
);
const selectedBoostedAttributeProfile = useMemo(() => {
if (!selectedMemberAttributeProfile) {
return null;
}
return {
...selectedMemberAttributeProfile,
values: {
...(selectedMemberAttributeProfile.values ?? {}),
...Object.fromEntries(
selectedAttributeSchema.slots.map((slot) => {
const baseValue =
selectedMemberAttributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
};
}, [
selectedAttributeBonusBySlot,
selectedAttributeSchema,
selectedMemberAttributeProfile,
]);
const selectedBoostedCombatStats = useMemo(
() =>
selectedMember
? resolveRoleCombatStats(selectedBoostedAttributeProfile)
: null,
[selectedBoostedAttributeProfile, selectedMember],
);
const selectedDisplayAttributeRows = useMemo(
() =>
selectedAttributeRows.map(({ slot, value }) => {
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = value * (1 + totalBonus);
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: selectedBoostedCombatStats
? getAttributeEffectText(
slot.slotId,
selectedBoostedCombatStats,
resourceLabels,
)
: slot.combatUseText,
};
}),
[selectedAttributeBonusBySlot, selectedAttributeRows],
[
resourceLabels,
selectedAttributeBonusBySlot,
selectedAttributeRows,
selectedBoostedCombatStats,
],
);
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(
@@ -718,7 +803,6 @@ export function CharacterPanel({
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
@@ -877,7 +961,13 @@ export function CharacterPanel({
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
{selectedDisplayAttributeRows.map(
({ slot, baseValue, boostedValue, totalBonus }) => (
({
slot,
baseValue,
boostedValue,
totalBonus,
effectText,
}) => (
<div
key={slot.slotId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
@@ -886,22 +976,25 @@ export function CharacterPanel({
{slot.name}
</div>
<div className="mt-1 flex items-start justify-between gap-3">
<div>
<div className="min-w-0 flex-1">
<div className="text-2xl font-bold text-white">
{formatAttributeMetricValue(boostedValue)}
</div>
<div className="mt-1 text-[10px] text-zinc-500">
</div>
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
>
{' '}
{formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
>
{formatBuildContributionPercent(totalBonus)}
</span>
</div>
<div className="mt-2 text-[10px] leading-relaxed text-zinc-500">
{slot.definition}
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</div>
</div>
),

View File

@@ -31,6 +31,7 @@ import {
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldSceneConnection,
type ItemRarity,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
@@ -64,6 +65,14 @@ const [
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
{ value: 'common', label: 'common' },
{ value: 'uncommon', label: 'uncommon' },
{ value: 'rare', label: 'rare' },
{ value: 'epic', label: 'epic' },
{ value: 'legendary', label: 'legendary' },
];
function slugify(value: string) {
const normalized = value
.trim()
@@ -101,6 +110,48 @@ function clampInitialAffinity(value: string, fallback: number) {
return Math.max(-40, Math.min(90, Math.round(parsed)));
}
function parseOptionalNumber(value: string) {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function createRoleSkillDraft(seedLabel: string, index: number) {
return {
id: createEntryId('skill', seedLabel, Date.now() + index),
name: `新技能${index + 1}`,
summary: '',
style: '起手压制',
};
}
function createRoleInitialItemDraft(seedLabel: string, index: number) {
return {
id: createEntryId('item', seedLabel, Date.now() + index),
name: `新物品${index + 1}`,
category: '材料',
quantity: 1,
rarity: 'rare' as ItemRarity,
description: '',
tags: [],
};
}
function createBackstoryChapterDraft(seedLabel: string, index: number) {
return {
id: createEntryId('backstory-chapter', seedLabel, Date.now() + index),
title: `背景片段${index + 1}`,
affinityRequired:
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[
Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1)
] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
};
}
function syncLandmarksWithStoryNpcs(
landmarks: CustomWorldLandmark[],
storyNpcs: CustomWorldProfile['storyNpcs'],
@@ -700,7 +751,8 @@ function SaveBar({
onSave: () => void;
}) {
return (
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
@@ -716,16 +768,423 @@ function SaveBar({
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
);
}
function SectionPanel({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
{title}
</div>
{subtitle ? (
<div className="mt-2 text-sm leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
{actions}
</div>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function BackstoryRevealEditor({
value,
onChange,
}: {
value: CustomWorldPlayableNpc['backstoryReveal'];
onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
}) {
const updateChapter = (
index: number,
updater: (
chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => {
onChange({
...value,
chapters: value.chapters.map((chapter, chapterIndex) =>
chapterIndex === index ? updater(chapter) : chapter,
),
});
};
const addChapter = () => {
onChange({
...value,
chapters: [
...value.chapters,
createBackstoryChapterDraft('custom-role', value.chapters.length),
],
});
};
const removeChapter = (index: number) => {
if (value.chapters.length <= 1) {
window.alert('至少保留一个背景章节。');
return;
}
onChange({
...value,
chapters: value.chapters.filter(
(_chapter, chapterIndex) => chapterIndex !== index,
),
});
};
return (
<SectionPanel
title="背景公开与章节"
subtitle="这里直接决定结果页、关系推进和后续剧情提示词看到的背景摘要与章节线索。"
actions={
<ActionButton label="新增章节" onClick={addChapter} tone="sky" />
}
>
<Field label="公开背景摘要">
<TextArea
value={value.publicSummary}
onChange={(nextValue) =>
onChange({
...value,
publicSummary: nextValue,
})
}
rows={3}
/>
</Field>
{value.chapters.map((chapter, index) => (
<div
key={`${chapter.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除章节"
onClick={() => removeChapter(index)}
/>
</div>
<Field label="章节标题">
<TextInput
value={chapter.title}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
title: nextValue,
}))
}
/>
</Field>
<Field label="解锁好感">
<TextInput
type="number"
value={chapter.affinityRequired}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
affinityRequired: clampInitialAffinity(
nextValue,
current.affinityRequired,
),
}))
}
/>
</Field>
<Field label="章节提示">
<TextArea
value={chapter.teaser}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
teaser: nextValue,
}))
}
rows={2}
/>
</Field>
<Field label="章节内容">
<TextArea
value={chapter.content}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
content: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="剧情引用摘要">
<TextArea
value={chapter.contextSnippet}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
contextSnippet: nextValue,
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function SkillListEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['skills'];
onChange: (value: CustomWorldPlayableNpc['skills']) => void;
labelSeed: string;
}) {
const updateSkill = (
index: number,
updater: (
skill: CustomWorldPlayableNpc['skills'][number],
) => CustomWorldPlayableNpc['skills'][number],
) => {
onChange(
value.map((skill, skillIndex) =>
skillIndex === index ? updater(skill) : skill,
),
);
};
return (
<SectionPanel
title="技能"
subtitle="技能名、摘要和风格都会进入结果页与运行时 NPC 档案。"
actions={
<ActionButton
label="新增技能"
onClick={() =>
onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
}
tone="sky"
/>
}
>
{value.map((skill, index) => (
<div
key={`${skill.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除技能"
onClick={() =>
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
}
/>
</div>
<Field label="技能名称">
<TextInput
value={skill.name}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<Field label="技能风格">
<TextInput
value={skill.style}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
style: nextValue,
}))
}
/>
</Field>
<Field label="技能摘要">
<TextArea
value={skill.summary}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
summary: nextValue,
}))
}
rows={3}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function InitialItemsEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['initialItems'];
onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
labelSeed: string;
}) {
const updateItem = (
index: number,
updater: (
item: CustomWorldPlayableNpc['initialItems'][number],
) => CustomWorldPlayableNpc['initialItems'][number],
) => {
onChange(
value.map((item, itemIndex) =>
itemIndex === index ? updater(item) : item,
),
);
};
return (
<SectionPanel
title="初始物品"
subtitle="这里的内容会影响结果页展示,也会作为后续运行时参考档案。"
actions={
<ActionButton
label="新增物品"
onClick={() =>
onChange([
...value,
createRoleInitialItemDraft(labelSeed, value.length),
])
}
tone="sky"
/>
}
>
{value.map((item, index) => (
<div
key={`${item.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除物品"
onClick={() =>
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
}
/>
</div>
<Field label="名称">
<TextInput
value={item.name}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="分类">
<TextInput
value={item.category}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
category: nextValue,
}))
}
/>
</Field>
<Field label="稀有度">
<SelectField
value={item.rarity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
rarity: nextValue as ItemRarity,
}))
}
options={ITEM_RARITY_OPTIONS}
/>
</Field>
</div>
<Field label="数量">
<TextInput
type="number"
value={item.quantity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
quantity: Math.max(
1,
parseOptionalNumber(nextValue) ?? current.quantity,
),
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={item.description}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
description: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(item.tags)}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
tags: parseCommaText(nextValue),
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function StoryNpcVisualEditorModal({
npc,
visual,
@@ -873,7 +1332,7 @@ function PlayableNpcEditor({
return (
<ModalShell
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
subtitle="可为角色指定外观模板,结果页和正式选角都会同步使用。"
subtitle="这里可以直接修改可扮演角色的完整档案字段,结果页和正式选角都会同步使用。"
onClose={onClose}
>
<div className="space-y-4">
@@ -1026,6 +1485,35 @@ function PlayableNpcEditor({
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {
@@ -1059,7 +1547,7 @@ function StoryNpcEditor({
return (
<ModalShell
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
subtitle="场景角色形象编辑已拆分到独立面板,当前页面只保留档案信息与预览。"
subtitle="这里可以直接修改场景角色的完整档案字段,形象编辑仍保留在独立面板。"
onClose={onClose}
>
<div className="space-y-4">
@@ -1200,6 +1688,35 @@ function StoryNpcEditor({
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {

View File

@@ -0,0 +1,376 @@
import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/ai';
import { AnimationState, type Character } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
interface CustomWorldGenerationViewProps {
settingText: string;
actionPreviewCharacters: Character[];
progress: CustomWorldGenerationProgress | null;
isGenerating: boolean;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRetry: () => void;
onInterrupt: () => void;
}
const ACTION_SHOWCASE: Array<{
label: string;
description: string;
state: AnimationState;
}> = [
{
label: '冲阵测试',
description: '检查角色前探、推进与开场压迫感。',
state: AnimationState.RUN,
},
{
label: '交战演示',
description: '预热战斗站姿与交锋节奏。',
state: AnimationState.ATTACK,
},
{
label: '驻场待命',
description: '确认角色在剧情停驻时的氛围姿态。',
state: AnimationState.IDLE,
},
] as const;
function formatDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms));
const totalSeconds = Math.ceil(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) {
return `${Math.max(1, seconds)}`;
}
if (seconds === 0) {
return `${minutes} 分钟`;
}
return `${minutes}${seconds}`;
}
function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
}
export function CustomWorldGenerationView({
settingText,
actionPreviewCharacters,
progress,
isGenerating,
error,
onBack,
onEditSetting,
onRetry,
onInterrupt,
}: CustomWorldGenerationViewProps) {
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
: '正在校准预计等待时间';
const elapsedText =
progress != null ? `已耗时 ${formatDuration(progress.elapsedMs)}` : '正在启动世界生成';
return (
<div
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.86),rgba(10,12,18,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
<button
type="button"
onClick={onBack}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-45' : ''}`}
>
</button>
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
{isGenerating ? '世界建设中' : error ? '生成已暂停' : '等待操作'}
</div>
</div>
<div className="grid flex-none gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.1fr)_minmax(22rem,0.9fr)]">
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-1 text-sm text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
</button>
</div>
<div className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
{settingText}
</div>
</section>
<section
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
</div>
</div>
<div className="shrink-0 sm:text-right">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
{progressValue}%
</div>
</div>
</div>
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
<motion.div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
animate={{ width: `${progressValue}%` }}
transition={{ duration: 0.35, ease: 'easeOut' }}
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{estimatedWaitText}
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{elapsedText}
</div>
</div>
</div>
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
{steps.map((step) => (
<div
key={step.id}
className={`rounded-2xl border px-4 py-3 transition-colors ${
step.status === 'completed'
? 'border-emerald-400/16 bg-emerald-500/8'
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'border-white/8 bg-black/18'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{step.label}
</div>
<div className="text-xs text-zinc-300">
{step.completed}/{step.total}
</div>
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{step.detail}
</div>
</div>
))}
</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">
{error}
</div>
) : null}
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
<button
type="button"
onClick={onEditSetting}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={onRetry}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
</>
) : (
<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"
>
</button>
)}
</div>
</section>
</div>
<div className="flex flex-col gap-4 xl:min-h-0">
<section
className="pixel-nine-slice pixel-panel relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<motion.div
className="pointer-events-none absolute -left-8 top-0 h-36 w-36 rounded-full bg-sky-400/18 blur-3xl"
animate={{
opacity: [0.22, 0.48, 0.22],
scale: [0.92, 1.08, 0.92],
}}
transition={{ duration: 6.5, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="pointer-events-none absolute bottom-0 right-0 h-32 w-32 rounded-full bg-amber-200/12 blur-3xl"
animate={{ opacity: [0.18, 0.4, 0.18], scale: [1, 1.12, 1] }}
transition={{ duration: 7.2, repeat: Infinity, ease: 'easeInOut' }}
/>
<div className="relative z-10">
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
</div>
<div className="mt-2 text-xl font-black leading-tight text-white sm:text-2xl">
</div>
<div className="mt-3 max-w-[26rem] text-sm leading-6 text-zinc-300">
</div>
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200 col-span-2 sm:col-span-1">
</div>
</div>
</div>
</section>
<section
className="pixel-nine-slice pixel-panel xl:min-h-0 xl:flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="mb-3">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-1 text-sm leading-6 text-zinc-300">
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-3">
{ACTION_SHOWCASE.map((showcase, index) => {
const character =
actionPreviewCharacters[
index % Math.max(1, actionPreviewCharacters.length)
];
return (
<div
key={showcase.label}
className="rounded-[1.5rem] border border-white/8 bg-black/22 px-4 py-4"
>
<div className="flex h-28 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.18),rgba(10,12,18,0.1)_38%,rgba(10,12,18,0.76)_100%)] sm:h-32">
{character ? (
<CharacterAnimator
state={showcase.state}
character={character}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : null}
</div>
<div className="mt-3 text-sm font-semibold text-white">
{showcase.label}
</div>
<div className="mt-1 text-xs leading-6 text-zinc-400">
{showcase.description}
</div>
{character ? (
<div className="mt-3 rounded-full border border-sky-300/14 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
{character.name}
</div>
) : null}
</div>
);
})}
</div>
</section>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,19 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { getScenePreset } from '../../data/scenePresets';
import { generateCustomWorldProfile } from '../../services/ai';
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile,
} from '../../services/ai';
import {
type CustomWorldProfile,
type GameState,
@@ -19,12 +25,17 @@ import {
UI_CHROME,
WORLD_SELECT_ICONS,
} from '../../uiAssets';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import { CustomWorldResultView } from '../CustomWorldResultView';
import { DeveloperTeamModal } from '../DeveloperTeamModal';
import { PixelIcon } from '../PixelIcon';
import { CustomWorldCreatorModal } from '../SelectionCustomizationModals';
export type SelectionStage = 'start' | 'world' | 'custom-world-result';
export type SelectionStage =
| 'start'
| 'world'
| 'custom-world-generating'
| 'custom-world-result';
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
@@ -66,6 +77,8 @@ const WORLD_OPTIONS = [
},
] as const;
const GENERATION_PREVIEW_CHARACTERS = PRESET_CHARACTERS.slice(0, 3);
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
@@ -75,22 +88,6 @@ function generateWorldOnlineCounts(): WorldOnlineCounts {
};
}
function getCustomWorldGenerationLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在关联地标和关键物品...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
function getCustomWorldProgressLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在组合场景和视觉效果...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
@@ -113,7 +110,9 @@ export function PreGameSelectionFlow({
const [customWorldDraft, setCustomWorldDraft] = useState('');
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
const [customWorldProgress, setCustomWorldProgress] = useState(0);
const [customWorldProgress, setCustomWorldProgress] =
useState<CustomWorldGenerationProgress | null>(null);
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
const previewCustomWorldCharacters = useMemo(
() =>
@@ -186,13 +185,51 @@ export function PreGameSelectionFlow({
}
}, [generatedCustomWorldProfile, selectionStage, setSelectionStage]);
useEffect(
() => () => {
customWorldAbortControllerRef.current?.abort();
},
[],
);
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setSelectionStage('world');
};
const leaveCustomWorldGeneration = () => {
if (isGeneratingCustomWorld) {
return;
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
};
const openCustomWorldCreator = () => {
if (isGeneratingCustomWorld) {
return;
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setShowCustomWorldModal(true);
};
const editCustomWorldSetting = () => {
if (isGeneratingCustomWorld) {
return;
}
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('world');
setShowCustomWorldModal(true);
};
const saveGeneratedCustomWorld = () => {
if (!generatedCustomWorldProfile) {
return;
@@ -212,51 +249,73 @@ export function PreGameSelectionFlow({
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setSelectionStage('world');
};
const createCustomWorld = async () => {
if (isGeneratingCustomWorld) {
return;
}
const settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
return;
}
const abortController = new AbortController();
customWorldAbortControllerRef.current?.abort();
customWorldAbortControllerRef.current = abortController;
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
setSelectionStage('custom-world-generating');
setIsGeneratingCustomWorld(true);
setCustomWorldProgress(8);
const progressTimer = window.setInterval(() => {
setCustomWorldProgress((current) => {
if (current >= 92) return current;
return Math.min(
92,
current + Math.max(3, Math.round((96 - current) / 5)),
);
});
}, 260);
try {
const profile = await generateCustomWorldProfile(settingText);
window.clearInterval(progressTimer);
setCustomWorldProgress(100);
await new Promise((resolve) => window.setTimeout(resolve, 180));
const profile = await generateCustomWorldProfile(settingText, {
signal: abortController.signal,
onProgress: setCustomWorldProgress,
});
if (abortController.signal.aborted) {
return;
}
setGeneratedCustomWorldProfile(profile);
setShowCustomWorldModal(false);
setCustomWorldError(null);
setSelectionStage('custom-world-result');
} catch (error) {
window.clearInterval(progressTimer);
setCustomWorldProgress(0);
if (abortController.signal.aborted) {
setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
return;
}
setCustomWorldError(
error instanceof Error ? error.message : '生成自定义世界失败。',
);
} finally {
if (customWorldAbortControllerRef.current === abortController) {
customWorldAbortControllerRef.current = null;
}
setIsGeneratingCustomWorld(false);
}
};
const interruptCustomWorldGeneration = () => {
if (!isGeneratingCustomWorld || !customWorldAbortControllerRef.current) {
return;
}
const confirmed = window.confirm(
'确认中断当前世界生成吗?本轮未完成的内容不会保留。',
);
if (!confirmed) {
return;
}
customWorldAbortControllerRef.current.abort(new Error('世界生成已中断。'));
};
return (
<>
<AnimatePresence mode="wait">
@@ -296,7 +355,7 @@ export function PreGameSelectionFlow({
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldError(null);
setCustomWorldProgress(0);
setCustomWorldProgress(null);
setShowCustomWorldModal(false);
setSelectionStage('world');
}}
@@ -495,12 +554,7 @@ export function PreGameSelectionFlow({
<button
type="button"
onClick={() => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
onClick={openCustomWorldCreator}
className="pixel-nine-slice pixel-pressable order-first relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
@@ -533,6 +587,31 @@ export function PreGameSelectionFlow({
</motion.div>
)}
{!gameState.worldType &&
selectionStage === 'custom-world-generating' && (
<motion.div
key="custom-world-generating"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<CustomWorldGenerationView
settingText={customWorldDraft.trim()}
actionPreviewCharacters={GENERATION_PREVIEW_CHARACTERS}
progress={customWorldProgress}
isGenerating={isGeneratingCustomWorld}
error={customWorldError}
onBack={leaveCustomWorldGeneration}
onEditSetting={editCustomWorldSetting}
onRetry={() => {
void createCustomWorld();
}}
onInterrupt={interruptCustomWorldGeneration}
/>
</motion.div>
)}
{!gameState.worldType &&
selectionStage === 'custom-world-result' &&
generatedCustomWorldProfile && (
@@ -547,16 +626,12 @@ export function PreGameSelectionFlow({
profile={generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldProgressLabel(customWorldProgress)}
progress={customWorldProgress?.overallProgress ?? 0}
progressLabel={customWorldProgress?.phaseLabel ?? ''}
error={customWorldError}
onProfileChange={setGeneratedCustomWorldProfile}
onBack={leaveCustomWorldResult}
onEditSetting={() => {
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
onEditSetting={editCustomWorldSetting}
onRegenerate={() => {
void createCustomWorld();
}}
@@ -581,8 +656,8 @@ export function PreGameSelectionFlow({
void createCustomWorld();
}}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldGenerationLabel(customWorldProgress)}
progress={customWorldProgress?.overallProgress ?? 0}
progressLabel={customWorldProgress?.phaseLabel ?? '正在准备生成'}
error={customWorldError}
/>