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>; type KnownSceneNpcGender = Exclude, 'unknown'>; export type SceneNpcPresetOverride = Partial> & { gender?: KnownSceneNpcGender; }; const SCENE_OVERRIDES = sceneOverridesJson as Record; const SCENE_NPC_OVERRIDES = sceneNpcOverridesJson as Record; // Keep scene-only NPC genders explicit so encounter prompts never receive "unknown". const SCENE_NPC_GENDERS: Record = { '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 = [ '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 = [ '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): KnownSceneNpcGender | null { for (const candidate of candidates) { if (candidate === 'male' || candidate === 'female') { return candidate; } } return null; } function resolveSceneNpcGender( npcId: string, ...candidates: Array ): 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(); [...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, 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[number]) => `${residue.title}:${residue.visibleClue}`).join('、') : '暂无明显场景残痕'; return [ `当前怪物:${monsterText}`, `当前敌对角色:${hostileNpcText}`, `当前场景角色:${friendlyNpcText}`, `当前宝藏线索:${treasureText}`, `当前场景残痕:${residueText}`, ].join('\n'); }