497
src/services/customWorldPresentation.ts
Normal file
497
src/services/customWorldPresentation.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
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 {
|
||||
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> = {
|
||||
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: 'Power', agility: 'Dexterity', intelligence: 'Logic', spirit: 'Core' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'Energy',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max Energy',
|
||||
damageLabel: 'Firepower',
|
||||
guardLabel: 'Shield',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Recharge',
|
||||
manaCostLabel: 'Energy Cost',
|
||||
campSuffix: 'Mobile Outpost',
|
||||
itemPrefixes: ['Iron', 'Steel', 'Pulse', 'Core', 'Nova', 'Plasma'],
|
||||
itemInfixes: ['-Core','-Drive','-Link','-Grid','-Node','-Unit'],
|
||||
skillPrefixes: ['Over','Ultra','Mega','Core','Pulse','Nova'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
},
|
||||
},
|
||||
tide: {
|
||||
mode: 'tide',
|
||||
attributeLabels: { strength: 'Strength', agility: 'Agility', intelligence: 'Intelligence', spirit: 'Spirit' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'MP',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max MP',
|
||||
damageLabel: 'Damage',
|
||||
guardLabel: 'Guard',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Cooldown',
|
||||
manaCostLabel: 'Mana Cost',
|
||||
campSuffix: 'Camp',
|
||||
itemPrefixes: ['Wave', 'Tide', 'Ocean', 'Sea', 'Storm', 'Surf'],
|
||||
itemInfixes: ['-Wave','-Tide','-Ocean','-Sea','-Storm','-Surf'],
|
||||
skillPrefixes: ['Wave','Tide','Ocean','Sea','Storm','Surf'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
},
|
||||
},
|
||||
rift: {
|
||||
mode: 'rift',
|
||||
attributeLabels: { strength: 'çå²', agility: 'è£æ¥', intelligence: 'çè¯', spirit: 'çå' },
|
||||
hpLabel: 'çå½',
|
||||
mpLabel: 'è£è½',
|
||||
maxHpLabel: 'çå½ä¸é',
|
||||
maxMpLabel: 'è£è½ä¸é',
|
||||
damageLabel: 'çå¿',
|
||||
guardLabel: '稳ç',
|
||||
rangeLabel: 'çè·',
|
||||
cooldownLabel: 'å¤ç',
|
||||
manaCostLabel: 'Rift Cost',
|
||||
campSuffix: 'è£çé©»è¥',
|
||||
itemPrefixes: ['è£ç', 'æå±', '边潮', 'ç°å', 'çæ¡¥', 'åå¨'],
|
||||
itemInfixes: ['edge', 'void', 'span', 'seal', 'rift', 'core'],
|
||||
skillPrefixes: ['rift', 'void', 'split', 'break', 'phase', 'warp'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['break', 'crash', 'shatter', 'burst'],
|
||||
steady: ['guard', 'hold', 'veil', 'ward'],
|
||||
mobility: ['step', 'shift', 'blink', 'drift'],
|
||||
finisher: ['ending', 'drop', 'break', 'flare'],
|
||||
projectile: ['spike', 'bolt', 'shard', 'wave'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_NOUNS: Record<string, string[]> = Object.fromEntries([
|
||||
[CATEGORY_WEAPON, ['blade', 'axe', 'bow', 'staff', 'spear', 'shield']],
|
||||
[CATEGORY_ARMOR, ['armor', 'robe', 'cloak', 'guard', 'mantle', 'bracer']],
|
||||
[CATEGORY_RELIC, ['ring', 'seal', 'badge', 'gem', 'charm', 'orb']],
|
||||
[CATEGORY_CONSUMABLE, ['potion', 'dust', 'draught', 'brew', 'oil', 'scroll']],
|
||||
[CATEGORY_MATERIAL, ['ore', 'crystal', 'bone', 'herb', 'core', 'silk']],
|
||||
[CATEGORY_RARE, ['sigil', 'relic', 'page', 'chart', 'key', 'idol']],
|
||||
[CATEGORY_EXCLUSIVE, ['core', 'seal', 'master-key', 'origin-box', 'true-mark', 'world-core']],
|
||||
]);
|
||||
const DEFAULT_CATEGORY_NOUNS = ['relic', 'sigil', 'token', 'seal', 'core', 'mark'];
|
||||
|
||||
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: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: 'æå¤?HP',
|
||||
maxMp: 'æå¤?MP',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: 'Mana',
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
return `${presentation.itemPrefixes[0]}${presentation.campSuffix}`;
|
||||
}
|
||||
|
||||
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['ç¨æå'];
|
||||
}
|
||||
|
||||
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 = [
|
||||
`Suitable for the current goal "${profile.playerGoal}".`,
|
||||
`Its tone closely matches the world tone "${profile.tone}".`,
|
||||
'Likely to appear in one of this world\'s major conflicts.',
|
||||
`It clearly ties into the expanding conflict inside this world.`,
|
||||
];
|
||||
const rarityText = {
|
||||
common: '常è§',
|
||||
uncommon: 'è¿é¶',
|
||||
rare: '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 === 'æ¦å¨') {
|
||||
return {
|
||||
equipmentSlotId: 'weapon',
|
||||
statProfile: {
|
||||
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
|
||||
},
|
||||
value: 28 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¤ç²') {
|
||||
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 === '???' || category === '????') {
|
||||
return {
|
||||
equipmentSlotId: 'relic',
|
||||
statProfile: {
|
||||
maxManaBonus: 8 * rarityTier + (seed % 7),
|
||||
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
|
||||
},
|
||||
value: 32 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¶èå') {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user