426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
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,
|
|
visualDescription: landmark.visualDescription,
|
|
dangerLevel: landmark.dangerLevel,
|
|
imageSrc: landmark.imageSrc,
|
|
narrativeResidues: landmark.narrativeResidues,
|
|
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,
|
|
narrativeResidues: landmark.narrativeResidues,
|
|
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 ?? [],
|
|
}));
|
|
}
|