1089 lines
41 KiB
TypeScript
1089 lines
41 KiB
TypeScript
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
|
||
import {
|
||
buildFallbackActorNarrativeProfile,
|
||
normalizeActorNarrativeProfile,
|
||
} from '../services/storyEngine/actorNarrativeProfile';
|
||
import { buildSceneNarrativeResidues } from '../services/storyEngine/sceneResidueCompiler';
|
||
import { buildThemePackFromWorldProfile } from '../services/storyEngine/themePack';
|
||
import { buildFallbackWorldStoryGraph } from '../services/storyEngine/worldStoryGraph';
|
||
import {
|
||
CustomWorldProfile,
|
||
Encounter,
|
||
SceneConnectionInfo,
|
||
SceneNpc,
|
||
ScenePresetInfo,
|
||
WorldType,
|
||
} from '../types';
|
||
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
|
||
import { resolveAttributeSchema } from './attributeResolver';
|
||
import {
|
||
buildCharacterNpc,
|
||
buildCustomWorldPlayableCharacters,
|
||
getCharacterHomeSceneId,
|
||
getCharacterNpcSceneIds,
|
||
ROLE_TEMPLATE_CHARACTERS,
|
||
} from './characterPresets';
|
||
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
|
||
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
|
||
import {
|
||
getRuntimeCustomWorldProfile,
|
||
resolveCompatibilityTemplateWorldType,
|
||
} from './customWorldRuntime';
|
||
import {
|
||
resolveCustomWorldCampSceneImage,
|
||
resolveCustomWorldLandmarkImageMap,
|
||
} from './customWorldVisuals';
|
||
import { getMonsterPresetById } from './hostileNpcPresets';
|
||
import sceneNpcOverridesJson from './sceneNpcOverrides.json';
|
||
import sceneOverridesJson from './sceneOverrides.json';
|
||
|
||
export interface ScenePreset {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
imageSrc: string;
|
||
worldType: WorldType;
|
||
forwardSceneId?: string;
|
||
connectedSceneIds: string[];
|
||
connections: SceneConnectionInfo[];
|
||
npcs: SceneNpc[];
|
||
treasureHints: string[];
|
||
narrativeResidues?: ScenePresetInfo['narrativeResidues'];
|
||
}
|
||
|
||
export type ScenePresetOverride = Partial<Omit<ScenePreset, 'id' | 'worldType' | 'npcs'>>;
|
||
|
||
type KnownSceneNpcGender = Exclude<NonNullable<SceneNpc['gender']>, 'unknown'>;
|
||
|
||
export type SceneNpcPresetOverride = Partial<Omit<SceneNpc, 'gender'>> & {
|
||
gender?: KnownSceneNpcGender;
|
||
};
|
||
|
||
const SCENE_OVERRIDES = sceneOverridesJson as Record<string, ScenePresetOverride>;
|
||
const SCENE_NPC_OVERRIDES = sceneNpcOverridesJson as Record<string, SceneNpcPresetOverride>;
|
||
// Keep scene-only NPC genders explicit so encounter prompts never receive "unknown".
|
||
const SCENE_NPC_GENDERS: Record<string, KnownSceneNpcGender> = {
|
||
'wuxia-npc-bamboo-woodcutter': 'male',
|
||
'wuxia-npc-gate-disciple': 'male',
|
||
'wuxia-npc-night-vendor': 'male',
|
||
'wuxia-npc-village-remnant': 'female',
|
||
'wuxia-npc-ferryman': 'male',
|
||
'wuxia-npc-hunter': 'male',
|
||
'wuxia-npc-quartermaster': 'male',
|
||
'wuxia-npc-tomb-scholar': 'male',
|
||
'wuxia-npc-temple-host': 'male',
|
||
'wuxia-npc-miner': 'male',
|
||
'wuxia-npc-blacksmith': 'male',
|
||
'wuxia-npc-maid': 'female',
|
||
'xianxia-npc-gate-attendant': 'male',
|
||
'xianxia-npc-cloud-hermit': 'male',
|
||
'xianxia-npc-palace-page': 'female',
|
||
'xianxia-npc-herbal-keeper': 'female',
|
||
'xianxia-npc-cold-scholar': 'male',
|
||
'xianxia-npc-fire-forger': 'male',
|
||
'xianxia-npc-thunder-keeper': 'male',
|
||
'xianxia-npc-helmsman': 'male',
|
||
'xianxia-npc-lake-watcher': 'female',
|
||
'xianxia-npc-ruin-scholar': 'female',
|
||
'xianxia-npc-tree-ward': 'female',
|
||
'xianxia-npc-cliff-scout': 'female',
|
||
};
|
||
|
||
type SceneTemplate = {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
worldType: WorldType;
|
||
hostileNpcPresetIds: string[];
|
||
connectedSceneIds: string[];
|
||
forwardSceneId?: string;
|
||
treasureHints: string[];
|
||
extraNpcs: SceneNpc[];
|
||
};
|
||
|
||
const PACKS = [
|
||
{ packName: 'Pixel Battle Backgrounds - Pack 1', count: 121 },
|
||
{ packName: 'Pixel Battle Backgrounds - Pack 2', count: 119 },
|
||
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
|
||
];
|
||
|
||
function buildImagePath(packName: string, imageNumber: number) {
|
||
const filename = `${imageNumber.toString().padStart(3, '0')}.png`;
|
||
return `/scene_bg/Pixel Battle Backgrounds Mega Pack/${packName}/${filename}`;
|
||
}
|
||
|
||
function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
|
||
const resolvedWorldType =
|
||
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||
const refs: string[] = [];
|
||
let globalIndex = 0;
|
||
|
||
for (const pack of PACKS) {
|
||
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
||
const assignedWorld = globalIndex % 2 === 0 ? WorldType.WUXIA : WorldType.XIANXIA;
|
||
if (assignedWorld === resolvedWorldType) {
|
||
refs.push(buildImagePath(pack.packName, imageNumber));
|
||
if (refs.length >= requiredCount) return refs;
|
||
}
|
||
globalIndex += 1;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||
}
|
||
return hash;
|
||
}
|
||
|
||
function inferCustomNpcGender(id: string, name: string) {
|
||
const seed = hashText(`${id}:${name}`);
|
||
return seed % 2 === 0 ? 'female' as const : 'male' as const;
|
||
}
|
||
|
||
function buildHostileSceneNpc(sceneId: string, worldType: WorldType, monsterId: string): SceneNpc | null {
|
||
const preset = getMonsterPresetById(worldType, monsterId);
|
||
if (!preset) return null;
|
||
|
||
return {
|
||
id: `hostile-npc:${sceneId}:${preset.id}`,
|
||
name: preset.name,
|
||
role: '敌对角色',
|
||
avatar: preset.name.slice(0, 1) || '敌',
|
||
description: preset.description,
|
||
gender: inferCustomNpcGender(`${sceneId}:${preset.id}`, preset.name),
|
||
monsterPresetId: preset.id,
|
||
initialAffinity: -40,
|
||
hostile: true,
|
||
recruitable: false,
|
||
functions: ['fight'],
|
||
attributeProfile: preset.attributeProfile,
|
||
};
|
||
}
|
||
|
||
export function isHostileSceneNpc(npc: SceneNpc) {
|
||
return Boolean(npc.hostile || npc.monsterPresetId || (npc.initialAffinity ?? 0) < 0);
|
||
}
|
||
|
||
export function getSceneHostileNpcs(scene: { npcs?: SceneNpc[] } | null | undefined) {
|
||
return (scene?.npcs ?? []).filter(isHostileSceneNpc);
|
||
}
|
||
|
||
export function getSceneFriendlyNpcs(scene: { npcs?: SceneNpc[] } | null | undefined) {
|
||
return (scene?.npcs ?? []).filter(npc => !isHostileSceneNpc(npc));
|
||
}
|
||
|
||
export function getSceneHostileNpcPresetIds(scene: { npcs?: SceneNpc[] } | null | undefined) {
|
||
return [
|
||
...new Set(
|
||
getSceneHostileNpcs(scene)
|
||
.map(npc => npc.monsterPresetId)
|
||
.filter((monsterPresetId): monsterPresetId is string => Boolean(monsterPresetId)),
|
||
),
|
||
];
|
||
}
|
||
|
||
export function buildEncounterFromSceneNpc(
|
||
npc: SceneNpc,
|
||
xMeters?: number,
|
||
): Encounter {
|
||
return {
|
||
id: npc.id,
|
||
kind: 'npc',
|
||
characterId: npc.characterId,
|
||
monsterPresetId: npc.monsterPresetId,
|
||
npcName: npc.name,
|
||
npcDescription: npc.description,
|
||
npcAvatar: npc.avatar,
|
||
context: npc.role,
|
||
gender: npc.gender,
|
||
xMeters,
|
||
initialAffinity: npc.initialAffinity,
|
||
hostile: isHostileSceneNpc(npc),
|
||
attributeProfile: npc.attributeProfile,
|
||
title: npc.title,
|
||
backstory: npc.backstory,
|
||
personality: npc.personality,
|
||
motivation: npc.motivation,
|
||
combatStyle: npc.combatStyle,
|
||
relationshipHooks: npc.relationshipHooks,
|
||
tags: npc.tags,
|
||
backstoryReveal: npc.backstoryReveal,
|
||
skills: npc.skills,
|
||
initialItems: npc.initialItems,
|
||
imageSrc: npc.imageSrc,
|
||
visual: npc.visual,
|
||
narrativeProfile: npc.narrativeProfile,
|
||
};
|
||
}
|
||
|
||
function buildCustomSceneNpc(
|
||
npc: CustomWorldProfile['storyNpcs'][number],
|
||
profile: CustomWorldProfile,
|
||
): SceneNpc {
|
||
const themePack = profile.themePack ?? buildThemePackFromWorldProfile(profile);
|
||
const storyGraph =
|
||
profile.storyGraph ?? buildFallbackWorldStoryGraph(profile, themePack);
|
||
const narrativeProfile = normalizeActorNarrativeProfile(
|
||
npc.narrativeProfile,
|
||
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
|
||
);
|
||
const monsterPreset =
|
||
npc.initialAffinity < 0
|
||
? resolveCustomWorldNpcMonsterPreset(npc, WorldType.CUSTOM, profile)
|
||
: null;
|
||
const hostile = npc.initialAffinity < 0 || Boolean(monsterPreset);
|
||
const attributeProfile = monsterPreset?.attributeProfile
|
||
?? npc.attributeProfile
|
||
?? buildRoleAttributeProfileFromLegacyData({
|
||
entityId: npc.id,
|
||
schema: resolveAttributeSchema(WorldType.CUSTOM, profile),
|
||
textBlocks: [
|
||
npc.title,
|
||
npc.role,
|
||
npc.description,
|
||
npc.backstory,
|
||
npc.personality,
|
||
npc.motivation,
|
||
npc.combatStyle,
|
||
...npc.relationshipHooks,
|
||
...npc.tags,
|
||
],
|
||
}).profile;
|
||
|
||
return {
|
||
id: npc.id,
|
||
characterId: npc.id,
|
||
name: npc.name,
|
||
title: npc.title,
|
||
role: npc.role,
|
||
avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?',
|
||
description: [
|
||
npc.description,
|
||
narrativeProfile.publicMask ? `公开面:${narrativeProfile.publicMask}` : '',
|
||
narrativeProfile.immediatePressure
|
||
? `当前压力:${narrativeProfile.immediatePressure}`
|
||
: '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' '),
|
||
gender: inferCustomNpcGender(npc.id, npc.name),
|
||
monsterPresetId: monsterPreset?.id,
|
||
initialAffinity: npc.initialAffinity,
|
||
hostile,
|
||
recruitable: !hostile,
|
||
functions: hostile
|
||
? ['fight']
|
||
: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||
attributeProfile,
|
||
backstory: npc.backstory,
|
||
personality: npc.personality,
|
||
motivation: npc.motivation,
|
||
combatStyle: npc.combatStyle,
|
||
relationshipHooks: [...npc.relationshipHooks],
|
||
tags: [...npc.tags],
|
||
backstoryReveal: npc.backstoryReveal,
|
||
skills: npc.skills.map((skill) => ({ ...skill })),
|
||
initialItems: npc.initialItems.map((item) => ({
|
||
...item,
|
||
tags: [...item.tags],
|
||
})),
|
||
imageSrc: npc.imageSrc,
|
||
visual: npc.visual,
|
||
narrativeProfile,
|
||
};
|
||
}
|
||
|
||
function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
|
||
return kind === 'camp' ? 'custom-scene-camp' : `custom-scene-landmark-${index + 1}`;
|
||
}
|
||
|
||
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||
const campSceneProfile = resolveCustomWorldCampScene(profile);
|
||
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
|
||
const baseMonsterPool: string[] = getCustomWorldMonsterPresetPool(profile)
|
||
.map((monster) => monster.id)
|
||
.filter((monsterId: string, index: number, array: string[]) => array.indexOf(monsterId) === index);
|
||
const fallbackMonsterIds: string[] = baseMonsterPool.length > 0 ? baseMonsterPool : [];
|
||
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
|
||
? {
|
||
...npc,
|
||
role: `${character.title} / 可扮演角色`,
|
||
description: `${character.description} 这名角色属于自定义世界“${profile.name}”的可扮演阵容。`,
|
||
}
|
||
: null;
|
||
}).filter(Boolean) as SceneNpc[];
|
||
|
||
const campConnections = profile.landmarks
|
||
.slice(0, 3)
|
||
.map((landmark, index) => ({
|
||
sceneId: landmarkSceneIds[index] ?? '',
|
||
relativePosition:
|
||
index === 0 ? 'forward' : index === 1 ? 'left' : 'right',
|
||
summary: `从${campSceneProfile.name}可直接通往${landmark.name}`,
|
||
}))
|
||
.filter((connection) => connection.sceneId) as SceneConnectionInfo[];
|
||
const customScenes: ScenePreset[] = [
|
||
{
|
||
id: campSceneId,
|
||
name: buildCustomCampSceneName(profile),
|
||
description: campSceneProfile.description,
|
||
worldType: WorldType.CUSTOM,
|
||
imageSrc: resolveCustomWorldCampSceneImage(profile),
|
||
connectedSceneIds: campConnections.map((connection) => connection.sceneId),
|
||
connections: campConnections,
|
||
forwardSceneId: pickForwardSceneIdFromConnections(campConnections),
|
||
treasureHints: [
|
||
`${profile.name}地图残页`,
|
||
...profile.landmarks.slice(0, 3).map(landmark => `${landmark.name}的旧线索`),
|
||
].slice(0, 4),
|
||
narrativeResidues: buildSceneNarrativeResidues({
|
||
sceneId: campSceneId,
|
||
sceneName: buildCustomCampSceneName(profile),
|
||
profile,
|
||
}),
|
||
npcs: campNpcs,
|
||
},
|
||
...profile.landmarks.map((landmark, index): ScenePreset => {
|
||
const sceneNpcs = landmark.sceneNpcIds
|
||
.map((npcId) => customStoryNpcById.get(npcId))
|
||
.filter(Boolean)
|
||
.map((npc) =>
|
||
buildCustomSceneNpc(npc!, profile),
|
||
);
|
||
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)),
|
||
);
|
||
}
|
||
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: `可回到${campSceneProfile.name}整备`,
|
||
} 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 seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
|
||
const hostileNpcs = seedMonsterIds
|
||
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), WorldType.CUSTOM, monsterId))
|
||
.filter(Boolean) as SceneNpc[];
|
||
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
|
||
|
||
return {
|
||
id: buildCustomSceneId('landmark', index),
|
||
name: landmark.name,
|
||
description: landmark.description,
|
||
worldType: WorldType.CUSTOM,
|
||
imageSrc: landmarkImageMap.get(landmark.id) ?? '',
|
||
connectedSceneIds,
|
||
connections,
|
||
forwardSceneId: pickForwardSceneIdFromConnections(connections),
|
||
treasureHints: [
|
||
`${landmark.name}的旧线索`,
|
||
`${profile.name}相关遗物`,
|
||
profile.storyNpcs[index]?.name ? `${profile.storyNpcs[index]!.name}留下的痕迹` : `${profile.playerGoal.slice(0, 10)}相关痕迹`,
|
||
],
|
||
narrativeResidues:
|
||
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
|
||
? landmark.narrativeResidues
|
||
: buildSceneNarrativeResidues({
|
||
sceneId: buildCustomSceneId('landmark', index),
|
||
sceneName: landmark.name,
|
||
profile,
|
||
}),
|
||
npcs: combinedNpcs,
|
||
};
|
||
}),
|
||
];
|
||
|
||
return customScenes;
|
||
}
|
||
|
||
function makeNpc(
|
||
id: string,
|
||
name: string,
|
||
role: string,
|
||
avatar: string,
|
||
description: string,
|
||
gender?: KnownSceneNpcGender,
|
||
): SceneNpc {
|
||
return {
|
||
id,
|
||
name,
|
||
role,
|
||
avatar,
|
||
description,
|
||
gender,
|
||
recruitable: true,
|
||
functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
|
||
};
|
||
}
|
||
|
||
function pickKnownSceneNpcGender(...candidates: Array<SceneNpc['gender'] | undefined>): KnownSceneNpcGender | null {
|
||
for (const candidate of candidates) {
|
||
if (candidate === 'male' || candidate === 'female') {
|
||
return candidate;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveSceneNpcGender(
|
||
npcId: string,
|
||
...candidates: Array<SceneNpc['gender'] | undefined>
|
||
): KnownSceneNpcGender {
|
||
const gender = pickKnownSceneNpcGender(...candidates, SCENE_NPC_GENDERS[npcId]);
|
||
if (gender) {
|
||
return gender;
|
||
}
|
||
throw new Error(`场景角色 "${npcId}" 缺少明确性别。`);
|
||
}
|
||
|
||
function buildCharacterNpcPool(sceneId: string, worldType: WorldType) {
|
||
const npcs: SceneNpc[] = [];
|
||
|
||
for (const character of ROLE_TEMPLATE_CHARACTERS) {
|
||
const characterId = character.id;
|
||
const sceneIds = getCharacterNpcSceneIds(worldType, characterId);
|
||
if (sceneIds.includes(sceneId)) {
|
||
const npc = buildCharacterNpc(characterId, worldType, getRuntimeCustomWorldProfile());
|
||
if (npc) {
|
||
npcs.push(npc);
|
||
}
|
||
}
|
||
}
|
||
|
||
return npcs;
|
||
}
|
||
|
||
function buildSceneNpcAttributeProfile(
|
||
npc: SceneNpc,
|
||
worldType: WorldType,
|
||
customWorldProfile?: CustomWorldProfile | null,
|
||
) {
|
||
if (npc.attributeProfile) return npc.attributeProfile;
|
||
return buildRoleAttributeProfileFromLegacyData({
|
||
entityId: npc.id,
|
||
schema: resolveAttributeSchema(worldType, customWorldProfile),
|
||
textBlocks: [npc.role, npc.name, npc.description],
|
||
}).profile;
|
||
}
|
||
|
||
function mergeNpcs(
|
||
characterNpcs: SceneNpc[],
|
||
extraNpcs: SceneNpc[],
|
||
worldType: WorldType,
|
||
customWorldProfile?: CustomWorldProfile | null,
|
||
) {
|
||
const map = new Map<string, SceneNpc>();
|
||
[...characterNpcs, ...extraNpcs].forEach(npc => {
|
||
const override = SCENE_NPC_OVERRIDES[npc.id] ?? {};
|
||
const mergedNpc = {
|
||
...npc,
|
||
...override,
|
||
gender: resolveSceneNpcGender(npc.id, override.gender, npc.gender),
|
||
} satisfies SceneNpc;
|
||
map.set(npc.id, {
|
||
...mergedNpc,
|
||
attributeProfile: buildSceneNpcAttributeProfile(mergedNpc, worldType, customWorldProfile),
|
||
});
|
||
});
|
||
return [...map.values()];
|
||
}
|
||
|
||
const WUXIA_SCENES: SceneTemplate[] = [
|
||
{
|
||
id: 'wuxia-bamboo-road',
|
||
name: '竹林古道',
|
||
description: '风过竹叶如刀鸣,窄道蜿蜒向深处,最适合藏伏毒物和游侠。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-13', 'monster-08'],
|
||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-mist-woods', 'wuxia-ferry-bridge'],
|
||
forwardSceneId: 'wuxia-mountain-gate',
|
||
treasureHints: ['竹根旁半埋的刀鞘', '倒竹间的旧药囊'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-bamboo-woodcutter', '樵夫老周', '樵夫', '樵', '常在竹海边缘砍柴,对附近路数和兽踪了如指掌。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-mountain-gate',
|
||
name: '山门石阶',
|
||
description: '青石阶层层向上,旧山门半开半掩,守山人与伏兽都能藏得很稳。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-04', 'monster-06'],
|
||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-border-camp', 'wuxia-bamboo-road'],
|
||
forwardSceneId: 'wuxia-temple-forecourt',
|
||
treasureHints: ['裂缝里的铜钥', '石狮座下遗落的令牌'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-gate-disciple', '守山弟子', '门派弟子', '守', '一直盯着石阶尽头的动静,像在等某位重要来客。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-rain-street',
|
||
name: '雨夜长街',
|
||
description: '长街积水映灯,屋檐下尽是藏身空隙,最易碰见追踪者与夜行客。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-11', 'monster-07'],
|
||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-palace-court', 'wuxia-ruined-village'],
|
||
forwardSceneId: 'wuxia-ferry-bridge',
|
||
treasureHints: ['灯檐下浸湿的布包', '排水沟边翻起的账册残页'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-night-vendor', '夜灯摊主', '摊主', '灯', '深夜仍在街口守着灯摊,见过太多不该见的人。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-ruined-village',
|
||
name: '荒村断垣',
|
||
description: '残墙和空屋挤成一团,风里总像夹着旧哭声与游荡脚步。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-03', 'monster-07'],
|
||
connectedSceneIds: ['wuxia-mist-woods', 'wuxia-rain-street', 'wuxia-border-camp'],
|
||
forwardSceneId: 'wuxia-border-camp',
|
||
treasureHints: ['断墙后压着的木匣', '枯井边散落的旧簪'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-village-remnant', '守村妇人', '遗民', '民', '不肯离开这片断垣,似乎还在等某个人归来。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-ferry-bridge',
|
||
name: '古桥渡口',
|
||
description: '桥面潮湿,渡口雾重,来往之人不多,但每个身影都藏着故事。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-04', 'monster-11'],
|
||
connectedSceneIds: ['wuxia-rain-street', 'wuxia-bamboo-road', 'wuxia-border-camp'],
|
||
forwardSceneId: 'wuxia-border-camp',
|
||
treasureHints: ['桥柱缝里的油纸包', '渡船板下藏着的旧钱袋'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-ferryman', '老渡工', '渡工', '渡', '常年摆渡,看人看路都很准,有些话只肯对识货的人说。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-mist-woods',
|
||
name: '雾林小径',
|
||
description: '晨雾久久不散,树影像一层层压下来,适合毒蛇与潜伏兽狩猎。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-08', 'monster-13', 'monster-07'],
|
||
connectedSceneIds: ['wuxia-bamboo-road', 'wuxia-ruined-village', 'wuxia-temple-forecourt'],
|
||
forwardSceneId: 'wuxia-ruined-village',
|
||
treasureHints: ['缠在树根上的锦囊', '被雾水泡湿的地图残页'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-hunter', '追迹猎户', '猎户', '猎', '脚边总带着兽夹和草药,对林中异动非常敏感。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-border-camp',
|
||
name: '边关营地',
|
||
description: '营火与旌旗都带着风沙味,士卒、斥候和异兽都可能在这里短暂停留。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-18', 'monster-11'],
|
||
connectedSceneIds: ['wuxia-ferry-bridge', 'wuxia-mountain-gate', 'wuxia-ruined-village'],
|
||
forwardSceneId: 'wuxia-rain-street',
|
||
treasureHints: ['废营帐里的箭囊', '火盆旁埋着的军需匣'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-quartermaster', '军需官', '营地官', '营', '管着兵器和粮草,对各路来客始终保持戒心。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-crypt-passage',
|
||
name: '地宫通道',
|
||
description: '地砖尽头传来回声,石壁上的裂隙像无数只正在张望的眼。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-03', 'monster-06'],
|
||
connectedSceneIds: ['wuxia-temple-forecourt', 'wuxia-mine-depths', 'wuxia-palace-court'],
|
||
forwardSceneId: 'wuxia-mine-depths',
|
||
treasureHints: ['砖缝里的陪葬铜匣', '石灯底座后的残卷'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-tomb-scholar', '探碑书生', '学者', '碑', '抱着拓本在地宫里转来转去,似乎在找某段缺失铭文。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-temple-forecourt',
|
||
name: '寺庙前庭',
|
||
description: '香灰、古钟和石灯挤在一处,清净里始终藏着不安的回响。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-04', 'monster-03'],
|
||
connectedSceneIds: ['wuxia-mountain-gate', 'wuxia-crypt-passage', 'wuxia-mist-woods'],
|
||
forwardSceneId: 'wuxia-crypt-passage',
|
||
treasureHints: ['香炉灰里的玉珠', '石灯下压着的签牌'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-temple-host', '守庙僧', '僧人', '僧', '白日扫院夜里守灯,似乎知道地宫里曾封过什么。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-mine-depths',
|
||
name: '矿道深处',
|
||
description: '碎石与矿灯照出曲折坑道,深处总有重物挪动与甲壳摩擦声。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-06', 'monster-18'],
|
||
connectedSceneIds: ['wuxia-crypt-passage', 'wuxia-forge-works', 'wuxia-border-camp'],
|
||
forwardSceneId: 'wuxia-forge-works',
|
||
treasureHints: ['矿车夹层里的银匣', '埋在碎矿中的精铁'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-miner', '老矿头', '矿工', '矿', '靠耳朵分辨坑道深处的回响,比谁都先知道危险会从哪边来。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-forge-works',
|
||
name: '铸坊工场',
|
||
description: '火星、铁水与重锤声混在一起,热浪里最容易引来重甲怪物与寻刀之人。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-18', 'monster-04'],
|
||
connectedSceneIds: ['wuxia-mine-depths', 'wuxia-palace-court', 'wuxia-border-camp'],
|
||
forwardSceneId: 'wuxia-palace-court',
|
||
treasureHints: ['淬火池旁的铁匣', '风箱后压着的旧兵谱'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-blacksmith', '老铸匠', '铸匠', '铸', '看一眼兵器缺口就知道你刚从什么地方杀出来。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'wuxia-palace-court',
|
||
name: '宫苑内庭',
|
||
description: '回廊深处静得过分,花木修得齐整,却处处像埋着王庭旧案。',
|
||
worldType: WorldType.WUXIA,
|
||
hostileNpcPresetIds: ['monster-11', 'monster-13'],
|
||
connectedSceneIds: ['wuxia-forge-works', 'wuxia-rain-street', 'wuxia-crypt-passage'],
|
||
forwardSceneId: 'wuxia-rain-street',
|
||
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
|
||
extraNpcs: [
|
||
makeNpc('wuxia-npc-maid', '旧宫侍女', '宫人', '侍', '嘴上说得少,却总知道哪条回廊最近不该过去。'),
|
||
],
|
||
},
|
||
];
|
||
|
||
const XIANXIA_SCENES: SceneTemplate[] = [
|
||
{
|
||
id: 'xianxia-cloud-gate',
|
||
name: '云海仙门',
|
||
description: '云阶在脚下翻涌,门阙后方灵光不断,来客与守门异物都极显眼。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-02', 'monster-16'],
|
||
connectedSceneIds: ['xianxia-floating-isle', 'xianxia-celestial-corridor', 'xianxia-star-vessel'],
|
||
forwardSceneId: 'xianxia-celestial-corridor',
|
||
treasureHints: ['云阶尽头的灵符匣', '门阙阴影里的玉牌'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-gate-attendant', '守门灵官', '门官', '门', '站在门阙侧旁观来者,像在等一份迟迟未到的回报。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-floating-isle',
|
||
name: '悬空仙岛',
|
||
description: '浮岛边缘风大云急,灵禽与飞蛾总绕着岛沿的光带盘旋。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-12', 'monster-16'],
|
||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-waterfall-cliff', 'xianxia-moon-lake'],
|
||
forwardSceneId: 'xianxia-moon-lake',
|
||
treasureHints: ['浮岛边缘的灵羽匣', '云藤下悬着的小玉瓶'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-cloud-hermit', '云栖散修', '散修', '云', '常坐在浮岛边缘打坐,对天风和禁制的变化很敏感。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-celestial-corridor',
|
||
name: '天宫长廊',
|
||
description: '廊柱之间回响着空灵风声,禁制和书妖都喜欢寄在这类高处回廊里。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-02', 'monster-14'],
|
||
connectedSceneIds: ['xianxia-cloud-gate', 'xianxia-thunder-altar', 'xianxia-ancient-ruins'],
|
||
forwardSceneId: 'xianxia-thunder-altar',
|
||
treasureHints: ['廊柱暗槽里的玉简', '风铃后藏着的封签'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-palace-page', '抄经侍者', '侍者', '卷', '抱着卷册在廊下快步穿行,像是在躲某种会翻页的东西。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-herb-garden',
|
||
name: '灵药花圃',
|
||
description: '灵草灵花层层叠开,香气诱人,却也最容易养出食灵的怪物。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-15', 'monster-05'],
|
||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-sacred-tree', 'xianxia-moon-lake'],
|
||
forwardSceneId: 'xianxia-sacred-tree',
|
||
treasureHints: ['药圃深处的灵壶', '花架下压着的采录册'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-herbal-keeper', '药圃执事', '药师', '药', '守着花圃记录灵植开谢,也清楚哪些地方最近长出了怪东西。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-jade-cavern',
|
||
name: '寒玉洞天',
|
||
description: '洞壁结着寒玉光泽,地面湿滑,水灵和阴性异物都爱停在这里。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-10', 'monster-12', 'monster-20'],
|
||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-moon-lake', 'xianxia-ancient-ruins'],
|
||
forwardSceneId: 'xianxia-moon-lake',
|
||
treasureHints: ['寒玉裂隙里的灵髓', '冰面下闪着光的贝匣'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-cold-scholar', '寒洞客', '访客', '玉', '在洞天里采样寒玉碎屑,像在研究更深处的封禁。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-molten-realm',
|
||
name: '熔岩秘境',
|
||
description: '热浪裹着赤光翻涌,附近的异章与泥灵都容易被灼气激得发狂。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-14', 'monster-10'],
|
||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-waterfall-cliff', 'xianxia-jade-cavern'],
|
||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||
treasureHints: ['熔岩边冷却的矿匣', '焦岩后藏着的火纹石'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-fire-forger', '熔炉匠修', '炼匠', '炉', '在热浪里锻器不歇,见惯灵火失控的后果。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-thunder-altar',
|
||
name: '雷殿祭坛',
|
||
description: '祭坛上方雷纹未散,灵书、飞蛾与雷意余波总会把来者围在中心。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-02', 'monster-16'],
|
||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-molten-realm', 'xianxia-star-vessel'],
|
||
forwardSceneId: 'xianxia-star-vessel',
|
||
treasureHints: ['祭坛角落的雷纹匣', '断碑背面的青铜铃'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-thunder-keeper', '祭雷守使', '守使', '雷', '总站在祭坛边缘看天,像在确认下一道雷会落到哪里。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-star-vessel',
|
||
name: '星舟甲板',
|
||
description: '甲板横在高天之上,风压和星光都很强,飞行异物最爱在这里盘旋。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-12', 'monster-16', 'monster-02'],
|
||
connectedSceneIds: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-floating-isle'],
|
||
forwardSceneId: 'xianxia-floating-isle',
|
||
treasureHints: ['舵台后的星图匣', '甲板缝里卡着的灵罗盘'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-helmsman', '星舟舵手', '舵手', '舟', '守着老旧星舟的航线图,对高空中的异动异常敏感。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-moon-lake',
|
||
name: '月湖仙洲',
|
||
description: '湖光像铺开的镜面,水灵、章灵与花影都可能从月色里浮出来。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-20', 'monster-14', 'monster-15'],
|
||
connectedSceneIds: ['xianxia-jade-cavern', 'xianxia-floating-isle', 'xianxia-herb-garden'],
|
||
forwardSceneId: 'xianxia-herb-garden',
|
||
treasureHints: ['湖岸边漂来的玉匣', '月色下若隐若现的银铃'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-lake-watcher', '湖畔琴师', '琴师', '琴', '常在月湖边抚琴,像在等某段旋律把什么引出来。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-ancient-ruins',
|
||
name: '古仙遗迹',
|
||
description: '残碑、断墙与旧阵纹密密叠在一起,最容易招来书妖和骨灵残念。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-02', 'monster-05', 'monster-12'],
|
||
connectedSceneIds: ['xianxia-celestial-corridor', 'xianxia-jade-cavern', 'xianxia-sacred-tree'],
|
||
forwardSceneId: 'xianxia-sacred-tree',
|
||
treasureHints: ['残阵中心埋着的玉简', '倒塌碑柱里的小匣'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-ruin-scholar', '寻迹司录', '司录', '录', '拿着一卷旧图在断墙间比对,像快要找到重要坐标。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-sacred-tree',
|
||
name: '神木秘境',
|
||
description: '古树根系盘踞成殿,枝叶遮天,最易孕出噬灵花与窥视灵眼。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-15', 'monster-05'],
|
||
connectedSceneIds: ['xianxia-herb-garden', 'xianxia-ancient-ruins', 'xianxia-waterfall-cliff'],
|
||
forwardSceneId: 'xianxia-waterfall-cliff',
|
||
treasureHints: ['盘根间的木纹匣', '树洞深处垂着的灵种'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-tree-ward', '守木灵侍', '灵侍', '木', '一直绕着古树巡看,像是担心有人惊动树心。'),
|
||
],
|
||
},
|
||
{
|
||
id: 'xianxia-waterfall-cliff',
|
||
name: '飞瀑仙崖',
|
||
description: '瀑声压住一切杂音,崖边潮气浓重,飞蝠、水灵与章影都很容易现身。',
|
||
worldType: WorldType.XIANXIA,
|
||
hostileNpcPresetIds: ['monster-12', 'monster-20', 'monster-16'],
|
||
connectedSceneIds: ['xianxia-sacred-tree', 'xianxia-molten-realm', 'xianxia-floating-isle'],
|
||
forwardSceneId: 'xianxia-cloud-gate',
|
||
treasureHints: ['瀑幕后闪着光的石匣', '崖边藤上挂着的护身铃'],
|
||
extraNpcs: [
|
||
makeNpc('xianxia-npc-cliff-scout', '崖巡女修', '巡修', '崖', '长期在飞瀑边巡看,脚步轻得像从不曾碰到过石面。'),
|
||
],
|
||
},
|
||
];
|
||
|
||
function buildScenePoolFromTemplates(templates: SceneTemplate[]): ScenePreset[] {
|
||
const imagePool = collectWorldImagePool(templates[0]?.worldType ?? WorldType.WUXIA, templates.length);
|
||
|
||
return templates.map((template, index) => {
|
||
const characterNpcs = buildCharacterNpcPool(template.id, template.worldType);
|
||
const hostileNpcs = template.hostileNpcPresetIds
|
||
.map(monsterId => buildHostileSceneNpc(template.id, template.worldType, monsterId))
|
||
.filter(Boolean) as SceneNpc[];
|
||
const mergedSceneNpcs = mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType);
|
||
const sceneOverride = SCENE_OVERRIDES[template.id] ?? {};
|
||
return {
|
||
...template,
|
||
...sceneOverride,
|
||
imageSrc: sceneOverride.imageSrc ?? imagePool[index] ?? imagePool[0] ?? '',
|
||
connections: buildDefaultSceneConnections(
|
||
sceneOverride.connectedSceneIds ?? template.connectedSceneIds,
|
||
sceneOverride.forwardSceneId ?? template.forwardSceneId,
|
||
),
|
||
narrativeResidues: template.treasureHints.slice(0, 2).map((hint, residueIndex) => ({
|
||
id: `residue:${template.id}:${residueIndex + 1}`,
|
||
title: `${template.name}的残痕 ${residueIndex + 1}`,
|
||
visibleClue: hint,
|
||
linkedFactIds: [],
|
||
linkedThreadIds: [],
|
||
})),
|
||
npcs: mergedSceneNpcs,
|
||
} satisfies ScenePreset;
|
||
});
|
||
}
|
||
|
||
const ALL_SCENE_PRESETS: ScenePreset[] = [
|
||
...buildScenePoolFromTemplates(WUXIA_SCENES),
|
||
...buildScenePoolFromTemplates(XIANXIA_SCENES),
|
||
];
|
||
|
||
export function getScenePresetsByWorld(worldType: WorldType): ScenePreset[] {
|
||
if (worldType === WorldType.CUSTOM) {
|
||
const profile = getRuntimeCustomWorldProfile();
|
||
return profile ? buildCustomScenePresets(profile) : [];
|
||
}
|
||
return ALL_SCENE_PRESETS.filter(scene => scene.worldType === worldType);
|
||
}
|
||
|
||
export function getScenePreset(worldType: WorldType, index: number): ScenePreset | null {
|
||
const scenes = getScenePresetsByWorld(worldType);
|
||
if (scenes.length === 0) return null;
|
||
return scenes[((index % scenes.length) + scenes.length) % scenes.length] ?? null;
|
||
}
|
||
|
||
export function getScenePresetById(worldType: WorldType, sceneId: string | null | undefined): ScenePreset | null {
|
||
if (!sceneId) return null;
|
||
return getScenePresetsByWorld(worldType).find(scene => scene.id === sceneId) ?? null;
|
||
}
|
||
|
||
export function getScenePresetOverrideById(sceneId: string) {
|
||
return SCENE_OVERRIDES[sceneId] ?? null;
|
||
}
|
||
|
||
export function getSceneNpcPresetOverrideById(npcId: string) {
|
||
return SCENE_NPC_OVERRIDES[npcId] ?? null;
|
||
}
|
||
|
||
export function getCharacterHomeScenePreset(worldType: WorldType, characterId: string) {
|
||
const sceneId = getCharacterHomeSceneId(worldType, characterId);
|
||
return sceneId ? getScenePresetById(worldType, sceneId) : null;
|
||
}
|
||
|
||
const WORLD_CAMP_SCENE_IDS: Record<Exclude<WorldType, WorldType.CUSTOM>, string> = {
|
||
[WorldType.WUXIA]: 'wuxia-border-camp',
|
||
[WorldType.XIANXIA]: 'xianxia-star-vessel',
|
||
};
|
||
|
||
export function getWorldCampScenePreset(worldType: WorldType) {
|
||
if (worldType === WorldType.CUSTOM) {
|
||
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
|
||
}
|
||
return getScenePresetById(worldType, WORLD_CAMP_SCENE_IDS[worldType]);
|
||
}
|
||
|
||
export function getConnectedScenePresets(worldType: WorldType, sceneId: string | null | undefined) {
|
||
const currentScene = getScenePresetById(worldType, sceneId);
|
||
if (!currentScene) return [];
|
||
|
||
return currentScene.connectedSceneIds
|
||
.map(id => getScenePresetById(worldType, id))
|
||
.filter(Boolean) as ScenePreset[];
|
||
}
|
||
|
||
export function getForwardScenePreset(worldType: WorldType, sceneId: string | null | undefined) {
|
||
const currentScene = getScenePresetById(worldType, sceneId);
|
||
if (!currentScene?.forwardSceneId) return null;
|
||
return getScenePresetById(worldType, currentScene.forwardSceneId);
|
||
}
|
||
|
||
export function getTravelScenePreset(worldType: WorldType, sceneId: string | null | undefined) {
|
||
const currentScene = getScenePresetById(worldType, sceneId);
|
||
if (!currentScene) return null;
|
||
|
||
const connectedScenes = getConnectedScenePresets(worldType, sceneId);
|
||
return connectedScenes.find(scene => scene.id !== currentScene.forwardSceneId) ?? connectedScenes[0] ?? null;
|
||
}
|
||
|
||
export function getSceneNpcById(worldType: WorldType, sceneId: string | null | undefined, npcId: string | undefined) {
|
||
if (!npcId) return null;
|
||
const scene = getScenePresetById(worldType, sceneId);
|
||
return scene?.npcs.find(npc => npc.id === npcId) ?? null;
|
||
}
|
||
|
||
export function buildSceneEntityCatalogText(worldType: WorldType, sceneId: string | null | undefined) {
|
||
const scene = getScenePresetById(worldType, sceneId);
|
||
if (!scene) {
|
||
return '当前区域暂无可用实体目录。';
|
||
}
|
||
|
||
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(scene);
|
||
const monsterText = hostileNpcPresetIds.length > 0
|
||
? hostileNpcPresetIds
|
||
.map(monsterId => getMonsterPresetById(worldType, monsterId)?.name ?? monsterId)
|
||
.join('、')
|
||
: '暂无明确怪物';
|
||
|
||
const hostileNpcs = getSceneHostileNpcs(scene);
|
||
const friendlyNpcs = getSceneFriendlyNpcs(scene);
|
||
const hostileNpcText = hostileNpcs.length > 0
|
||
? hostileNpcs.map(npc => npc.name).join('、')
|
||
: '暂无明确敌对角色';
|
||
const friendlyNpcText = friendlyNpcs.length > 0
|
||
? friendlyNpcs.map(npc => `${npc.name}(${npc.role})`).join('、')
|
||
: '暂无明确场景角色';
|
||
const treasureText = scene.treasureHints.length > 0
|
||
? scene.treasureHints.join('、')
|
||
: '暂无明确宝藏线索';
|
||
const residueText = (scene.narrativeResidues?.length ?? 0) > 0
|
||
? scene.narrativeResidues!.map((residue: NonNullable<ScenePresetInfo['narrativeResidues']>[number]) => `${residue.title}:${residue.visibleClue}`).join('、')
|
||
: '暂无明显场景残痕';
|
||
|
||
return [
|
||
`当前怪物:${monsterText}`,
|
||
`当前敌对角色:${hostileNpcText}`,
|
||
`当前场景角色:${friendlyNpcText}`,
|
||
`当前宝藏线索:${treasureText}`,
|
||
`当前场景残痕:${residueText}`,
|
||
].join('\n');
|
||
}
|