540 lines
17 KiB
TypeScript
540 lines
17 KiB
TypeScript
import { getRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
|
import { ITEM_CATEGORY_OPTIONS } from '../data/itemCatalog';
|
|
import {
|
|
Character,
|
|
CharacterSkillDefinition,
|
|
CustomWorldPlayableNpc,
|
|
CustomWorldProfile,
|
|
EquipmentSlotId,
|
|
ItemRarity,
|
|
ItemStatProfile,
|
|
ItemUseProfile,
|
|
WorldType,
|
|
} from '../types';
|
|
import { resolveCustomWorldCampScene } from './customWorldCamp';
|
|
import { resolveCustomWorldRuleProfile } from './customWorldOwnedSettingLayers';
|
|
import {
|
|
type CustomWorldThemeMode,
|
|
detectCustomWorldThemeMode,
|
|
} from './customWorldTheme';
|
|
|
|
type ThemeMode = CustomWorldThemeMode;
|
|
type AttributeLabelMap = Record<keyof Character['attributes'], string>;
|
|
|
|
const [
|
|
CATEGORY_WEAPON,
|
|
CATEGORY_ARMOR,
|
|
CATEGORY_RELIC,
|
|
CATEGORY_CONSUMABLE,
|
|
CATEGORY_MATERIAL,
|
|
CATEGORY_RARE,
|
|
CATEGORY_EXCLUSIVE,
|
|
] = ITEM_CATEGORY_OPTIONS;
|
|
|
|
type WorldPresentation = {
|
|
mode: ThemeMode;
|
|
attributeLabels: AttributeLabelMap;
|
|
hpLabel: string;
|
|
mpLabel: string;
|
|
maxHpLabel: string;
|
|
maxMpLabel: string;
|
|
damageLabel: string;
|
|
guardLabel: string;
|
|
rangeLabel: string;
|
|
cooldownLabel: string;
|
|
manaCostLabel: string;
|
|
campSuffix: string;
|
|
itemPrefixes: string[];
|
|
itemInfixes: string[];
|
|
skillPrefixes: string[];
|
|
skillSuffixByStyle: Record<CharacterSkillDefinition['style'], string[]>;
|
|
};
|
|
|
|
const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
|
|
mythic: {
|
|
mode: 'mythic',
|
|
attributeLabels: { strength: '体魄', agility: '身法', intelligence: '识见', spirit: '心魂' },
|
|
hpLabel: '生命',
|
|
mpLabel: '心流',
|
|
maxHpLabel: '生命上限',
|
|
maxMpLabel: '心流上限',
|
|
damageLabel: '势能',
|
|
guardLabel: '防护',
|
|
rangeLabel: '距离',
|
|
cooldownLabel: '回整',
|
|
manaCostLabel: '心流消耗',
|
|
campSuffix: '归舍',
|
|
itemPrefixes: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
|
|
itemInfixes: ['印', '纹', '辉', '迹', '息', '铭'],
|
|
skillPrefixes: ['映', '折', '回', '逐', '临', '流'],
|
|
skillSuffixByStyle: {
|
|
burst: ['震', '断', '破', '坠'],
|
|
steady: ['守', '定', '护', '镇'],
|
|
mobility: ['跃', '移', '转', '行'],
|
|
finisher: ['终', '决', '落', '尽'],
|
|
projectile: ['矢', '刃', '波', '纹'],
|
|
},
|
|
},
|
|
martial: {
|
|
mode: 'martial',
|
|
attributeLabels: { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' },
|
|
hpLabel: '气血',
|
|
mpLabel: '内力',
|
|
maxHpLabel: '气血上限',
|
|
maxMpLabel: '内力上限',
|
|
damageLabel: '招式',
|
|
guardLabel: '防御',
|
|
rangeLabel: '招距',
|
|
cooldownLabel: '调息',
|
|
manaCostLabel: '内力消耗',
|
|
campSuffix: '归舍',
|
|
itemPrefixes: ['风雨', '青锋', '断桥', '冷铁', '旧案', '残影'],
|
|
itemInfixes: ['刃','锋','魂','诀','式','影'],
|
|
skillPrefixes: ['破','斩','击','御','飞','隐'],
|
|
skillSuffixByStyle: {
|
|
burst: ['杀','灭','破','击'],
|
|
steady: ['守','御','护','镇'],
|
|
mobility: ['闪','移','跃','遁'],
|
|
finisher: ['决','断','灭','终'],
|
|
projectile: ['飞','射','投','掷'],
|
|
},
|
|
},
|
|
arcane: {
|
|
mode: 'arcane',
|
|
attributeLabels: { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' },
|
|
hpLabel: '元命',
|
|
mpLabel: '灵韵',
|
|
maxHpLabel: '元命上限',
|
|
maxMpLabel: '灵韵上限',
|
|
damageLabel: '术法',
|
|
guardLabel: '护盾',
|
|
rangeLabel: '术距',
|
|
cooldownLabel: '回息',
|
|
manaCostLabel: '灵韵消耗',
|
|
campSuffix: '栖居',
|
|
itemPrefixes: ['灵韵', '道纹', '云篆', '星芒', '界辉', '道痕'],
|
|
itemInfixes: ['灵','道','法','术','诀','印'],
|
|
skillPrefixes: ['灵','道','法','界','星','印'],
|
|
skillSuffixByStyle: {
|
|
burst: ['破','灭','毁','绝'],
|
|
steady: ['守','御','护','镇'],
|
|
mobility: ['闪','移','跃','遁'],
|
|
finisher: ['决','断','灭','终'],
|
|
projectile: ['飞','射','投','掷'],
|
|
},
|
|
},
|
|
machina: {
|
|
mode: 'machina',
|
|
attributeLabels: { strength: '动力', agility: '精度', intelligence: '逻辑', spirit: '核心' },
|
|
hpLabel: '耐久',
|
|
mpLabel: '能量',
|
|
maxHpLabel: '耐久上限',
|
|
maxMpLabel: '能量上限',
|
|
damageLabel: '火力',
|
|
guardLabel: '护盾',
|
|
rangeLabel: '射程',
|
|
cooldownLabel: '充能',
|
|
manaCostLabel: '能量消耗',
|
|
campSuffix: '整备居',
|
|
itemPrefixes: ['铁脊', '钢律', '脉冲', '核列', '新星', '等离'],
|
|
itemInfixes: ['芯', '驱', '链', '阵', '节', '机'],
|
|
skillPrefixes: ['超载', '脉冲', '聚核', '磁轨', '新星', '裂火'],
|
|
skillSuffixByStyle: {
|
|
burst: ['爆裂', '齐射', '连发', '倾泻'],
|
|
steady: ['稳压', '固守', '护持', '锚定'],
|
|
mobility: ['疾冲', '推进', '跃迁', '闪移'],
|
|
finisher: ['终断', '歼灭', '过载', '坠落'],
|
|
projectile: ['弹', '束', '矢', '炮'],
|
|
},
|
|
},
|
|
tide: {
|
|
mode: 'tide',
|
|
attributeLabels: { strength: '潮力', agility: '浪步', intelligence: '潮识', spirit: '潮魄' },
|
|
hpLabel: '潮命',
|
|
mpLabel: '潮息',
|
|
maxHpLabel: '潮命上限',
|
|
maxMpLabel: '潮息上限',
|
|
damageLabel: '潮势',
|
|
guardLabel: '潮护',
|
|
rangeLabel: '潮距',
|
|
cooldownLabel: '回潮',
|
|
manaCostLabel: '潮息消耗',
|
|
campSuffix: '潮居',
|
|
itemPrefixes: ['潮纹', '海晕', '霜浪', '天澜', '潮歌', '沧流'],
|
|
itemInfixes: ['潮', '浪', '汐', '海', '涛', '澜'],
|
|
skillPrefixes: ['潮', '浪', '汐', '海', '澜', '涌'],
|
|
skillSuffixByStyle: {
|
|
burst: ['裂潮', '怒涌', '连浪', '奔潮'],
|
|
steady: ['守潮', '潮护', '定澜', '镇流'],
|
|
mobility: ['踏浪', '游潮', '跃汐', '逐流'],
|
|
finisher: ['断潮', '覆海', '终汐', '沉落'],
|
|
projectile: ['潮矢', '水矛', '浪刃', '飞涌'],
|
|
},
|
|
},
|
|
rift: {
|
|
mode: 'rift',
|
|
attributeLabels: { strength: '界劲', agility: '裂步', intelligence: '界识', spirit: '界压' },
|
|
hpLabel: '界命',
|
|
mpLabel: '裂能',
|
|
maxHpLabel: '界命上限',
|
|
maxMpLabel: '裂能上限',
|
|
damageLabel: '界势',
|
|
guardLabel: '稳界',
|
|
rangeLabel: '界距',
|
|
cooldownLabel: '复界',
|
|
manaCostLabel: '裂能消耗',
|
|
campSuffix: '界隙居所',
|
|
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
|
|
itemInfixes: ['锋', '隙', '锚', '印', '界', '核'],
|
|
skillPrefixes: ['裂', '断', '界', '相', '折', '迁'],
|
|
skillSuffixByStyle: {
|
|
burst: ['崩断', '碎坠', '裂爆', '界崩'],
|
|
steady: ['守界', '固相', '帷障', '界卫'],
|
|
mobility: ['裂步', '转相', '闪迁', '漂移'],
|
|
finisher: ['终坠', '断灭', '裂终', '界燃'],
|
|
projectile: ['界刺', '裂矢', '碎片', '裂波'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const CATEGORY_NOUNS: Record<string, string[]> = Object.fromEntries([
|
|
[CATEGORY_WEAPON, ['剑', '刃', '弓', '杖', '枪', '盾']],
|
|
[CATEGORY_ARMOR, ['甲', '袍', '披风', '护具', '肩甲', '护腕']],
|
|
[CATEGORY_RELIC, ['戒', '印', '徽', '玉', '符', '珠']],
|
|
[CATEGORY_CONSUMABLE, ['药', '散', '剂', '露', '油', '卷']],
|
|
[CATEGORY_MATERIAL, ['矿', '晶', '骨', '草', '核', '丝']],
|
|
[CATEGORY_RARE, ['符', '遗物', '残页', '图', '钥', '像']],
|
|
[CATEGORY_EXCLUSIVE, ['核心', '封印', '主钥', '源匣', '真印', '界核']],
|
|
]);
|
|
const DEFAULT_CATEGORY_NOUNS = ['符', '印', '信物', '匣', '核', '铭片'];
|
|
|
|
const ROLE_SKILL_ROOTS: Record<string, string[]> = {
|
|
'sword-princess': ['王剑', '锋式', '裁锋', '裂锋'],
|
|
'archer-hero': ['弦诀', '远袭', '追风', '贯矢'],
|
|
'girl-hero': ['双刃', '影袭', '疾斩', '掠影'],
|
|
'punch-hero': ['拳势', '震击', '裂拳', '崩步'],
|
|
'fighter-4': ['重锋', '盾阵', '镇线', '压城'],
|
|
};
|
|
|
|
const SKILL_ROOT_STOP_WORDS = new Set([
|
|
'世界',
|
|
'设定',
|
|
'基调',
|
|
'目标',
|
|
'角色',
|
|
'战斗',
|
|
'风格',
|
|
'背景',
|
|
'性格',
|
|
'故事',
|
|
'custom-world',
|
|
'playable-role',
|
|
]);
|
|
|
|
|
|
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 >>> 0;
|
|
}
|
|
|
|
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
|
|
const item = items[index % items.length];
|
|
if (item === undefined) {
|
|
throw new Error(`Missing ${label}`);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
function getWorldPresentation(profile: CustomWorldProfile) {
|
|
return WORLD_PRESENTATIONS[detectCustomWorldThemeMode(profile)];
|
|
}
|
|
|
|
function dedupeStrings(values: string[], max = 12) {
|
|
return [...new Set(values.map(value => value.trim()).filter(Boolean))].slice(0, max);
|
|
}
|
|
|
|
function collectSkillRootFragments(value: string, max = 8) {
|
|
if (!value.trim()) return [] as string[];
|
|
|
|
const directSegments = value
|
|
.split(/[ \t\r\n,!?:"()|/\\[\]-]+/u)
|
|
.map(segment => segment.trim())
|
|
.filter(segment => segment.length >= 2 && segment.length <= 6)
|
|
.filter(segment => !SKILL_ROOT_STOP_WORDS.has(segment));
|
|
|
|
const chineseSource = value.replace(/[^\u4e00-\u9fa5]/gu, '');
|
|
const ngrams: string[] = [];
|
|
|
|
for (let size = 2; size <= 4; size += 1) {
|
|
for (let index = 0; index <= chineseSource.length - size; index += 1) {
|
|
const fragment = chineseSource.slice(index, index + size);
|
|
if (SKILL_ROOT_STOP_WORDS.has(fragment)) {
|
|
continue;
|
|
}
|
|
ngrams.push(fragment);
|
|
if (ngrams.length >= max) {
|
|
return dedupeStrings([...directSegments, ...ngrams], max);
|
|
}
|
|
}
|
|
}
|
|
|
|
return dedupeStrings([...directSegments, ...ngrams], max);
|
|
}
|
|
|
|
function buildSkillThemeSeedSource(
|
|
profile: CustomWorldProfile,
|
|
character: Character,
|
|
skill: CharacterSkillDefinition,
|
|
index: number,
|
|
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
|
) {
|
|
return [
|
|
profile.name,
|
|
profile.settingText,
|
|
profile.summary,
|
|
profile.tone,
|
|
profile.playerGoal,
|
|
role?.title ?? '',
|
|
role?.combatStyle ?? '',
|
|
role?.tags.join('|') ?? '',
|
|
character.id,
|
|
skill.id,
|
|
skill.style,
|
|
skill.delivery ?? '',
|
|
index,
|
|
].join('::');
|
|
}
|
|
|
|
function buildSkillRootOptions(
|
|
profile: CustomWorldProfile,
|
|
character: Character,
|
|
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
|
) {
|
|
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['界式', '行诀', '裂锋', '潮印'];
|
|
const derivedRoots = dedupeStrings([
|
|
...collectSkillRootFragments(role?.title ?? '', 4),
|
|
...collectSkillRootFragments(role?.combatStyle ?? '', 6),
|
|
...(role?.tags ?? []).flatMap(tag => collectSkillRootFragments(tag, 2)),
|
|
...collectSkillRootFragments(profile.name, 4),
|
|
...collectSkillRootFragments(profile.playerGoal, 6),
|
|
], 8);
|
|
|
|
return derivedRoots.length > 0 ? dedupeStrings([...derivedRoots, ...fallbackRoots], 8) : fallbackRoots;
|
|
}
|
|
|
|
export function getCustomWorldProfileForDisplay(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null) {
|
|
if (explicitProfile) return explicitProfile;
|
|
if (worldType === WorldType.CUSTOM) {
|
|
return getRuntimeCustomWorldProfile();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getAttributeLabelsForWorld(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null): AttributeLabelMap {
|
|
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
|
if (!profile) {
|
|
if (worldType === WorldType.XIANXIA) {
|
|
return { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' };
|
|
}
|
|
return { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' };
|
|
}
|
|
|
|
return getWorldPresentation(profile).attributeLabels;
|
|
}
|
|
|
|
export function getResourceLabelsForWorld(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null) {
|
|
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
|
if (!profile) {
|
|
if (worldType === WorldType.XIANXIA) {
|
|
return {
|
|
hp: '命元',
|
|
mp: '灵韵',
|
|
maxHp: '命元上限',
|
|
maxMp: '灵韵上限',
|
|
damage: '术势',
|
|
guard: '护元',
|
|
range: '术距',
|
|
cooldown: '回息',
|
|
manaCost: '灵韵消耗',
|
|
};
|
|
}
|
|
return {
|
|
hp: '生命',
|
|
mp: '灵力',
|
|
maxHp: '生命上限',
|
|
maxMp: '灵力上限',
|
|
damage: '伤害',
|
|
guard: '防护',
|
|
range: '距离',
|
|
cooldown: '冷却',
|
|
manaCost: '消耗',
|
|
};
|
|
}
|
|
|
|
const ruleProfile = resolveCustomWorldRuleProfile(profile);
|
|
if (ruleProfile) {
|
|
return {
|
|
hp: ruleProfile.resourceLabels.hp,
|
|
mp: ruleProfile.resourceLabels.mp,
|
|
maxHp: ruleProfile.resourceLabels.maxHp,
|
|
maxMp: ruleProfile.resourceLabels.maxMp,
|
|
damage: ruleProfile.resourceLabels.damage,
|
|
guard: ruleProfile.resourceLabels.guard,
|
|
range: ruleProfile.resourceLabels.range,
|
|
cooldown: ruleProfile.resourceLabels.cooldown,
|
|
manaCost: ruleProfile.resourceLabels.manaCost,
|
|
};
|
|
}
|
|
|
|
const presentation = getWorldPresentation(profile);
|
|
return {
|
|
hp: presentation.hpLabel,
|
|
mp: presentation.mpLabel,
|
|
maxHp: presentation.maxHpLabel,
|
|
maxMp: presentation.maxMpLabel,
|
|
damage: presentation.damageLabel,
|
|
guard: presentation.guardLabel,
|
|
range: presentation.rangeLabel,
|
|
cooldown: presentation.cooldownLabel,
|
|
manaCost: presentation.manaCostLabel,
|
|
};
|
|
}
|
|
|
|
|
|
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
|
|
return resolveCustomWorldCampScene(profile).name;
|
|
}
|
|
|
|
export function buildThemedSkillName(
|
|
profile: CustomWorldProfile,
|
|
character: Character,
|
|
skill: CharacterSkillDefinition,
|
|
index: number,
|
|
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
|
) {
|
|
const presentation = getWorldPresentation(profile);
|
|
const seed = hashText(buildSkillThemeSeedSource(profile, character, skill, index, role));
|
|
const rootOptions = buildSkillRootOptions(profile, character, role);
|
|
const prefix = presentation.skillPrefixes[seed % presentation.skillPrefixes.length];
|
|
const root = rootOptions[(seed >>> 3) % rootOptions.length];
|
|
const suffix = presentation.skillSuffixByStyle[skill.style][(seed >>> 5) % presentation.skillSuffixByStyle[skill.style].length];
|
|
return `${prefix}${root}${suffix}`;
|
|
}
|
|
|
|
function getCategoryNouns(category: string) {
|
|
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS[CATEGORY_RARE];
|
|
}
|
|
|
|
function getResolvedCategoryNouns(category: string): string[] {
|
|
return getCategoryNouns(category) ?? DEFAULT_CATEGORY_NOUNS;
|
|
}
|
|
|
|
export function buildThemedItemName(
|
|
profile: CustomWorldProfile,
|
|
category: string,
|
|
sourceKey: string,
|
|
index: number,
|
|
) {
|
|
const presentation = getWorldPresentation(profile);
|
|
const seed = hashText(`${profile.id}:${sourceKey}:${category}:${index}`);
|
|
const prefix = presentation.itemPrefixes[seed % presentation.itemPrefixes.length];
|
|
const infix = presentation.itemInfixes[(seed >>> 3) % presentation.itemInfixes.length];
|
|
const nouns = getResolvedCategoryNouns(category);
|
|
const noun = pickCyclic(nouns, seed >>> 5, `item noun for category "${category}"`);
|
|
return `${prefix}${infix}${noun}${index + 1}`;
|
|
}
|
|
|
|
export function buildThemedItemDescription(
|
|
profile: CustomWorldProfile,
|
|
category: string,
|
|
rarity: ItemRarity,
|
|
seedKey: string,
|
|
) {
|
|
const seed = hashText(`${profile.id}:${category}:${rarity}:${seedKey}`);
|
|
const hooks = [
|
|
`适合围绕“${profile.playerGoal}”继续推进。`,
|
|
`它的气质和“${profile.tone}”这条世界基调很贴近。`,
|
|
'很可能会出现在这个世界的关键冲突里。',
|
|
'能明显牵出这个世界正在扩大的主要矛盾。',
|
|
];
|
|
const rarityText = {
|
|
common: '常见',
|
|
uncommon: '进阶',
|
|
rare: '稀有',
|
|
epic: '核心',
|
|
legendary: '关键',
|
|
}[rarity];
|
|
|
|
return `${rarityText}${category}。${hooks[seed % hooks.length]}`;
|
|
}
|
|
|
|
export function inferCustomItemMechanics(
|
|
category: string,
|
|
rarity: ItemRarity,
|
|
tags: string[],
|
|
seedKey: string,
|
|
): {
|
|
equipmentSlotId?: EquipmentSlotId | null;
|
|
statProfile?: ItemStatProfile | null;
|
|
useProfile?: ItemUseProfile | null;
|
|
value: number;
|
|
} {
|
|
const seed = hashText(`${category}:${rarity}:${seedKey}:${tags.join('|')}`);
|
|
const rarityTier = {
|
|
common: 1,
|
|
uncommon: 2,
|
|
rare: 3,
|
|
epic: 4,
|
|
legendary: 5,
|
|
}[rarity];
|
|
|
|
if (category === CATEGORY_WEAPON) {
|
|
return {
|
|
equipmentSlotId: 'weapon',
|
|
statProfile: {
|
|
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
|
|
},
|
|
value: 28 * rarityTier,
|
|
};
|
|
}
|
|
|
|
if (category === CATEGORY_ARMOR) {
|
|
return {
|
|
equipmentSlotId: 'armor',
|
|
statProfile: {
|
|
maxHpBonus: 10 * rarityTier + (seed % 8),
|
|
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
|
|
},
|
|
value: 26 * rarityTier,
|
|
};
|
|
}
|
|
|
|
if (category === CATEGORY_RELIC || category === CATEGORY_RARE || category === CATEGORY_EXCLUSIVE) {
|
|
return {
|
|
equipmentSlotId: 'relic',
|
|
statProfile: {
|
|
maxManaBonus: 8 * rarityTier + (seed % 7),
|
|
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
|
|
},
|
|
value: 32 * rarityTier,
|
|
};
|
|
}
|
|
|
|
if (category === CATEGORY_CONSUMABLE) {
|
|
const heals = tags.includes('healing') || seed % 2 === 0;
|
|
return {
|
|
useProfile: heals
|
|
? { hpRestore: 16 * rarityTier }
|
|
: { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 },
|
|
value: 18 * rarityTier,
|
|
};
|
|
}
|
|
|
|
return {
|
|
value: 10 * rarityTier,
|
|
};
|
|
}
|