Fix DashScope env loading for scene image generation
This commit is contained in:
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
376
src/components/CustomWorldGenerationView.tsx
Normal file
376
src/components/CustomWorldGenerationView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user