Files
Genarrative/src/data/scenePresets.ts

865 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { buildCustomCampSceneName } from '../services/customWorldPresentation';
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
import { CustomWorldProfile, Encounter, SceneNpc, WorldType } from '../types';
import { buildRoleAttributeProfileFromLegacyData } from './attributeProfileGenerator';
import { resolveAttributeSchema } from './attributeResolver';
import {
buildCharacterNpc,
buildCustomWorldPlayableCharacters,
getCharacterHomeSceneId,
getCharacterNpcSceneIds,
PRESET_CHARACTERS,
} from './characterPresets';
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
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[];
monsterIds: string[];
npcs: SceneNpc[];
treasureHints: string[];
}
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;
monsterIds: 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 = resolveRuleWorldType(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 collectAllImagePool() {
const refs: string[] = [];
for (const pack of PACKS) {
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
refs.push(buildImagePath(pack.packName, imageNumber));
}
}
return refs;
}
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),
hostileNpcPresetId: preset.id,
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 buildEncounterFromSceneNpc(
npc: SceneNpc,
xMeters?: number,
): Encounter {
const hostileNpcPresetId = npc.hostileNpcPresetId ?? npc.monsterPresetId;
const monsterPresetId = npc.monsterPresetId ?? npc.hostileNpcPresetId;
return {
id: npc.id,
kind: 'npc',
characterId: npc.characterId,
hostileNpcPresetId,
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,
};
}
function buildCustomSceneNpc(
npc: CustomWorldProfile['storyNpcs'][number],
profile: CustomWorldProfile,
anchorWorldType: WorldType,
): SceneNpc {
const monsterPreset =
npc.initialAffinity < 0
? resolveCustomWorldNpcMonsterPreset(npc, anchorWorldType)
: 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,
name: npc.name,
role: npc.role,
avatar: npc.name.slice(0, 1) || '?',
description: `${npc.description} 动机:${npc.motivation}`,
gender: inferCustomNpcGender(npc.id, npc.name),
monsterPresetId: monsterPreset?.id,
hostileNpcPresetId: monsterPreset?.id,
initialAffinity: npc.initialAffinity,
hostile,
recruitable: !hostile,
functions: hostile
? ['fight']
: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'],
attributeProfile,
};
}
function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
return kind === 'camp' ? 'custom-scene-camp' : `custom-scene-landmark-${index + 1}`;
}
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
const allImages = collectAllImagePool();
const imageOffset = hashText(profile.id || profile.name) % Math.max(1, allImages.length);
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
const baseMonsterPool: string[] = getScenePresetsByWorld(anchorWorldType)
.flatMap((scene: ScenePreset) => scene.monsterIds)
.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 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 customStoryNpcs = profile.storyNpcs.map(npc =>
buildCustomSceneNpc(npc, profile, anchorWorldType),
);
const chunkSize = Math.max(4, Math.ceil(customStoryNpcs.length / Math.max(1, profile.landmarks.length)));
const customScenes: ScenePreset[] = [
{
id: campSceneId,
name: buildCustomCampSceneName(profile),
description: `你在${profile.name}的临时营地整备行装。${profile.summary}`,
worldType: WorldType.CUSTOM,
imageSrc: profile.landmarks[0]?.imageSrc ?? allImages[imageOffset] ?? '',
connectedSceneIds: landmarkSceneIds.slice(0, 3),
forwardSceneId: landmarkSceneIds[0],
monsterIds: [],
treasureHints: [
`${profile.name}地图残页`,
...profile.landmarks.slice(0, 3).map(landmark => `${landmark.name}的旧线索`),
].slice(0, 4),
npcs: campNpcs,
},
...profile.landmarks.map((landmark, index): ScenePreset => {
const sceneNpcs = customStoryNpcs.slice(index * chunkSize, (index + 1) * chunkSize);
const connectedSceneIds: string[] = [
campSceneId,
landmarkSceneIds[(index - 1 + landmarkSceneIds.length) % landmarkSceneIds.length],
landmarkSceneIds[(index + 1) % landmarkSceneIds.length],
]
.filter((sceneId): sceneId is string => Boolean(sceneId))
.filter((sceneId, sceneIndex, array) => array.indexOf(sceneId) === sceneIndex);
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
const monsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
const hostileNpcs = monsterIds
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), anchorWorldType, monsterId))
.filter(Boolean) as SceneNpc[];
return {
id: buildCustomSceneId('landmark', index),
name: landmark.name,
description: landmark.description,
worldType: WorldType.CUSTOM,
imageSrc: landmark.imageSrc ?? allImages[(imageOffset + index + 1) % Math.max(1, allImages.length)] ?? '',
connectedSceneIds,
forwardSceneId: connectedSceneIds.find(sceneId => sceneId !== campSceneId) ?? campSceneId,
monsterIds,
treasureHints: [
`${landmark.name}的旧线索`,
`${profile.name}相关遗物`,
profile.storyNpcs[index]?.name ? `${profile.storyNpcs[index]!.name}留下的痕迹` : `${profile.playerGoal.slice(0, 10)}相关痕迹`,
],
npcs: [...sceneNpcs, ...hostileNpcs],
};
}),
];
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 PRESET_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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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,
monsterIds: ['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.monsterIds
.map(monsterId => buildHostileSceneNpc(template.id, template.worldType, monsterId))
.filter(Boolean) as SceneNpc[];
const sceneOverride = SCENE_OVERRIDES[template.id] ?? {};
return {
...template,
...sceneOverride,
imageSrc: sceneOverride.imageSrc ?? imagePool[index] ?? imagePool[0] ?? '',
npcs: mergeNpcs(characterNpcs, [...hostileNpcs, ...template.extraNpcs], template.worldType),
} 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 monsterText = scene.monsterIds.length > 0
? scene.monsterIds
.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('、')
: '暂无明确宝藏线索';
return [
`当前怪物:${monsterText}`,
`当前敌对角色:${hostileNpcText}`,
`当前场景角色:${friendlyNpcText}`,
`当前宝藏线索:${treasureText}`,
].join('\n');
}