This commit is contained in:
2026-04-25 22:19:04 +08:00
parent 2ebfd1cf55
commit 8404081d7b
149 changed files with 10508 additions and 2732 deletions

View File

@@ -32,6 +32,14 @@ import {
buildDefaultCustomWorldCoverProfile,
resolveCustomWorldCoverPresentation,
} from '../../services/customWorldCover';
import {
getCustomWorldFoundationAnchorContent,
parseFoundationTagText,
type CustomWorldFoundationEntryId,
} from '../../services/customWorldFoundationEntries';
import {
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import {
type CustomWorldCoverAssetResult,
generateCustomWorldCoverImage,
@@ -94,6 +102,7 @@ import {
export type RpgCreationEditorTarget =
| { kind: 'world' }
| { kind: 'foundation' }
| { kind: 'cover' }
| { kind: 'camp' }
| { kind: 'playable'; mode: 'create' }
@@ -4522,6 +4531,270 @@ export function WorldEditor({
);
}
type FoundationDraft = Record<CustomWorldFoundationEntryId, string>;
const FOUNDATION_EDITOR_FIELDS: Array<{
id: CustomWorldFoundationEntryId;
label: string;
rows: number;
}> = [
{ id: 'world-promise', label: '世界承诺', rows: 4 },
{ id: 'player-fantasy', label: '玩家幻想', rows: 4 },
{ id: 'theme-boundary', label: '主题边界', rows: 4 },
{ id: 'player-entry-point', label: '玩家切入口', rows: 4 },
{ id: 'core-conflict', label: '核心冲突', rows: 4 },
{ id: 'key-relationships', label: '关键关系', rows: 4 },
{ id: 'hidden-lines', label: '暗线与揭示', rows: 4 },
{ id: 'iconic-elements', label: '标志元素', rows: 4 },
];
function buildFoundationDraft(profile: CustomWorldProfile): FoundationDraft {
const anchorContent = getCustomWorldFoundationAnchorContent(profile);
return {
'world-promise': [
anchorContent.worldPromise?.hook || '',
anchorContent.worldPromise?.differentiator || '',
anchorContent.worldPromise?.desiredExperience || '',
]
.filter(Boolean)
.join(''),
'player-fantasy': [
anchorContent.playerFantasy?.playerRole || '',
anchorContent.playerFantasy?.corePursuit || '',
anchorContent.playerFantasy?.fearOfLoss || '',
]
.filter(Boolean)
.join(''),
'theme-boundary': [
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
anchorContent.themeBoundary?.forbiddenDirectives.length
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
: '',
]
.filter(Boolean)
.join(''),
'player-entry-point': [
anchorContent.playerEntryPoint?.openingIdentity || '',
anchorContent.playerEntryPoint?.openingProblem || '',
anchorContent.playerEntryPoint?.entryMotivation || '',
]
.filter(Boolean)
.join(''),
'core-conflict': [
anchorContent.coreConflict?.surfaceConflicts.join('、') || '',
anchorContent.coreConflict?.hiddenCrisis || '',
anchorContent.coreConflict?.firstTouchedConflict || '',
]
.filter(Boolean)
.join(''),
'key-relationships': anchorContent.keyRelationships
.map((entry) =>
[entry.pairs, entry.relationshipType, entry.secretOrCost]
.filter(Boolean)
.join(''),
)
.join('\n'),
'hidden-lines': [
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
anchorContent.hiddenLines?.revealPacing || '',
]
.filter(Boolean)
.join(''),
'iconic-elements': [
anchorContent.iconicElements?.iconicMotifs.join('、') || '',
anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
anchorContent.iconicElements?.hardRules.join('、') || '',
]
.filter(Boolean)
.join(''),
};
}
function splitCommaTags(value: string) {
return value
.split(/[,]/u)
.map((item) => item.trim())
.filter(Boolean);
}
function stripAvoidPrefix(value: string) {
return value.replace(/^[:]\s*/u, '').trim();
}
function applyFoundationDraftToProfile(
profile: CustomWorldProfile,
draft: FoundationDraft,
): CustomWorldProfile {
const worldPromiseTags = parseFoundationTagText(draft['world-promise']);
const playerFantasyTags = parseFoundationTagText(draft['player-fantasy']);
const themeBoundaryTags = parseFoundationTagText(draft['theme-boundary']);
const playerEntryTags = parseFoundationTagText(draft['player-entry-point']);
const coreConflictTags = parseFoundationTagText(draft['core-conflict']);
const hiddenLineTags = parseFoundationTagText(draft['hidden-lines']);
const iconicElementTags = parseFoundationTagText(draft['iconic-elements']);
const relationshipLines = draft['key-relationships']
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
const creatorIntent =
profile.creatorIntent ?? createEmptyCustomWorldCreatorIntent('freeform');
return {
...profile,
summary: worldPromiseTags[0] || profile.summary,
subtitle: worldPromiseTags[1] || profile.subtitle,
tone: themeBoundaryTags[0] || profile.tone,
playerGoal: playerFantasyTags[1] || profile.playerGoal,
worldHook: worldPromiseTags[0] || profile.worldHook || null,
playerPremise: playerFantasyTags[0] || profile.playerPremise || null,
coreConflicts: coreConflictTags[0]
? splitCommaTags(coreConflictTags[0])
: profile.coreConflicts,
creatorIntent: {
...creatorIntent,
worldHook: worldPromiseTags[0] || creatorIntent.worldHook,
playerPremise: playerFantasyTags[0] || creatorIntent.playerPremise,
openingSituation: playerEntryTags[1] || creatorIntent.openingSituation,
themeKeywords: themeBoundaryTags[0]
? splitCommaTags(themeBoundaryTags[0])
: creatorIntent.themeKeywords,
toneDirectives: themeBoundaryTags[1]
? splitCommaTags(themeBoundaryTags[1])
: creatorIntent.toneDirectives,
coreConflicts: coreConflictTags[0]
? splitCommaTags(coreConflictTags[0])
: creatorIntent.coreConflicts,
iconicElements: iconicElementTags[0]
? splitCommaTags(iconicElementTags[0])
: creatorIntent.iconicElements,
forbiddenDirectives: themeBoundaryTags[2]
? splitCommaTags(stripAvoidPrefix(themeBoundaryTags[2]))
: creatorIntent.forbiddenDirectives,
},
anchorContent: {
worldPromise: {
hook: worldPromiseTags[0] || '',
differentiator: worldPromiseTags[1] || '',
desiredExperience: worldPromiseTags[2] || '',
},
playerFantasy: {
playerRole: playerFantasyTags[0] || '',
corePursuit: playerFantasyTags[1] || '',
fearOfLoss: playerFantasyTags[2] || '',
},
themeBoundary: {
toneKeywords: themeBoundaryTags[0]
? splitCommaTags(themeBoundaryTags[0])
: [],
aestheticDirectives: themeBoundaryTags[1]
? splitCommaTags(themeBoundaryTags[1])
: [],
forbiddenDirectives: themeBoundaryTags[2]
? splitCommaTags(stripAvoidPrefix(themeBoundaryTags[2]))
: [],
},
playerEntryPoint: {
openingIdentity: playerEntryTags[0] || '',
openingProblem: playerEntryTags[1] || '',
entryMotivation: playerEntryTags[2] || '',
},
coreConflict: {
surfaceConflicts: coreConflictTags[0]
? splitCommaTags(coreConflictTags[0])
: [],
hiddenCrisis: coreConflictTags[1] || '',
firstTouchedConflict: coreConflictTags[2] || '',
},
keyRelationships: relationshipLines.map((line) => {
const tags = parseFoundationTagText(line);
return {
pairs: tags[0] || '',
relationshipType: tags[1] || '',
secretOrCost: tags[2] || '',
};
}),
hiddenLines: {
hiddenTruths: hiddenLineTags[0] ? splitCommaTags(hiddenLineTags[0]) : [],
misdirectionHints: hiddenLineTags[1]
? splitCommaTags(hiddenLineTags[1])
: [],
revealPacing: hiddenLineTags[2] || '',
},
iconicElements: {
iconicMotifs: iconicElementTags[0]
? splitCommaTags(iconicElementTags[0])
: [],
institutionsOrArtifacts: iconicElementTags[1]
? splitCommaTags(iconicElementTags[1])
: [],
hardRules: iconicElementTags[2] ? splitCommaTags(iconicElementTags[2]) : [],
},
},
};
}
export function WorldFoundationEditor({
profile,
onSave,
onClose,
}: {
profile: CustomWorldProfile;
onSave: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const initialDraft = useMemo(() => buildFoundationDraft(profile), [profile]);
const [draft, setDraft] = useDraft(initialDraft);
return (
<ModalShell
title="编辑基本设定"
onClose={onClose}
panelClassName="sm:max-w-4xl"
>
<div className="space-y-4">
{FOUNDATION_EDITOR_FIELDS.map((field) => (
<Field key={field.id} label={field.label}>
<div className="space-y-3">
<TextArea
value={draft[field.id]}
onChange={(value) =>
setDraft((current) => ({
...current,
[field.id]: value,
}))
}
rows={field.rows}
/>
{draft[field.id].trim() ? (
<div className="flex flex-wrap gap-2">
{parseFoundationTagText(draft[field.id]).map((tag, index) => (
<span
key={`${field.id}-${index}-${tag}`}
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
>
{tag}
</span>
))}
</div>
) : null}
</div>
</Field>
))}
<SaveBar
onClose={onClose}
onSave={() => {
onSave(applyFoundationDraftToProfile(profile, draft));
onClose();
}}
/>
</div>
</ModalShell>
);
}
export function CampSceneEditor({
profile,
onSaveProfile,
@@ -5921,6 +6194,16 @@ function RpgCreationEntityEditorModal({
);
}
if (target.kind === 'foundation') {
return (
<WorldFoundationEditor
profile={profile}
onSave={onProfileChange}
onClose={onClose}
/>
);
}
if (target.kind === 'cover') {
return (
<WorldCoverEditor