Files
Genarrative/src/data/hostileNpcPresets.ts
高物 0981d6ee1b
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-11 15:43:32 +08:00

1130 lines
33 KiB
TypeScript

import { HostileNpcAnimationConfig, HostileNpcSpriteConfig } from '../components/HostileNpcAnimator';
import {generateRuntimeItemAiIntents} from '../services/runtimeItemAiDirector';
import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, RuntimeItemPlan, SceneHostileNpc, WorldType } from '../types';
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
import { buildDefaultAxisVector } from './attributeResolver';
import {normalizeBuildTags} from './buildTags';
import {
buildRuntimeCustomWorldInventoryItems,
resolveCompatibilityTemplateWorldType,
} from './customWorldRuntime';
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
import {
applyRuntimeItemNarrativeToExistingItem,
buildRuntimeItemAiIntent,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemNarrative';
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
worldType: WorldType;
description: string;
introAction: string;
baseStats: Pick<SceneHostileNpc, 'attackRange' | 'speed' | 'hp' | 'maxHp'>;
combatTags: string[];
habitatTags: string[];
lootTable: MonsterLootEntry[];
attributeProfile: NonNullable<SceneHostileNpc['attributeProfile']>;
behaviorVectors: RoleActionDefinition[];
}
export type HostileNpcPresetOverride = Partial<Omit<HostileNpcPreset, 'animations' | 'baseStats'>> & {
animations?: Partial<HostileNpcPreset['animations']>;
baseStats?: Partial<HostileNpcPreset['baseStats']>;
lootTable?: HostileNpcPreset['lootTable'];
};
export type MonsterPreset = HostileNpcPreset;
export type MonsterPresetOverride = HostileNpcPresetOverride;
function rowStart(sheetWidth: number, frameWidth: number, rowIndex: number) {
return Math.floor(sheetWidth / frameWidth) * rowIndex;
}
function rowAnimation(
sheetWidth: number,
frameWidth: number,
rowIndex: number,
frames: number,
fps = 24,
): HostileNpcAnimationConfig {
return {
start: rowStart(sheetWidth, frameWidth, rowIndex),
frames,
fps,
};
}
function buildHostileNpcLootItem(
id: string,
category: string,
name: string,
rarity: InventoryItem['rarity'],
tags: string[],
quantity = 1,
description?: string,
): InventoryItem {
return {
id: `monster-loot:${id}`,
category,
name,
quantity,
rarity,
tags,
description,
};
}
function buildHostileNpcLootEntry(
id: string,
dropRate: number,
item: InventoryItem,
): MonsterLootEntry {
return {
id,
dropRate,
item,
};
}
const BASE_HOSTILE_NPC_PRESETS: Array<
Omit<HostileNpcPreset, 'lootTable' | 'combatTags' | 'attributeProfile' | 'behaviorVectors'> & {
combatTags?: string[];
}
> = [
{
id: 'monster-03',
worldType: WorldType.WUXIA,
name: '断骨祟灵',
description: '一团缠着碎骨与阴气的游灵,喜欢在地宫、荒村和古旧殿宇中徘徊。',
introAction: '摇晃着碎骨在前方游荡,阴气正一点点漫开',
src: '/Pixel Monsters Vol1/Sprites/monster_03.png',
frameWidth: 61,
frameHeight: 37,
sheetWidth: 976,
animations: {
idle: rowAnimation(976, 61, 0, 4),
move: rowAnimation(976, 61, 1, 8),
attack: rowAnimation(976, 61, 2, 16),
die: rowAnimation(976, 61, 4, 11),
},
baseStats: { attackRange: 1.3, speed: 6.8, hp: 102, maxHp: 102 },
habitatTags: ['地宫', '荒村', '废寺', '遗迹'],
},
{
id: 'monster-04',
worldType: WorldType.WUXIA,
name: '石背蜗怪',
description: '驮着厚重石壳缓慢爬行,常盘踞在石阶、桥边与潮湿山路上。',
introAction: '拖着沉重石壳缓慢拱上前来',
src: '/Pixel Monsters Vol1/Sprites/monster_04.png',
frameWidth: 58,
frameHeight: 37,
sheetWidth: 870,
animations: {
idle: rowAnimation(870, 58, 0, 12),
move: rowAnimation(870, 58, 1, 4),
attack: rowAnimation(870, 58, 2, 9),
die: rowAnimation(870, 58, 3, 15),
},
baseStats: { attackRange: 1.1, speed: 4.8, hp: 152, maxHp: 152 },
habitatTags: ['石阶', '渡口', '古桥', '山门'],
},
{
id: 'monster-06',
worldType: WorldType.WUXIA,
name: '岩甲蛛兽',
description: '甲壳坚硬、步足沉重,常潜伏在残垣、矿洞和石阶附近拦截来者。',
introAction: '守在前方,盯着来者蓄势待发',
src: '/Pixel Monsters Vol1/Sprites/monster_06.png',
frameWidth: 73,
frameHeight: 34,
sheetWidth: 1606,
animations: {
idle: rowAnimation(1606, 73, 0, 16),
move: rowAnimation(1606, 73, 1, 4),
attack: rowAnimation(1606, 73, 2, 22),
die: rowAnimation(1606, 73, 3, 11),
},
baseStats: { attackRange: 1.2, speed: 5.8, hp: 140, maxHp: 140 },
habitatTags: ['矿道', '石阶', '废城', '地宫'],
},
{
id: 'monster-07',
worldType: WorldType.WUXIA,
name: '孢爆菇灵',
description: '伪装成无害的小蘑菇,靠近后会突然爆出孢雾和酸毒。',
introAction: '看似无害地立在前方,伞盖正轻轻颤动',
src: '/Pixel Monsters Vol1/Sprites/monster_07.png',
frameWidth: 76,
frameHeight: 59,
sheetWidth: 1444,
animations: {
idle: rowAnimation(1444, 76, 0, 4),
move: rowAnimation(1444, 76, 1, 8),
attack: rowAnimation(1444, 76, 2, 19),
die: rowAnimation(1444, 76, 3, 11),
},
baseStats: { attackRange: 1.1, speed: 6.1, hp: 108, maxHp: 108 },
habitatTags: ['雾林', '荒村', '废寺', '山路'],
},
{
id: 'monster-08',
worldType: WorldType.WUXIA,
name: '枯藤伏虫',
description: '像一截会蠕动的枯藤,贴地潜行,适合在林地和湿地里伏击。',
introAction: '贴着地面缓缓爬来,像一截活过来的枯藤',
src: '/Pixel Monsters Vol1/Sprites/monster_08.png',
frameWidth: 50,
frameHeight: 23,
sheetWidth: 950,
animations: {
idle: rowAnimation(950, 50, 0, 19),
move: rowAnimation(950, 50, 1, 12),
attack: rowAnimation(950, 50, 2, 11),
die: rowAnimation(950, 50, 3, 17),
},
baseStats: { attackRange: 1.0, speed: 6.7, hp: 118, maxHp: 118 },
habitatTags: ['竹林', '雾林', '沼泽', '荒野'],
},
{
id: 'monster-11',
worldType: WorldType.WUXIA,
name: '夜牙潜兽',
description: '夜色中贴地匍匐的潜伏兽,惯于在街巷、残垣与营地周边游猎。',
introAction: '收拢身形伏在前方,尖牙在阴影里一闪而过',
src: '/Pixel Monsters Vol1/Sprites/monster_11.png',
frameWidth: 70,
frameHeight: 36,
sheetWidth: 770,
animations: {
idle: rowAnimation(770, 70, 0, 6),
move: rowAnimation(770, 70, 1, 8),
attack: rowAnimation(770, 70, 2, 11),
die: rowAnimation(770, 70, 3, 10),
},
baseStats: { attackRange: 1.4, speed: 7.4, hp: 124, maxHp: 124 },
habitatTags: ['长街', '营地', '断垣', '宫苑'],
},
{
id: 'monster-13',
worldType: WorldType.WUXIA,
name: '青鳞毒蛇',
description: '身形细长,吐信极快,最喜欢守在草木掩映和石缝交错之地。',
introAction: '盘在前路中央缓缓昂头,蛇信正来回试探',
src: '/Pixel Monsters Vol1/Sprites/monster_13.png',
frameWidth: 66,
frameHeight: 36,
sheetWidth: 1056,
animations: {
idle: rowAnimation(1056, 66, 0, 16),
attack: rowAnimation(1056, 66, 1, 15),
},
baseStats: { attackRange: 1.7, speed: 8.2, hp: 96, maxHp: 96 },
habitatTags: ['竹林', '雾林', '山路', '花圃'],
},
{
id: 'monster-18',
worldType: WorldType.WUXIA,
name: '瘴背古象',
description: '体格厚重,背脊总有瘴气或孢苔翻涌,适合盘踞在矿道和废弃重地。',
introAction: '拖着厚重身躯踩碎碎石,正迎面压来',
src: '/Pixel Monsters Vol1/Sprites/monster_18.png',
frameWidth: 77,
frameHeight: 43,
sheetWidth: 1771,
animations: {
idle: rowAnimation(1771, 77, 0, 8),
move: rowAnimation(1771, 77, 0, 8),
attack: rowAnimation(1771, 77, 2, 23),
die: rowAnimation(1771, 77, 1, 17),
},
baseStats: { attackRange: 1.8, speed: 4.4, hp: 186, maxHp: 186 },
habitatTags: ['矿道', '铸坊', '废城', '边关'],
},
{
id: 'monster-02',
worldType: WorldType.XIANXIA,
name: '秘匣书妖',
description: '像会自行翻页的秘典与宝匣,常在仙门、遗迹与禁制附近浮游。',
introAction: '在前方半空翻页浮动,纸页间渗出幽光',
src: '/Pixel Monsters Vol1/Sprites/monster_02.png',
frameWidth: 43,
frameHeight: 46,
sheetWidth: 688,
animations: {
idle: rowAnimation(688, 43, 0, 16),
move: rowAnimation(688, 43, 1, 5),
attack: rowAnimation(688, 43, 2, 12),
die: rowAnimation(688, 43, 4, 7),
},
baseStats: { attackRange: 1.4, speed: 7.3, hp: 112, maxHp: 112 },
habitatTags: ['仙门', '长廊', '遗迹', '祭坛'],
},
{
id: 'monster-05',
worldType: WorldType.XIANXIA,
name: '血瞳妖眼',
description: '硕大眼球外生细足,擅长隔着雾障与灵光窥伺猎物。',
introAction: '转动血色眼瞳在前方缓慢逼近',
src: '/Pixel Monsters Vol1/Sprites/monster_05.png',
frameWidth: 60,
frameHeight: 48,
sheetWidth: 840,
animations: {
idle: rowAnimation(840, 60, 0, 12),
move: rowAnimation(840, 60, 1, 8),
attack: rowAnimation(840, 60, 2, 9),
die: rowAnimation(840, 60, 4, 14),
},
baseStats: { attackRange: 1.6, speed: 7.0, hp: 126, maxHp: 126 },
habitatTags: ['妖雾', '洞天', '谷地', '秘境'],
},
{
id: 'monster-10',
worldType: WorldType.XIANXIA,
name: '青腐泥灵',
description: '浑身流着青绿色黏液,移动时像在地面拖出灵腐痕迹。',
introAction: '一边渗出黏液一边缓慢朝前爬来',
src: '/Pixel Monsters Vol1/Sprites/monster_10.png',
frameWidth: 48,
frameHeight: 25,
sheetWidth: 720,
animations: {
idle: rowAnimation(720, 48, 0, 8),
move: rowAnimation(720, 48, 1, 8),
attack: rowAnimation(720, 48, 2, 13),
die: rowAnimation(720, 48, 3, 15),
},
baseStats: { attackRange: 1.2, speed: 5.0, hp: 136, maxHp: 136 },
habitatTags: ['洞天', '谷地', '月湖', '石坑'],
},
{
id: 'monster-12',
worldType: WorldType.XIANXIA,
name: '幽烬灵蝠',
description: '翅翼缭绕灰烬般的灵火,常成群出没于洞天、崖壁与灵脉附近。',
introAction: '盘旋在前方半空,幽火忽明忽暗',
src: '/Pixel Monsters Vol1/Sprites/monster_12.png',
frameWidth: 46,
frameHeight: 43,
sheetWidth: 506,
animations: {
idle: rowAnimation(506, 46, 0, 8),
move: rowAnimation(506, 46, 1, 4),
attack: rowAnimation(506, 46, 2, 7),
die: rowAnimation(506, 46, 3, 11),
},
baseStats: { attackRange: 1.6, speed: 7.8, hp: 120, maxHp: 120 },
habitatTags: ['洞天', '崖壁', '仙岛', '古迹'],
},
{
id: 'monster-14',
worldType: WorldType.XIANXIA,
name: '赤潮墨章',
description: '像灵海中游出的赤色异章,触腕灵活,最爱在潮湿灵境与月湖边出没。',
introAction: '舒展触腕在前方缓缓游弋',
src: '/Pixel Monsters Vol1/Sprites/monster_14.png',
frameWidth: 61,
frameHeight: 46,
sheetWidth: 671,
animations: {
idle: rowAnimation(671, 61, 0, 8),
move: rowAnimation(671, 61, 1, 8),
attack: rowAnimation(671, 61, 2, 9),
die: rowAnimation(671, 61, 4, 11),
},
baseStats: { attackRange: 1.7, speed: 7.1, hp: 128, maxHp: 128 },
habitatTags: ['月湖', '天河', '仙洲', '灵泉'],
},
{
id: 'monster-15',
worldType: WorldType.XIANXIA,
name: '噬灵妖花',
description: '花口如獠牙,根须会吞食靠近的灵气与血肉,常藏在灵植密地。',
introAction: '缓缓舒展花瓣,在前方守着必经之路',
src: '/Pixel Monsters Vol1/Sprites/monster_15.png',
frameWidth: 75,
frameHeight: 62,
sheetWidth: 2400,
animations: {
idle: rowAnimation(2400, 75, 0, 6),
attack: rowAnimation(2400, 75, 1, 32),
die: rowAnimation(2400, 75, 2, 17),
},
baseStats: { attackRange: 1.5, speed: 4.6, hp: 168, maxHp: 168 },
habitatTags: ['花圃', '神木', '谷地', '秘境'],
},
{
id: 'monster-16',
worldType: WorldType.XIANXIA,
name: '噬雾飞蛾',
description: '借雾气遮身,飞行轨迹诡谲,喜欢围着灵光和人影打转。',
introAction: '绕着雾气轻轻盘飞,正朝前方逼近',
src: '/Pixel Monsters Vol1/Sprites/monster_16.png',
frameWidth: 52,
frameHeight: 50,
sheetWidth: 728,
animations: {
idle: rowAnimation(728, 52, 0, 8),
move: rowAnimation(728, 52, 1, 4),
attack: rowAnimation(728, 52, 2, 14),
die: rowAnimation(728, 52, 3, 9),
},
baseStats: { attackRange: 1.8, speed: 6.9, hp: 130, maxHp: 130 },
habitatTags: ['妖雾', '仙门', '星舟', '悬空'],
},
{
id: 'monster-20',
worldType: WorldType.XIANXIA,
name: '澄潮灵母',
description: '像由灵泉和潮汐凝出的母体水灵,甩动触须时会带出大片灵浪。',
introAction: '在前方拖着水光般的触须缓缓游来',
src: '/Pixel Monsters Vol1/Sprites/monster_20.png',
frameWidth: 67,
frameHeight: 42,
sheetWidth: 804,
animations: {
idle: rowAnimation(804, 67, 0, 6),
move: rowAnimation(804, 67, 1, 6),
attack: rowAnimation(804, 67, 2, 12),
},
baseStats: { attackRange: 1.4, speed: 6.4, hp: 138, maxHp: 138 },
habitatTags: ['月湖', '灵泉', '天河', '寒玉'],
},
];
const BASE_HOSTILE_NPC_LOOT_TABLES: Record<string, MonsterLootEntry[]> = {
'monster-02': [
buildHostileNpcLootEntry(
'monster-02-script-fragment',
0.74,
buildHostileNpcLootItem(
'script-fragment',
'材料',
'残页碎片',
'common',
['material'],
2,
'散落的书页和易碎纸片上,还残留着护符墨水的痕迹。',
),
),
buildHostileNpcLootEntry(
'monster-02-archive-sigil',
0.24,
buildHostileNpcLootItem(
'archive-sigil',
'稀有品',
'档案印记',
'rare',
['relic', 'mana'],
1,
'一枚悬浮的印记,内部储着周围灵文的回响。',
),
),
],
'monster-03': [
buildHostileNpcLootEntry(
'monster-03-bone-dust',
0.74,
buildHostileNpcLootItem(
'bone-dust',
'材料',
'骨尘',
'common',
['material'],
2,
'破碎骨灵散落下来的粉末状残渣。',
),
),
buildHostileNpcLootEntry(
'monster-03-whisper-ember',
0.22,
buildHostileNpcLootItem(
'whisper-ember',
'稀有品',
'低语余烬',
'rare',
['relic', 'mana'],
1,
'微弱的火星里还带着不安魂力的回响。',
),
),
],
'monster-04': [
buildHostileNpcLootEntry(
'monster-04-stone-shell',
0.7,
buildHostileNpcLootItem(
'stone-shell-shard',
'材料',
'石壳碎片',
'uncommon',
['material'],
2,
'从石背蜗怪身上剥落下来的坚硬护壳碎片。',
),
),
buildHostileNpcLootEntry(
'monster-04-venom-gland',
0.32,
buildHostileNpcLootItem(
'venom-gland',
'材料',
'毒腺',
'uncommon',
['material'],
1,
'仍带着余温的密封毒囊。',
),
),
],
'monster-05': [
buildHostileNpcLootEntry(
'monster-05-watcher-tendon',
0.68,
buildHostileNpcLootItem(
'watcher-tendon',
'材料',
'守望筋丝',
'common',
['material'],
2,
'从漂浮妖眼身上扯下的细丝筋络。',
),
),
buildHostileNpcLootEntry(
'monster-05-blood-lens',
0.3,
buildHostileNpcLootItem(
'blood-lens',
'稀有品',
'血瞳透镜',
'rare',
['relic', 'mana'],
1,
'经由打磨的眼晶,可放大敌意与凝视类术式。',
),
),
],
'monster-06': [
buildHostileNpcLootEntry(
'monster-06-carapace-plate',
0.38,
buildHostileNpcLootItem(
'carapace-plate',
'护甲',
'甲壳板',
'rare',
['armor', 'material'],
1,
'可重铸成重型防具的致密甲板。',
),
),
buildHostileNpcLootEntry(
'monster-06-guard-core',
0.18,
buildHostileNpcLootItem(
'guard-core',
'稀有品',
'守御核心',
'rare',
['relic'],
1,
'蕴着本能防御意志的核心,坚实得近乎难摧。',
),
),
],
'monster-07': [
buildHostileNpcLootEntry(
'monster-07-spore-pouch',
0.72,
buildHostileNpcLootItem(
'spore-pouch',
'材料',
'孢囊',
'uncommon',
['material'],
2,
'装满不稳定菌孢的鼓胀囊袋。',
),
),
buildHostileNpcLootEntry(
'monster-07-burst-cap',
0.28,
buildHostileNpcLootItem(
'burst-cap',
'消耗品',
'爆菇帽',
'uncommon',
['healing'],
1,
'可加工成战地药剂的挥发菌帽。',
),
),
],
'monster-08': [
buildHostileNpcLootEntry(
'monster-08-vine-tendril',
0.76,
buildHostileNpcLootItem(
'vine-tendril',
'材料',
'藤须',
'common',
['material'],
2,
'从枯藤伏虫身上取得的韧性藤丝。',
),
),
buildHostileNpcLootEntry(
'monster-08-ambush-fang',
0.2,
buildHostileNpcLootItem(
'ambush-fang',
'武器',
'伏袭牙刃',
'rare',
['weapon', 'material'],
1,
'近战猎手格外珍视的天然尖牙刃。',
),
),
],
'monster-10': [
buildHostileNpcLootEntry(
'monster-10-spirit-slime',
0.72,
buildHostileNpcLootItem(
'spirit-slime',
'材料',
'灵腐黏液',
'common',
['material'],
2,
'在黑暗中微微发光的腐灵残液。',
),
),
buildHostileNpcLootEntry(
'monster-10-marsh-core',
0.2,
buildHostileNpcLootItem(
'marsh-core',
'稀有品',
'沼泽核心',
'rare',
['relic', 'mana'],
1,
'沼气与阴湿灵力凝成的核心结节。',
),
),
],
'monster-11': [
buildHostileNpcLootEntry(
'monster-11-night-fang',
0.58,
buildHostileNpcLootItem(
'night-fang',
'材料',
'夜牙',
'uncommon',
['material'],
1,
'适合炼毒与设陷的刃状兽牙。',
),
),
buildHostileNpcLootEntry(
'monster-11-shadow-pelt',
0.24,
buildHostileNpcLootItem(
'shadow-pelt',
'护甲',
'暗影兽皮',
'rare',
['armor', 'material'],
1,
'披戴后能稍稍融入夜色的深色皮膜。',
),
),
],
'monster-12': [
buildHostileNpcLootEntry(
'monster-12-ember-wing',
0.64,
buildHostileNpcLootItem(
'ember-wing',
'材料',
'烬翼',
'uncommon',
['material'],
2,
'仍散着余热灰烬的焦脆翼片。',
),
),
buildHostileNpcLootEntry(
'monster-12-ashfire-feather',
0.22,
buildHostileNpcLootItem(
'ashfire-feather',
'稀有品',
'灰焰翎',
'rare',
['relic', 'mana'],
1,
'羽尖里封着微弱却持久的灼灵余火。',
),
),
],
'monster-13': [
buildHostileNpcLootEntry(
'monster-13-venom-sac',
0.66,
buildHostileNpcLootItem(
'serpent-venom-sac',
'材料',
'蛇毒囊',
'uncommon',
['material'],
1,
'炼药师与刺客都很看重的高浓毒囊。',
),
),
buildHostileNpcLootEntry(
'monster-13-serpent-eye',
0.16,
buildHostileNpcLootItem(
'serpent-eye',
'稀有品',
'蛇瞳',
'rare',
['relic', 'mana'],
1,
'即使静止不动,也像仍在追索附近的活物。',
),
),
],
'monster-14': [
buildHostileNpcLootEntry(
'monster-14-tide-ink',
0.7,
buildHostileNpcLootItem(
'tide-ink',
'材料',
'潮墨',
'uncommon',
['material'],
2,
'适合封印、符阵与水属术式的浓黑灵墨。',
),
),
buildHostileNpcLootEntry(
'monster-14-lake-pearl',
0.18,
buildHostileNpcLootItem(
'lake-pearl',
'稀有品',
'湖珠',
'rare',
['relic', 'mana'],
1,
'裹着月华水气的光润珍珠。',
),
),
],
'monster-15': [
buildHostileNpcLootEntry(
'monster-15-thorn-nectar',
0.46,
buildHostileNpcLootItem(
'thorn-nectar',
'消耗品',
'棘露蜜浆',
'uncommon',
['healing', 'material'],
1,
'黏稠树露可炼成紧急疗伤的回生药浆。',
),
),
buildHostileNpcLootEntry(
'monster-15-devour-bloom',
0.12,
buildHostileNpcLootItem(
'devour-bloom',
'稀有品',
'噬灵花核',
'epic',
['relic'],
1,
'会掠食灵机的妖花花核,积着浓缩生机。',
),
),
],
'monster-16': [
buildHostileNpcLootEntry(
'monster-16-mist-wing',
0.72,
buildHostileNpcLootItem(
'mist-wing',
'材料',
'雾翼膜',
'common',
['material'],
2,
'浸着游雾与静电的轻薄翼膜。',
),
),
buildHostileNpcLootEntry(
'monster-16-chase-rune',
0.2,
buildHostileNpcLootItem(
'chase-rune',
'稀有品',
'追猎符印',
'rare',
['relic'],
1,
'能与速度和压迫感共鸣的追索印记。',
),
),
],
'monster-18': [
buildHostileNpcLootEntry(
'monster-18-ancient-hide',
0.62,
buildHostileNpcLootItem(
'ancient-hide',
'护甲',
'古苔兽皮',
'uncommon',
['armor', 'material'],
1,
'厚重皮层间压着旧尘、苔痕与碰撞裂纹。',
),
),
buildHostileNpcLootEntry(
'monster-18-ruin-heart',
0.14,
buildHostileNpcLootItem(
'ruin-heart',
'稀有品',
'遗墟之心',
'epic',
['relic'],
1,
'沉重的核心里跳着废墟不肯坍塌的执念。',
),
),
],
'monster-20': [
buildHostileNpcLootEntry(
'monster-20-spring-essence',
0.44,
buildHostileNpcLootItem(
'spring-essence',
'消耗品',
'灵泉精露',
'uncommon',
['mana'],
1,
'清凉的泉露能稳住心神与灵息运转。',
),
),
buildHostileNpcLootEntry(
'monster-20-tide-mother-core',
0.11,
buildHostileNpcLootItem(
'tide-mother-core',
'稀有品',
'潮母灵核',
'epic',
['relic', 'mana'],
1,
'只有在最深的明泉里才会凝出的水华灵核。',
),
),
],
};
const HOSTILE_NPC_OVERRIDES = hostileNpcOverridesJson as Record<string, HostileNpcPresetOverride>;
const BASE_HOSTILE_NPC_COMBAT_TAGS: Record<string, string[]> = {
'monster-03': ['镇邪', '控场', '机动'],
'monster-04': ['重甲', '守御', '反击'],
'monster-06': ['重甲', '守御', '压制'],
'monster-07': ['控场', '回复', '炼药'],
'monster-08': ['快袭', '追击', '机动'],
'monster-11': ['快袭', '突进', '压制'],
'monster-13': ['快袭', '追击', '风行'],
'monster-18': ['重击', '守御', '堡垒'],
'monster-02': ['法修', '符阵', '控场'],
'monster-05': ['控场', '法修', '镇邪'],
'monster-10': ['法力', '回复', '护体'],
'monster-12': ['机动', '远射', '风行'],
'monster-14': ['法力', '符阵', '回复'],
'monster-15': ['回复', '护体', '重甲'],
'monster-16': ['雷法', '机动', '过载'],
'monster-20': ['法力', '回复', '镇邪'],
};
function mergeHostileNpcPreset(
basePreset: Omit<HostileNpcPreset, 'combatTags' | 'lootTable' | 'attributeProfile' | 'behaviorVectors'> & { combatTags?: string[] },
): HostileNpcPreset {
const override = HOSTILE_NPC_OVERRIDES[basePreset.id];
if (!override) {
return hydrateHostileNpcPresetRoleData({
...basePreset,
combatTags: basePreset.combatTags ?? BASE_HOSTILE_NPC_COMBAT_TAGS[basePreset.id] ?? [],
lootTable: BASE_HOSTILE_NPC_LOOT_TABLES[basePreset.id] ?? [],
});
}
return hydrateHostileNpcPresetRoleData({
...basePreset,
...override,
animations: {
...basePreset.animations,
...(override.animations ?? {}),
},
baseStats: {
...basePreset.baseStats,
...(override.baseStats ?? {}),
},
combatTags: override.combatTags ?? basePreset.combatTags ?? BASE_HOSTILE_NPC_COMBAT_TAGS[basePreset.id] ?? [],
habitatTags: override.habitatTags ?? basePreset.habitatTags,
lootTable: override.lootTable ?? BASE_HOSTILE_NPC_LOOT_TABLES[basePreset.id] ?? [],
});
}
function buildHostileNpcBehaviorVectors(preset: {
id: string;
name: string;
combatTags: string[];
baseStats: Pick<SceneHostileNpc, 'attackRange' | 'speed' | 'maxHp'>;
}) {
const controlBias = preset.combatTags.some(tag => ['控场', '符阵', '法力'].includes(tag)) ? 0.28 : 0.12;
const mobilityBias = preset.baseStats.speed >= 7 ? 0.34 : 0.16;
const pressureBias = preset.baseStats.attackRange >= 1.5 ? 0.32 : 0.18;
const enduranceBias = preset.baseStats.maxHp >= 150 ? 0.3 : 0.16;
return [
{
id: `${preset.id}:predatory-strike`,
name: `${preset.name}的本能压迫`,
category: 'combat' as const,
baseScore: 0.52,
intentVector: buildDefaultAxisVector({
axis_a: enduranceBias,
axis_b: mobilityBias,
axis_c: controlBias,
axis_d: pressureBias,
axis_f: 0.2,
}),
resistVector: buildDefaultAxisVector({
axis_b: 0.24,
axis_c: 0.22,
axis_f: 0.18,
}),
},
];
}
function hydrateHostileNpcPresetRoleData(
preset: Omit<HostileNpcPreset, 'attributeProfile' | 'behaviorVectors'>,
): HostileNpcPreset {
const schema = getTemplateWorldAttributeSchema(
preset.worldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA,
);
return {
...preset,
attributeProfile: buildMonsterAttributeProfile(preset, schema),
behaviorVectors: buildHostileNpcBehaviorVectors(preset),
};
}
const ALL_HOSTILE_NPC_PRESETS = BASE_HOSTILE_NPC_PRESETS.map(basePreset => mergeHostileNpcPreset(basePreset));
export const HOSTILE_NPC_PRESETS_BY_WORLD: Record<WorldType, HostileNpcPreset[]> = {
[WorldType.WUXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.WUXIA),
[WorldType.XIANXIA]: ALL_HOSTILE_NPC_PRESETS.filter(monster => monster.worldType === WorldType.XIANXIA),
[WorldType.CUSTOM]: [...ALL_HOSTILE_NPC_PRESETS],
};
export const MONSTER_PRESETS_BY_WORLD = HOSTILE_NPC_PRESETS_BY_WORLD;
export function getHostileNpcPresetById(worldType: WorldType, monsterId: string) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
}
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
}
export const getMonsterPresetById = getHostileNpcPresetById;
export function getHostileNpcPresetsByWorld(worldType: WorldType) {
if (worldType === WorldType.CUSTOM) {
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
}
const resolvedWorldType =
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
}
export const getMonsterPresetsByWorld = getHostileNpcPresetsByWorld;
export function getHostileNpcPresetOverrideById(monsterId: string) {
return HOSTILE_NPC_OVERRIDES[monsterId] ?? null;
}
function inferRuntimePlanFromLootItem(
item: InventoryItem,
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
index: number,
): RuntimeItemPlan {
const normalizedBuildTags = normalizeBuildTags(item.tags, 3);
const targetBuildDirection = normalizedBuildTags.length > 0
? normalizedBuildTags
: normalizeBuildTags(context.playerBuildTags, 3);
return {
slot: item.rarity === 'epic' || item.rarity === 'legendary'
? 'primary'
: index === 0
? 'secondary'
: 'support',
itemKind: item.category === '武器' || item.category === '护甲'
? 'equipment'
: item.category === '消耗品' || item.tags.includes('consumable') || item.tags.includes('healing') || item.tags.includes('mana')
? 'consumable'
: item.category === '材料' || item.tags.includes('material')
? 'material'
: item.category === '专属品' || item.category === '专属物' || item.category === '专属物品'
? 'quest'
: 'relic',
permanence: item.category === '材料'
? 'resource'
: item.category === '消耗品'
? 'timed'
: 'permanent',
narrativeWeight: item.rarity === 'epic' || item.rarity === 'legendary' ? 'heavy' : 'medium',
targetBuildDirection: targetBuildDirection.length > 0 ? targetBuildDirection : ['均衡'],
relationAnchor: context.encounter?.monsterPresetId
? {
type: 'monster' as const,
monsterId: context.encounter.monsterPresetId,
monsterName: context.encounter.npcName,
}
: context.encounterNpcName
? {
type: 'npc' as const,
npcId: context.encounterNpcId ?? undefined,
npcName: context.encounterNpcName,
roleText: context.encounterContextText ?? undefined,
}
: {
type: 'scene' as const,
sceneId: context.sceneId ?? undefined,
sceneName: context.sceneName ?? '战场余烬',
},
} satisfies RuntimeItemPlan;
}
async function decoratePresetLootWithNarrative(
items: InventoryItem[],
context: ReturnType<typeof buildRuntimeItemGenerationContext>,
seedKeyPrefix: string,
) {
if (items.length <= 0) return items;
const plans = items.map((item, index) => inferRuntimePlanFromLootItem(item, context, index));
const fallbackIntents = plans.map(plan => buildRuntimeItemAiIntent(context, plan));
let intents = fallbackIntents;
try {
intents = await generateRuntimeItemAiIntents({
context,
plans,
});
} catch (error) {
console.warn('[HostileNpcPresets] preset loot narrative fallback', error);
}
return items.map((item, index) =>
applyRuntimeItemNarrativeToExistingItem({
item: {
...item,
runtimeMetadata: {
origin: 'procedural',
generationChannel: 'monster_drop',
relationAnchor: plans[index]!.relationAnchor,
seedKey: `${seedKeyPrefix}:preset:${index}`,
sourceReason: intents[index]!.reasonToAppear,
},
},
context,
plan: plans[index]!,
intent: intents[index]!,
preserveName: true,
}),
);
}
export async function rollHostileNpcLoot(
state: GameState,
defeatedHostileNpcs: Array<Pick<SceneHostileNpc, 'id' | 'name'>>,
) {
if (!state.worldType) return [];
if (state.worldType === WorldType.CUSTOM) {
return defeatedHostileNpcs.flatMap(monster => buildRuntimeCustomWorldInventoryItems(
`monster-loot:${monster.id}:${monster.name}`,
{
count: 2,
categories: ['材料', '消耗品', '稀有品', '专属品'],
},
));
}
const rewardBatches = await Promise.all(defeatedHostileNpcs.map(async monster => {
const preset = getHostileNpcPresetById(state.worldType!, monster.id);
const presetLoot = preset
? preset.lootTable
.filter(entry => Math.random() <= entry.dropRate)
.map(entry => ({
...entry.item,
}))
: [];
const context = buildRuntimeItemGenerationContext({
state,
generationChannel: 'monster_drop',
encounter: {
id: monster.id,
kind: 'npc',
npcName: monster.name,
npcDescription: `${monster.name}倒下后留下的战利痕迹。`,
npcAvatar: '',
context: state.currentScenePreset?.name ?? '战场余烬',
monsterPresetId: monster.id,
},
});
const seedKey = `monster-loot:${monster.id}:${monster.name}:${state.currentScenePreset?.id ?? 'scene'}`;
const [decoratedPresetLoot, directedReward] = await Promise.all([
decoratePresetLootWithNarrative(presetLoot, context, seedKey),
generateDirectedRuntimeReward(context, {
seedKey,
itemCount: 2,
fixedKinds: ['material', 'consumable'],
fixedPermanence: ['resource', 'timed'],
}),
]);
const runtimeItems = flattenDirectedRuntimeRewardItems(directedReward);
return [...decoratedPresetLoot, ...runtimeItems];
}));
return rewardBatches.flat();
}