Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1,22 +1,55 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import type {
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldGenerationMode,
|
||||
} from '../types';
|
||||
|
||||
interface CustomWorldCreatorModalProps {
|
||||
type BaseModalProps = {
|
||||
isOpen: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error: string | null;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
function SelectionModal({
|
||||
isOpen,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer = null,
|
||||
}: BaseModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#11161f] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-semibold text-white">{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CharacterDraftModalProps {
|
||||
export function CharacterDraftModal(props: {
|
||||
isOpen: boolean;
|
||||
characterLabel: string;
|
||||
draftName: string;
|
||||
@@ -25,124 +58,152 @@ interface CharacterDraftModalProps {
|
||||
onBackstoryChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function ModalShell({
|
||||
isOpen,
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
disableClose = false,
|
||||
children,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
disableClose?: boolean;
|
||||
children: ReactNode;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={disableClose ? undefined : onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-1 text-xs leading-relaxed text-zinc-400">{subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
const {
|
||||
isOpen,
|
||||
characterLabel,
|
||||
draftName,
|
||||
draftBackstory,
|
||||
onNameChange,
|
||||
onBackstoryChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
error = null,
|
||||
} = props;
|
||||
|
||||
export function CustomWorldCreatorModal({
|
||||
isOpen,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isGenerating,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
}: CustomWorldCreatorModalProps) {
|
||||
return (
|
||||
<ModalShell
|
||||
<SelectionModal
|
||||
isOpen={isOpen}
|
||||
title="创建自定义世界"
|
||||
title="角色自定义"
|
||||
onClose={onClose}
|
||||
disableClose={isGenerating}
|
||||
footer={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="rounded-2xl bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
确认进入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
当前角色:{characterLabel}
|
||||
</div>
|
||||
<label className="block">
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-white">世界设定文本</div>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
disabled={isGenerating}
|
||||
placeholder="例如:一个被古老机关城与修真宗门共同争夺的边境世界,灵气潮汐会周期性改写地形,玩家需要在多个势力之间周旋,寻找导致世界裂缝扩大的真正原因。"
|
||||
className="min-h-[22rem] w-full resize-none rounded-[1.75rem] border border-transparent bg-black/18 px-5 py-4 text-sm leading-7 text-zinc-100 outline-none transition-[background-color,box-shadow] placeholder:text-zinc-500 focus:bg-black/24 focus:shadow-[inset_0_0_0_1px_rgba(125,211,252,0.22)]"
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">角色名字</div>
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="输入一个更贴合这次旅程的称呼"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">背景补充</div>
|
||||
<textarea
|
||||
value={draftBackstory}
|
||||
onChange={(event) => onBackstoryChange(event.target.value)}
|
||||
rows={6}
|
||||
placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-emerald-400/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{(isGenerating || progress > 0) && (
|
||||
<div className="rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">{progressLabel}</div>
|
||||
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectionModal>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
type CustomWorldCreatorModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error?: string | null;
|
||||
} & (
|
||||
| {
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
creatorIntent?: never;
|
||||
onCreatorIntentChange?: never;
|
||||
generationMode?: never;
|
||||
onGenerationModeChange?: never;
|
||||
}
|
||||
| {
|
||||
draft?: never;
|
||||
onDraftChange?: never;
|
||||
creatorIntent: CustomWorldCreatorIntent;
|
||||
onCreatorIntentChange: (value: CustomWorldCreatorIntent) => void;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
onGenerationModeChange: (value: CustomWorldGenerationMode) => void;
|
||||
}
|
||||
);
|
||||
|
||||
function hasCreatorIntentProps(
|
||||
props: CustomWorldCreatorModalProps,
|
||||
): props is Extract<
|
||||
CustomWorldCreatorModalProps,
|
||||
{ creatorIntent: CustomWorldCreatorIntent }
|
||||
> {
|
||||
return 'creatorIntent' in props;
|
||||
}
|
||||
|
||||
export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isGenerating,
|
||||
progress,
|
||||
progressLabel,
|
||||
error = null,
|
||||
} = props;
|
||||
|
||||
const draftText = hasCreatorIntentProps(props)
|
||||
? props.creatorIntent.rawSettingText
|
||||
: props.draft;
|
||||
|
||||
const updateDraftText = (value: string) => {
|
||||
if (hasCreatorIntentProps(props)) {
|
||||
props.onCreatorIntentChange({
|
||||
...props.creatorIntent,
|
||||
rawSettingText: value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
props.onDraftChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectionModal
|
||||
isOpen={isOpen}
|
||||
title="创建自定义世界"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
className={`inline-flex min-w-24 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-sm text-zinc-300 transition-colors hover:bg-white/10 hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -150,90 +211,65 @@ export function CustomWorldCreatorModal({
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'cursor-wait opacity-60' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">{isGenerating ? '正在生成世界...' : '确认并开始生成'}</span>
|
||||
<span className="text-white/60">{isGenerating ? '...' : '→'}</span>
|
||||
</div>
|
||||
{isGenerating ? '生成中...' : '开始生成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterDraftModal({
|
||||
isOpen,
|
||||
characterLabel,
|
||||
draftName,
|
||||
draftBackstory,
|
||||
onNameChange,
|
||||
onBackstoryChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
error,
|
||||
}: CharacterDraftModalProps) {
|
||||
return (
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
title="自定义角色背景"
|
||||
subtitle={`你正在修改 ${characterLabel} 的角色名称与背景故事。`}
|
||||
onClose={onClose}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-7 text-zinc-300">
|
||||
这里的修改会直接带入本轮开场、剧情提示词和后续角色展示,不会改动原始预设。
|
||||
{hasCreatorIntentProps(props) ? (
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">生成模式</div>
|
||||
<select
|
||||
value={props.generationMode}
|
||||
onChange={(event) =>
|
||||
props.onGenerationModeChange(
|
||||
event.target.value as CustomWorldGenerationMode,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40"
|
||||
>
|
||||
<option value="fast">快速</option>
|
||||
<option value="full">完整</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="text-sm leading-7 text-zinc-300">
|
||||
用几句话描述世界观、核心矛盾、时代气质和你想体验的叙事方向。系统会据此生成可游玩的自定义世界。
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white">角色名称</div>
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={event => onNameChange(event.target.value)}
|
||||
placeholder="输入新的角色名称"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
value={draftText}
|
||||
onChange={(event) => updateDraftText(event.target.value)}
|
||||
rows={8}
|
||||
placeholder="例:一个雨雾笼罩的海上武侠世界,旧朝遗臣、海盗盟约与沉船秘术纠缠在一起……"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white">角色背景故事</div>
|
||||
<textarea
|
||||
value={draftBackstory}
|
||||
onChange={event => onBackstoryChange(event.target.value)}
|
||||
placeholder="写下这名角色进入世界前后的经历、动机、执念、秘密或人与人之间的纠葛。"
|
||||
className="min-h-44 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
{isGenerating ? (
|
||||
<div className="rounded-2xl border border-sky-300/15 bg-sky-500/10 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80">
|
||||
<span>{progressLabel}</span>
|
||||
<span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-cyan-200 transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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={onConfirm}
|
||||
className="pixel-nine-slice pixel-pressable text-left"
|
||||
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>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalShell>
|
||||
</SelectionModal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user