Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-05 22:20:30 +08:00
parent 89cecda7da
commit fcd8d727b0
57 changed files with 7646 additions and 1425 deletions

View File

@@ -1,6 +1,12 @@
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import { CustomWorldProfile, Encounter, SceneNpc, WorldType } from '../types';
import {
CustomWorldProfile,
Encounter,
SceneConnectionInfo,
SceneNpc,
WorldType,
} from '../types';
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
import { resolveAttributeSchema } from './attributeResolver';
import {
@@ -24,6 +30,7 @@ export interface ScenePreset {
worldType: WorldType;
forwardSceneId?: string;
connectedSceneIds: string[];
connections: SceneConnectionInfo[];
monsterIds: string[];
npcs: SceneNpc[];
treasureHints: string[];
@@ -121,6 +128,83 @@ function collectAllImagePool() {
return refs;
}
function uniqueStrings(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
function buildDefaultSceneConnections(
connectedSceneIds: string[],
forwardSceneId?: string,
): SceneConnectionInfo[] {
const uniqueSceneIds = uniqueStrings(connectedSceneIds);
const branchPositions: Array<SceneConnectionInfo['relativePosition']> = [
'left',
'right',
'back',
'portal',
];
const resolvedForwardSceneId =
forwardSceneId && uniqueSceneIds.includes(forwardSceneId)
? forwardSceneId
: uniqueSceneIds[0];
const branchSceneIds = uniqueSceneIds.filter(
(sceneId) => sceneId !== resolvedForwardSceneId,
);
const connections: SceneConnectionInfo[] = [];
if (resolvedForwardSceneId) {
connections.push({
sceneId: resolvedForwardSceneId,
relativePosition: 'forward',
summary: '沿主路继续深入前方区域',
});
}
branchSceneIds.forEach((sceneId, index) => {
connections.push({
sceneId,
relativePosition: branchPositions[index] ?? 'portal',
summary:
index === 0
? '这里分出一条支路'
: index === 1
? '这里还能转向另一条路'
: '这里还有额外通路',
});
});
return connections;
}
function pickForwardSceneIdFromConnections(connections: SceneConnectionInfo[]) {
const preferredOrder: Array<SceneConnectionInfo['relativePosition']> = [
'forward',
'north',
'east',
'right',
'up',
'outside',
'portal',
'left',
'west',
'south',
'down',
'inside',
'back',
];
for (const relativePosition of preferredOrder) {
const matchedConnection = connections.find(
(connection) => connection.relativePosition === relativePosition,
);
if (matchedConnection?.sceneId) {
return matchedConnection.sceneId;
}
}
return connections[0]?.sceneId;
}
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
@@ -225,7 +309,23 @@ function buildCustomSceneNpc(
name: npc.name,
role: npc.role,
avatar: npc.name.slice(0, 1) || '?',
description: `${npc.description} 动机:${npc.motivation}`,
description: [
npc.description,
npc.backstoryReveal.publicSummary
? `公开背景:${npc.backstoryReveal.publicSummary}`
: '',
npc.motivation ? `动机:${npc.motivation}` : '',
npc.skills.length > 0
? `技能:${npc.skills.map((skill) => skill.name).join('、')}`
: '',
npc.initialItems.length > 0
? `随身物:${npc.initialItems
.map((item) => `${item.name}x${item.quantity}`)
.join('、')}`
: '',
]
.filter(Boolean)
.join(' '),
gender: inferCustomNpcGender(npc.id, npc.name),
monsterPresetId: monsterPreset?.id,
hostileNpcPresetId: monsterPreset?.id,
@@ -254,6 +354,18 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const playableCharacters = buildCustomWorldPlayableCharacters(profile);
const campSceneId = buildCustomSceneId('camp');
const landmarkSceneIds = profile.landmarks.map((_, index) => buildCustomSceneId('landmark', index));
const landmarkSceneIdByLandmarkId = new Map(
profile.landmarks.map((landmark, index) => [
landmark.id,
buildCustomSceneId('landmark', index),
]),
);
const landmarkById = new Map(
profile.landmarks.map((landmark) => [landmark.id, landmark]),
);
const customStoryNpcById = new Map(
profile.storyNpcs.map((npc) => [npc.id, npc]),
);
const campNpcs = playableCharacters.slice(1).map(character => {
const npc = buildCharacterNpc(character.id, WorldType.CUSTOM, profile);
return npc
@@ -265,10 +377,15 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
: null;
}).filter(Boolean) as SceneNpc[];
const customStoryNpcs = profile.storyNpcs.map(npc =>
buildCustomSceneNpc(npc, profile, anchorWorldType),
);
const chunkSize = Math.max(4, Math.ceil(customStoryNpcs.length / Math.max(1, profile.landmarks.length)));
const campConnections = profile.landmarks
.slice(0, 3)
.map((landmark, index) => ({
sceneId: landmarkSceneIds[index] ?? '',
relativePosition:
index === 0 ? 'forward' : index === 1 ? 'left' : 'right',
summary: `从营地可直接通往${landmark.name}`,
}))
.filter((connection) => connection.sceneId) as SceneConnectionInfo[];
const customScenes: ScenePreset[] = [
{
id: campSceneId,
@@ -276,8 +393,9 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
description: `你在${profile.name}的临时营地整备行装。${profile.summary}`,
worldType: WorldType.CUSTOM,
imageSrc: profile.landmarks[0]?.imageSrc ?? allImages[imageOffset] ?? '',
connectedSceneIds: landmarkSceneIds.slice(0, 3),
forwardSceneId: landmarkSceneIds[0],
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
connections: campConnections,
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
monsterIds: [],
treasureHints: [
`${profile.name}地图残页`,
@@ -286,14 +404,57 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
npcs: campNpcs,
},
...profile.landmarks.map((landmark, index): ScenePreset => {
const sceneNpcs = customStoryNpcs.slice(index * chunkSize, (index + 1) * chunkSize);
const connectedSceneIds: string[] = [
campSceneId,
landmarkSceneIds[(index - 1 + landmarkSceneIds.length) % landmarkSceneIds.length],
landmarkSceneIds[(index + 1) % landmarkSceneIds.length],
]
.filter((sceneId): sceneId is string => Boolean(sceneId))
.filter((sceneId, sceneIndex, array) => array.indexOf(sceneId) === sceneIndex);
const sceneNpcs = landmark.sceneNpcIds
.map((npcId) => customStoryNpcById.get(npcId))
.filter(Boolean)
.map((npc) =>
buildCustomSceneNpc(npc!, profile, anchorWorldType),
);
if (sceneNpcs.length < 3) {
profile.storyNpcs
.filter(
(npc) => !sceneNpcs.some((sceneNpc) => sceneNpc.id === npc.id),
)
.slice(0, 3 - sceneNpcs.length)
.forEach((npc) =>
sceneNpcs.push(buildCustomSceneNpc(npc, profile, anchorWorldType)),
);
}
const landmarkConnections = landmark.connections
.map((connection) => {
const targetSceneId = landmarkSceneIdByLandmarkId.get(
connection.targetLandmarkId,
);
const targetLandmark = landmarkById.get(connection.targetLandmarkId);
if (!targetSceneId || !targetLandmark) {
return null;
}
return {
sceneId: targetSceneId,
relativePosition: connection.relativePosition,
summary:
connection.summary || `可通往${targetLandmark.name}`,
} satisfies SceneConnectionInfo;
})
.filter((connection): connection is SceneConnectionInfo =>
Boolean(connection),
);
const shouldLinkCamp = index < 3;
const extraCampConnection = shouldLinkCamp
? ({
sceneId: campSceneId,
relativePosition: 'back',
summary: '可回到临时营地整备',
} satisfies SceneConnectionInfo)
: null;
const connections = [
...landmarkConnections,
...(extraCampConnection ? [extraCampConnection] : []),
];
const connectedSceneIds = uniqueStrings(
connections.map((connection) => connection.sceneId),
);
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
const monsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = monsterIds
@@ -307,7 +468,8 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
worldType: WorldType.CUSTOM,
imageSrc: landmark.imageSrc ?? allImages[(imageOffset + index + 1) % Math.max(1, allImages.length)] ?? '',
connectedSceneIds,
forwardSceneId: connectedSceneIds.find(sceneId => sceneId !== campSceneId) ?? campSceneId,
connections,
forwardSceneId: pickForwardSceneIdFromConnections(connections),
monsterIds,
treasureHints: [
`${landmark.name}的旧线索`,
@@ -745,6 +907,10 @@ function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[]
...template,
...sceneOverride,
imageSrc: sceneOverride.imageSrc ?? imagePool[index] ?? imagePool[0] ?? '',
connections: buildDefaultSceneConnections(
sceneOverride.connectedSceneIds ?? template.connectedSceneIds,
sceneOverride.forwardSceneId ?? template.forwardSceneId,
),
npcs: mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType),
} satisfies ScenePreset;
});