Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
422
src/data/customWorldSceneGraph.ts
Normal file
422
src/data/customWorldSceneGraph.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldSceneConnection,
|
||||
CustomWorldSceneRelativePosition,
|
||||
} from '../types';
|
||||
|
||||
export type CustomWorldSceneConnectionDraft = {
|
||||
targetLandmarkId?: string;
|
||||
targetLandmarkName?: string;
|
||||
relativePosition?: unknown;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export type CustomWorldLandmarkDraft = Omit<
|
||||
CustomWorldLandmark,
|
||||
'sceneNpcIds' | 'connections'
|
||||
> & {
|
||||
sceneNpcIds?: string[];
|
||||
sceneNpcNames?: string[];
|
||||
connections?: CustomWorldSceneConnectionDraft[];
|
||||
};
|
||||
|
||||
export const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS: Array<{
|
||||
value: CustomWorldSceneRelativePosition;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: 'forward', label: '前方' },
|
||||
{ value: 'back', label: '后方' },
|
||||
{ value: 'left', label: '左侧' },
|
||||
{ value: 'right', label: '右侧' },
|
||||
{ value: 'north', label: '北侧' },
|
||||
{ value: 'south', label: '南侧' },
|
||||
{ value: 'east', label: '东侧' },
|
||||
{ value: 'west', label: '西侧' },
|
||||
{ value: 'up', label: '上方' },
|
||||
{ value: 'down', label: '下方' },
|
||||
{ value: 'inside', label: '内部' },
|
||||
{ value: 'outside', label: '外部' },
|
||||
{ value: 'portal', label: '传送节点' },
|
||||
] as const;
|
||||
|
||||
const RELATIVE_POSITION_ALIASES: Record<
|
||||
CustomWorldSceneRelativePosition,
|
||||
string[]
|
||||
> = {
|
||||
forward: ['forward', 'front', 'ahead', '前方', '前面', '前侧', '向前'],
|
||||
back: ['back', 'rear', 'behind', '后方', '后面', '后侧', '回程'],
|
||||
left: ['left', '左侧', '左边', '左方'],
|
||||
right: ['right', '右侧', '右边', '右方'],
|
||||
north: ['north', '北侧', '北边', '北方', '上北'],
|
||||
south: ['south', '南侧', '南边', '南方', '下南'],
|
||||
east: ['east', '东侧', '东边', '东方'],
|
||||
west: ['west', '西侧', '西边', '西方'],
|
||||
up: ['up', 'upper', 'above', '上方', '上层', '高处', '顶部'],
|
||||
down: ['down', 'lower', 'below', '下方', '下层', '低处', '底部'],
|
||||
inside: ['inside', 'inner', 'indoors', '内部', '内侧', '内里', '室内'],
|
||||
outside: ['outside', 'outer', 'outdoors', '外部', '外侧', '外围', '室外'],
|
||||
portal: ['portal', 'gate', 'path', 'junction', '传送', '门', '入口', '通道'],
|
||||
};
|
||||
|
||||
const RELATIVE_POSITION_LABELS = Object.fromEntries(
|
||||
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map((option) => [
|
||||
option.value,
|
||||
option.label,
|
||||
]),
|
||||
) as Record<CustomWorldSceneRelativePosition, string>;
|
||||
|
||||
const RELATIVE_POSITION_DISPLAY_ORDER: CustomWorldSceneRelativePosition[] = [
|
||||
'forward',
|
||||
'north',
|
||||
'east',
|
||||
'right',
|
||||
'up',
|
||||
'outside',
|
||||
'portal',
|
||||
'left',
|
||||
'west',
|
||||
'south',
|
||||
'down',
|
||||
'inside',
|
||||
'back',
|
||||
];
|
||||
|
||||
function normalizeKey(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildSceneNpcLookup(storyNpcs: CustomWorldNpc[]) {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
storyNpcs.forEach((npc) => {
|
||||
const normalizedId = normalizeKey(npc.id);
|
||||
const normalizedName = normalizeKey(npc.name);
|
||||
if (normalizedId) {
|
||||
lookup.set(normalizedId, npc.id);
|
||||
}
|
||||
if (normalizedName) {
|
||||
lookup.set(normalizedName, npc.id);
|
||||
}
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function buildLandmarkLookup(landmarks: Array<Pick<CustomWorldLandmarkDraft, 'id' | 'name'>>) {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
landmarks.forEach((landmark) => {
|
||||
const normalizedId = normalizeKey(landmark.id);
|
||||
const normalizedName = normalizeKey(landmark.name);
|
||||
if (normalizedId) {
|
||||
lookup.set(normalizedId, landmark.id);
|
||||
}
|
||||
if (normalizedName) {
|
||||
lookup.set(normalizedName, landmark.id);
|
||||
}
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function compactUnique(values: string[]) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function sortConnections(connections: CustomWorldSceneConnection[]) {
|
||||
return [...connections].sort((left, right) => {
|
||||
const leftOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
|
||||
left.relativePosition,
|
||||
);
|
||||
const rightOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
|
||||
right.relativePosition,
|
||||
);
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return left.targetLandmarkId.localeCompare(right.targetLandmarkId);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeConnections(connections: CustomWorldSceneConnection[]) {
|
||||
const deduped = new Map<string, CustomWorldSceneConnection>();
|
||||
|
||||
connections.forEach((connection) => {
|
||||
const key = [
|
||||
connection.targetLandmarkId.trim(),
|
||||
connection.relativePosition,
|
||||
connection.summary.trim(),
|
||||
].join('::');
|
||||
if (!deduped.has(key)) {
|
||||
deduped.set(key, {
|
||||
targetLandmarkId: connection.targetLandmarkId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
export function getCustomWorldSceneRelativePositionLabel(
|
||||
value: CustomWorldSceneRelativePosition,
|
||||
) {
|
||||
return RELATIVE_POSITION_LABELS[value] ?? value;
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldSceneRelativePosition(
|
||||
value: unknown,
|
||||
): CustomWorldSceneRelativePosition {
|
||||
const normalizedValue =
|
||||
typeof value === 'string' ? normalizeKey(value) : '';
|
||||
|
||||
for (const option of CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS) {
|
||||
if (option.value === normalizedValue) {
|
||||
return option.value;
|
||||
}
|
||||
|
||||
if (RELATIVE_POSITION_ALIASES[option.value].includes(normalizedValue)) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
|
||||
return 'forward';
|
||||
}
|
||||
|
||||
export function invertCustomWorldSceneRelativePosition(
|
||||
value: CustomWorldSceneRelativePosition,
|
||||
): CustomWorldSceneRelativePosition {
|
||||
switch (value) {
|
||||
case 'forward':
|
||||
return 'back';
|
||||
case 'back':
|
||||
return 'forward';
|
||||
case 'left':
|
||||
return 'right';
|
||||
case 'right':
|
||||
return 'left';
|
||||
case 'north':
|
||||
return 'south';
|
||||
case 'south':
|
||||
return 'north';
|
||||
case 'east':
|
||||
return 'west';
|
||||
case 'west':
|
||||
return 'east';
|
||||
case 'up':
|
||||
return 'down';
|
||||
case 'down':
|
||||
return 'up';
|
||||
case 'inside':
|
||||
return 'outside';
|
||||
case 'outside':
|
||||
return 'inside';
|
||||
default:
|
||||
return 'portal';
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackSceneNpcIds(
|
||||
storyNpcs: CustomWorldNpc[],
|
||||
currentNpcIds: string[],
|
||||
landmarkIndex: number,
|
||||
) {
|
||||
const targetCount = Math.min(3, storyNpcs.length);
|
||||
if (targetCount <= currentNpcIds.length) {
|
||||
return currentNpcIds.slice(0, targetCount);
|
||||
}
|
||||
|
||||
const resolved = [...currentNpcIds];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < storyNpcs.length && resolved.length < targetCount;
|
||||
offset += 1
|
||||
) {
|
||||
const nextNpc = storyNpcs[(landmarkIndex + offset) % storyNpcs.length];
|
||||
if (!nextNpc || resolved.includes(nextNpc.id)) {
|
||||
continue;
|
||||
}
|
||||
resolved.push(nextNpc.id);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveSceneNpcIdsForLandmark(
|
||||
landmark: CustomWorldLandmarkDraft,
|
||||
storyNpcs: CustomWorldNpc[],
|
||||
lookup: Map<string, string>,
|
||||
landmarkIndex: number,
|
||||
) {
|
||||
const references = compactUnique([
|
||||
...(landmark.sceneNpcIds ?? []),
|
||||
...(landmark.sceneNpcNames ?? []),
|
||||
]);
|
||||
const resolvedIds = compactUnique(
|
||||
references
|
||||
.map((reference) => lookup.get(normalizeKey(reference)) ?? '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return buildFallbackSceneNpcIds(storyNpcs, resolvedIds, landmarkIndex);
|
||||
}
|
||||
|
||||
function resolveConnectionsForLandmark(
|
||||
landmark: CustomWorldLandmarkDraft,
|
||||
landmarkLookup: Map<string, string>,
|
||||
) {
|
||||
return (landmark.connections ?? [])
|
||||
.map((connection) => {
|
||||
const targetReference =
|
||||
connection.targetLandmarkId ?? connection.targetLandmarkName ?? '';
|
||||
const targetLandmarkId =
|
||||
landmarkLookup.get(normalizeKey(targetReference)) ?? '';
|
||||
|
||||
if (!targetLandmarkId || targetLandmarkId === landmark.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetLandmarkId,
|
||||
relativePosition: normalizeCustomWorldSceneRelativePosition(
|
||||
connection.relativePosition,
|
||||
),
|
||||
summary: typeof connection.summary === 'string'
|
||||
? connection.summary.trim()
|
||||
: '',
|
||||
} satisfies CustomWorldSceneConnection;
|
||||
})
|
||||
.filter((connection): connection is CustomWorldSceneConnection =>
|
||||
Boolean(connection),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureReverseConnections(landmarks: CustomWorldLandmark[]) {
|
||||
const connectionMap = new Map(
|
||||
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
|
||||
);
|
||||
const nameMap = new Map(landmarks.map((landmark) => [landmark.id, landmark.name]));
|
||||
|
||||
landmarks.forEach((landmark) => {
|
||||
landmark.connections.forEach((connection) => {
|
||||
const reverseConnections = connectionMap.get(connection.targetLandmarkId);
|
||||
if (!reverseConnections) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasReverseConnection = reverseConnections.some(
|
||||
(item) => item.targetLandmarkId === landmark.id,
|
||||
);
|
||||
if (hasReverseConnection) {
|
||||
return;
|
||||
}
|
||||
|
||||
reverseConnections.push({
|
||||
targetLandmarkId: landmark.id,
|
||||
relativePosition: invertCustomWorldSceneRelativePosition(
|
||||
connection.relativePosition,
|
||||
),
|
||||
summary: nameMap.get(landmark.id)
|
||||
? `可通往${nameMap.get(landmark.id)}`
|
||||
: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: sortConnections(
|
||||
dedupeConnections(connectionMap.get(landmark.id) ?? []),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureFallbackLandmarkConnections(landmarks: CustomWorldLandmark[]) {
|
||||
if (landmarks.length <= 1) {
|
||||
return landmarks;
|
||||
}
|
||||
|
||||
const connectionMap = new Map(
|
||||
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
|
||||
);
|
||||
|
||||
landmarks.forEach((landmark, index) => {
|
||||
const nextLandmark = landmarks[(index + 1) % landmarks.length];
|
||||
if (!nextLandmark || nextLandmark.id === landmark.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingConnections = connectionMap.get(landmark.id) ?? [];
|
||||
if (
|
||||
existingConnections.some(
|
||||
(connection) => connection.targetLandmarkId === nextLandmark.id,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
existingConnections.push({
|
||||
targetLandmarkId: nextLandmark.id,
|
||||
relativePosition: 'forward',
|
||||
summary: `沿主路可继续前往${nextLandmark.name}`,
|
||||
});
|
||||
connectionMap.set(landmark.id, existingConnections);
|
||||
});
|
||||
|
||||
return landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: sortConnections(connectionMap.get(landmark.id) ?? []),
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldLandmarks(params: {
|
||||
landmarks: CustomWorldLandmarkDraft[];
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
}) {
|
||||
const { landmarks, storyNpcs } = params;
|
||||
const npcLookup = buildSceneNpcLookup(storyNpcs);
|
||||
const landmarkLookup = buildLandmarkLookup(landmarks);
|
||||
|
||||
const resolvedLandmarks = landmarks.map((landmark, index) => ({
|
||||
id: landmark.id,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
imageSrc: landmark.imageSrc,
|
||||
sceneNpcIds: resolveSceneNpcIdsForLandmark(
|
||||
landmark,
|
||||
storyNpcs,
|
||||
npcLookup,
|
||||
index,
|
||||
),
|
||||
connections: sortConnections(
|
||||
resolveConnectionsForLandmark(landmark, landmarkLookup),
|
||||
),
|
||||
}));
|
||||
|
||||
return ensureReverseConnections(
|
||||
ensureFallbackLandmarkConnections(resolvedLandmarks),
|
||||
);
|
||||
}
|
||||
|
||||
export function syncCustomWorldLandmarkConnections(
|
||||
landmarks: CustomWorldLandmark[],
|
||||
) {
|
||||
return normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds,
|
||||
connections: landmark.connections.map((connection) => ({
|
||||
targetLandmarkId: connection.targetLandmarkId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
})),
|
||||
})),
|
||||
storyNpcs: [],
|
||||
}).map((landmark, index) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmarks[index]?.sceneNpcIds ?? [],
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user