1942 lines
75 KiB
TypeScript
1942 lines
75 KiB
TypeScript
import { buildThemedSkillName } from '../services/customWorldPresentation';
|
||
import { detectCustomWorldThemeMode } from '../services/customWorldTheme';
|
||
import {
|
||
AnimationState,
|
||
Character,
|
||
CharacterAdventureOpening,
|
||
CharacterAnimationConfig,
|
||
CharacterConversationStyle,
|
||
CharacterSkillDefinition,
|
||
CharacterSkillEffectDefinition,
|
||
CompanionState,
|
||
ConversationGuardStyle,
|
||
ConversationTruthStyle,
|
||
ConversationWarmStyle,
|
||
CustomWorldPlayableNpc,
|
||
CustomWorldProfile,
|
||
Encounter,
|
||
InventoryItem,
|
||
SceneNpc,
|
||
WorldTemplateType,
|
||
WorldType,
|
||
} from '../types';
|
||
import {
|
||
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
|
||
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||
} from './affinityLevels';
|
||
import { resolveRoleCombatStats } from './attributeCombat';
|
||
import {
|
||
buildCharacterAttributeProfile,
|
||
buildCustomWorldPlayableNpcAttributeProfile,
|
||
buildSkillAttributeProfile,
|
||
} from './attributeProfileGenerator';
|
||
import { resolveCharacterAttributeProfile } from './attributeResolver';
|
||
import { normalizeBuildTags } from './buildTags';
|
||
import characterOverridesJson from './characterOverrides.json';
|
||
import { deriveCustomWorldCharacterCombatTags } from './customWorldBuildTags';
|
||
import {
|
||
buildCustomWorldStarterEquipmentItems,
|
||
buildCustomWorldStarterInventoryItems,
|
||
} from './customWorldCharacterLoadout';
|
||
import { getRuntimeCustomWorldProfile, isCustomWorldType } from './customWorldRuntime';
|
||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
||
|
||
function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition {
|
||
return skill;
|
||
}
|
||
|
||
const [
|
||
BACKSTORY_UNLOCK_AFFINITY_EASED = 15,
|
||
BACKSTORY_UNLOCK_AFFINITY_FRIENDLY = 30,
|
||
BACKSTORY_UNLOCK_AFFINITY_TRUSTED = 60,
|
||
BACKSTORY_UNLOCK_AFFINITY_CLOSE = 90,
|
||
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
|
||
|
||
function effect(definition: CharacterSkillEffectDefinition) {
|
||
return definition;
|
||
}
|
||
|
||
function opening(definition: CharacterAdventureOpening) {
|
||
return {
|
||
...definition,
|
||
surfaceHook: definition.surfaceHook ?? '这地方和我盯着的事脱不开干系。',
|
||
immediateConcern: definition.immediateConcern ?? '前面的动静不对,贸然往里闯只会先吃亏。',
|
||
guardedMotive: definition.guardedMotive ?? '我暂时只能告诉你,我不是路过,也不会现在就离开。',
|
||
};
|
||
}
|
||
|
||
function conversationStyle(definition: CharacterConversationStyle) {
|
||
return definition;
|
||
}
|
||
|
||
function inferGuardStyle(text: string): ConversationGuardStyle {
|
||
if (/直率|强硬|果决|大胆|主动/u.test(text)) return 'blunt';
|
||
if (/冷静|敏锐|耐心|谨慎|警觉/u.test(text)) return 'wary';
|
||
if (/圆滑|轻快|机敏|狡黠|试探/u.test(text)) return 'evasive';
|
||
return 'measured';
|
||
}
|
||
|
||
function inferWarmStyle(text: string): ConversationWarmStyle {
|
||
if (/温和|照应|体贴|柔和/u.test(text)) return 'gentle';
|
||
if (/轻快|松弛|玩笑|洒脱/u.test(text)) return 'teasing';
|
||
if (/冷静|冷淡|寡言/u.test(text)) return 'dry';
|
||
return 'steady';
|
||
}
|
||
|
||
function inferTruthStyle(text: string): ConversationTruthStyle {
|
||
if (/直率|强硬|果决|直接/u.test(text)) return 'direct';
|
||
if (/敏锐|谨慎|耐心|沉稳|纪律/u.test(text)) return 'fragmented';
|
||
return 'deflecting';
|
||
}
|
||
|
||
function inferConversationStyleFromText(text: string) {
|
||
return conversationStyle({
|
||
guardStyle: inferGuardStyle(text),
|
||
warmStyle: inferWarmStyle(text),
|
||
truthStyle: inferTruthStyle(text),
|
||
});
|
||
}
|
||
|
||
function animationSequence(animation: AnimationState, fps?: number) {
|
||
return {
|
||
source: 'animation' as const,
|
||
animation,
|
||
fps,
|
||
};
|
||
}
|
||
|
||
function assetSequence(
|
||
folder: string,
|
||
options: {
|
||
prefix?: string;
|
||
frames?: number;
|
||
startFrame?: number;
|
||
extension?: string;
|
||
file?: string;
|
||
fps?: number;
|
||
} = {},
|
||
) {
|
||
return {
|
||
source: 'asset' as const,
|
||
folder,
|
||
...options,
|
||
};
|
||
}
|
||
|
||
export type CharacterEquipmentItem = {
|
||
slot: string;
|
||
item: string;
|
||
rarity: string;
|
||
};
|
||
|
||
export type CharacterInventoryItem = {
|
||
category: string;
|
||
name: string;
|
||
quantity: number;
|
||
};
|
||
|
||
export type CharacterSceneBindingOverride = {
|
||
homeSceneId?: string;
|
||
npcSceneIds?: string[];
|
||
};
|
||
|
||
type KnownCharacterGender = Exclude<NonNullable<Character['gender']>, 'unknown'>;
|
||
|
||
export type CharacterPresetOverride = Partial<Omit<Character, 'attributes' | 'skills' | 'animationMap' | 'gender'>> & {
|
||
gender?: KnownCharacterGender;
|
||
attributes?: Partial<Character['attributes']>;
|
||
skills?: CharacterSkillDefinition[];
|
||
animationMap?: Partial<Record<AnimationState, CharacterAnimationConfig>>;
|
||
equipment?: CharacterEquipmentItem[];
|
||
inventory?: CharacterInventoryItem[];
|
||
inventoryByWorld?: Partial<Record<WorldType, CharacterInventoryItem[]>>;
|
||
sceneBindings?: Partial<Record<WorldType, CharacterSceneBindingOverride>>;
|
||
};
|
||
|
||
const CHARACTER_OVERRIDES = characterOverridesJson as Record<string, CharacterPresetOverride>;
|
||
export const UNIVERSAL_MAX_MANA = 999;
|
||
|
||
function getLegacyCharacterBaseMaxHp(character: Character) {
|
||
return Math.max(120, 90 + character.attributes.strength * 10 + character.attributes.spirit * 4);
|
||
}
|
||
|
||
function getCharacterBaseResourceProfile(character: Character) {
|
||
return character.resourceProfile ?? buildCharacterResourceProfile(character);
|
||
}
|
||
|
||
export function getCharacterMaxMana(character: Character) {
|
||
return character.resourceProfile?.maxMana ?? UNIVERSAL_MAX_MANA;
|
||
}
|
||
|
||
export function getCharacterCombatStats(
|
||
character: Character,
|
||
worldType: WorldType | null = null,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
) {
|
||
return resolveRoleCombatStats(
|
||
resolveCharacterAttributeProfile(character, worldType, customWorldProfile) ?? character.attributeProfile,
|
||
);
|
||
}
|
||
|
||
export function getCharacterMaxHp(
|
||
character: Character,
|
||
worldType: WorldType | null = null,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
) {
|
||
return getCharacterBaseResourceProfile(character).maxHp
|
||
+ getCharacterCombatStats(character, worldType, customWorldProfile).maxHpBonus;
|
||
}
|
||
|
||
export function createCharacterSkillCooldowns(character: Character) {
|
||
return Object.fromEntries(character.skills.map(skill => [skill.id, 0]));
|
||
}
|
||
|
||
function buildCharacterResourceProfile(character: Character) {
|
||
if (character.resourceProfile) {
|
||
return character.resourceProfile;
|
||
}
|
||
|
||
const source = `${character.title} ${character.description} ${character.personality} ${(character.combatTags ?? []).join(' ')}`;
|
||
const baseHp = /守|甲|拳|先锋|重击|护体/u.test(source)
|
||
? 210
|
||
: /远射|机动|快袭|游击/u.test(source)
|
||
? 168
|
||
: /法|符|阵|灵|术/u.test(source)
|
||
? 176
|
||
: 188;
|
||
|
||
return {
|
||
maxHp: Math.max(
|
||
getLegacyCharacterBaseMaxHp(character),
|
||
baseHp + Math.min(18, character.skills.length * 4),
|
||
),
|
||
maxMana: UNIVERSAL_MAX_MANA,
|
||
};
|
||
}
|
||
|
||
function hydrateCharacterRoleData(
|
||
character: Character,
|
||
options: {
|
||
customWorldProfile?: CustomWorldProfile | null;
|
||
customRole?: CustomWorldPlayableNpc | null;
|
||
} = {},
|
||
) {
|
||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||
const wuxiaProfile = buildCharacterAttributeProfile(character, wuxiaSchema);
|
||
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
|
||
const customProfile = options.customWorldProfile
|
||
? buildCustomWorldPlayableNpcAttributeProfile(
|
||
options.customRole ?? {
|
||
id: character.id,
|
||
name: character.name,
|
||
title: character.title,
|
||
role: character.title,
|
||
description: character.description,
|
||
backstory: character.backstory,
|
||
personality: character.personality,
|
||
motivation: character.description,
|
||
combatStyle: character.skills.map(skill => skill.name).join('、'),
|
||
initialAffinity: 18,
|
||
relationshipHooks: character.combatTags?.slice(0, 3) ?? [],
|
||
tags: character.combatTags ?? [],
|
||
backstoryReveal: {
|
||
publicSummary: character.description,
|
||
chapters: [
|
||
{
|
||
id: 'surface',
|
||
title: '表层来意',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||
teaser: character.description,
|
||
content: character.backstory,
|
||
contextSnippet: character.backstory,
|
||
},
|
||
{
|
||
id: 'scar',
|
||
title: '旧事裂痕',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||
teaser: character.backstory,
|
||
content: character.backstory,
|
||
contextSnippet: character.backstory,
|
||
},
|
||
{
|
||
id: 'hidden',
|
||
title: '隐藏执念',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||
teaser: character.personality,
|
||
content: character.personality,
|
||
contextSnippet: character.personality,
|
||
},
|
||
{
|
||
id: 'final',
|
||
title: '最终底牌',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||
teaser: character.skills[0]?.name ?? character.title,
|
||
content: character.backstory,
|
||
contextSnippet: character.backstory,
|
||
},
|
||
],
|
||
},
|
||
skills: character.skills.slice(0, 3).map((skill, index) => ({
|
||
id: `preset-skill-${index + 1}`,
|
||
name: skill.name,
|
||
summary: skill.name,
|
||
style: skill.style,
|
||
})),
|
||
initialItems: [],
|
||
},
|
||
options.customWorldProfile.attributeSchema,
|
||
character.attributes,
|
||
)
|
||
: null;
|
||
|
||
return {
|
||
...character,
|
||
resourceProfile: buildCharacterResourceProfile(character),
|
||
attributeProfiles: {
|
||
[WorldType.WUXIA]: wuxiaProfile,
|
||
[WorldType.XIANXIA]: xianxiaProfile,
|
||
...(customProfile ? { [WorldType.CUSTOM]: customProfile } : {}),
|
||
},
|
||
attributeProfile: customProfile ?? wuxiaProfile,
|
||
skills: character.skills.map(skill => ({
|
||
...skill,
|
||
attributeProfile: skill.attributeProfile ?? buildSkillAttributeProfile(skill),
|
||
})),
|
||
} satisfies Character;
|
||
}
|
||
|
||
export function buildCompanionState(
|
||
npcId: string,
|
||
character: Character,
|
||
joinedAtAffinity: number,
|
||
worldType: WorldType | null = null,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
): CompanionState {
|
||
const maxHp = Math.max(180, getCharacterMaxHp(character, worldType, customWorldProfile));
|
||
const maxMana = getCharacterMaxMana(character);
|
||
|
||
return {
|
||
npcId,
|
||
characterId: character.id,
|
||
joinedAtAffinity,
|
||
hp: maxHp,
|
||
maxHp,
|
||
mana: maxMana,
|
||
maxMana,
|
||
skillCooldowns: createCharacterSkillCooldowns(character),
|
||
animationState: AnimationState.IDLE,
|
||
actionMode: 'idle',
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
transitionMs: 0,
|
||
};
|
||
}
|
||
|
||
const RECRUIT_CHARACTER_FALLBACKS = [
|
||
'sword-princess',
|
||
'archer-hero',
|
||
'girl-hero',
|
||
'punch-hero',
|
||
'fighter-4',
|
||
] as const;
|
||
|
||
const CHARACTER_GENDERS: Record<string, KnownCharacterGender> = {
|
||
'sword-princess': 'female',
|
||
'archer-hero': 'male',
|
||
'girl-hero': 'female',
|
||
'punch-hero': 'male',
|
||
'fighter-4': 'male',
|
||
};
|
||
|
||
function pickKnownCharacterGender(...candidates: Array<Character['gender'] | undefined>): KnownCharacterGender | null {
|
||
for (const candidate of candidates) {
|
||
if (candidate === 'male' || candidate === 'female') {
|
||
return candidate;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function resolveCharacterGender(
|
||
characterId: string,
|
||
...candidates: Array<Character['gender'] | undefined>
|
||
): KnownCharacterGender {
|
||
const gender = pickKnownCharacterGender(...candidates, CHARACTER_GENDERS[characterId]);
|
||
if (gender) {
|
||
return gender;
|
||
}
|
||
throw new Error(`Character "${characterId}" is missing a concrete gender.`);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export function resolveEncounterRecruitCharacter(
|
||
encounter: Pick<Encounter, 'characterId' | 'context' | 'npcName'>,
|
||
) {
|
||
if (encounter.characterId) {
|
||
return getCharacterById(encounter.characterId);
|
||
}
|
||
|
||
const source = `${encounter.context} ${encounter.npcName}`;
|
||
|
||
if (/猎|巡|舟|渡|哨|斥候|舵|船|琴|湖/u.test(source)) {
|
||
return getCharacterById('archer-hero');
|
||
}
|
||
if (/锻|矿|炉|铁|甲|守|卫|军需|门|雷/u.test(source)) {
|
||
return getCharacterById('fighter-4');
|
||
}
|
||
if (/僧|寺|木|树|拳|火|熔/u.test(source)) {
|
||
return getCharacterById('punch-hero');
|
||
}
|
||
if (/宫|侍|女|药|书|学|司录|页|圃/u.test(source)) {
|
||
return getCharacterById('girl-hero');
|
||
}
|
||
if (/修|使|官|王|殿/u.test(source)) {
|
||
return getCharacterById('sword-princess');
|
||
}
|
||
|
||
const fallbackId = RECRUIT_CHARACTER_FALLBACKS[hashText(source) % RECRUIT_CHARACTER_FALLBACKS.length]
|
||
?? RECRUIT_CHARACTER_FALLBACKS[0]
|
||
?? 'sword-princess';
|
||
return getCharacterById(fallbackId);
|
||
}
|
||
|
||
export function getCharacterEquipment(character: Character) {
|
||
const runtimeProfile = getRuntimeCustomWorldProfile();
|
||
if (runtimeProfile) {
|
||
const starterEquipment = buildCustomWorldStarterEquipmentItems(character, runtimeProfile);
|
||
const toRarityLabel = (rarity: InventoryItem['rarity'] | undefined) => ({
|
||
common: '普通',
|
||
uncommon: '优秀',
|
||
rare: '稀有',
|
||
epic: '史诗',
|
||
legendary: '传说',
|
||
}[rarity ?? 'rare']);
|
||
|
||
return [
|
||
{
|
||
slot: '武器',
|
||
item: starterEquipment.weapon?.name ?? `${character.name}的主手器`,
|
||
rarity: toRarityLabel(starterEquipment.weapon?.rarity),
|
||
},
|
||
{
|
||
slot: '护甲',
|
||
item: starterEquipment.armor?.name ?? `${character.name}的护身装`,
|
||
rarity: toRarityLabel(starterEquipment.armor?.rarity),
|
||
},
|
||
{
|
||
slot: '饰品',
|
||
item: starterEquipment.relic?.name ?? `${character.name}的随身符`,
|
||
rarity: toRarityLabel(starterEquipment.relic?.rarity ?? 'epic'),
|
||
},
|
||
];
|
||
}
|
||
|
||
const overrideEquipment = CHARACTER_OVERRIDES[character.id]?.equipment;
|
||
if (overrideEquipment?.length) {
|
||
return overrideEquipment;
|
||
}
|
||
|
||
const equipmentById: Record<string, CharacterEquipmentItem[]> = {
|
||
'sword-princess': [
|
||
{ slot: '武器', item: '王庭剑', rarity: '稀有' },
|
||
{ slot: '护甲', item: '王庭轻甲', rarity: '稀有' },
|
||
{ slot: '饰品', item: '皇室徽章', rarity: '史诗' },
|
||
],
|
||
'archer-hero': [
|
||
{ slot: '武器', item: '流风弓', rarity: '稀有' },
|
||
{ slot: '护甲', item: '风行者皮甲', rarity: '稀有' },
|
||
{ slot: '饰品', item: '鹰眼石', rarity: '史诗' },
|
||
],
|
||
'girl-hero': [
|
||
{ slot: '武器', item: '双影刃', rarity: '稀有' },
|
||
{ slot: '护甲', item: '疾影轻甲', rarity: '稀有' },
|
||
{ slot: '饰品', item: '敏捷徽章', rarity: '史诗' },
|
||
],
|
||
'punch-hero': [
|
||
{ slot: '武器', item: '破军拳套', rarity: '稀有' },
|
||
{ slot: '护甲', item: '刚岩护甲', rarity: '稀有' },
|
||
{ slot: '饰品', item: '力量护符', rarity: '史诗' },
|
||
],
|
||
'fighter-4': [
|
||
{ slot: '武器', item: '玄甲战刃', rarity: '稀有' },
|
||
{ slot: '护甲', item: '玄铁甲', rarity: '稀有' },
|
||
{ slot: '饰品', item: '守护徽章', rarity: '史诗' },
|
||
],
|
||
};
|
||
|
||
return equipmentById[character.id] ?? [
|
||
{ slot: '未知', item: '空缺', rarity: '普通' },
|
||
{ slot: '未知', item: '空缺', rarity: '普通' },
|
||
{ slot: '未知', item: '空缺', rarity: '普通' },
|
||
];
|
||
}
|
||
|
||
export function getInventoryItems(character: Character, worldType: WorldType | null) {
|
||
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
|
||
return buildCustomWorldStarterInventoryItems(character).map(item => ({
|
||
category: item.category,
|
||
name: item.name,
|
||
quantity: item.quantity,
|
||
}));
|
||
}
|
||
|
||
const overrideInventory = worldType ? CHARACTER_OVERRIDES[character.id]?.inventoryByWorld?.[worldType] : undefined;
|
||
if (overrideInventory?.length) {
|
||
return overrideInventory;
|
||
}
|
||
|
||
const overrideConfig = CHARACTER_OVERRIDES[character.id];
|
||
if (overrideConfig?.inventory?.length) {
|
||
return overrideConfig.inventory;
|
||
}
|
||
const worldItem = worldType === WorldType.XIANXIA ? '仙灵石' : '武斗牌';
|
||
return [
|
||
{ category: '消耗品', name: worldItem, quantity: 6 },
|
||
{ category: '消耗品', name: '治疗药水', quantity: 4 },
|
||
{ category: '稀有品', name: '神秘卷轴', quantity: 1 },
|
||
{ category: '专属品', name: `${character.name}的信物`, quantity: 2 },
|
||
{ category: '材料', name: '精炼石', quantity: 9 },
|
||
];
|
||
}
|
||
|
||
const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attributeProfiles' | 'resourceProfile'>> = [
|
||
{
|
||
id: 'sword-princess',
|
||
name: '剑之公主',
|
||
title: '王庭剑姬',
|
||
gender: 'female',
|
||
description: '以迅疾剑技和正面压制见长,适合喜欢凌厉推进的玩家。',
|
||
backstory: '王庭旁支出身,自幼被当作执剑者培养。一次宫变让她失去旧有庇护,也背上了亲手追回王室誓剑与真相的责任。',
|
||
avatar: 'SP',
|
||
portrait: '/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
|
||
assetFolder: 'Sword Princess',
|
||
assetVariant: 'Original',
|
||
groundOffsetY: 78,
|
||
animationMap: {
|
||
[AnimationState.IDLE]: { folder: 'idle', prefix: 'Idle', frames: 4 },
|
||
[AnimationState.ACQUIRE]: { folder: 'acquire', prefix: 'acquire', frames: 1, extension: 'PNG', file: 'acquire.PNG' },
|
||
[AnimationState.ATTACK]: { folder: 'attack', prefix: 'attack', frames: 6 },
|
||
[AnimationState.RUN]: { folder: 'run', prefix: 'Run', frames: 8 },
|
||
[AnimationState.JUMP]: { folder: 'jump', prefix: 'jump', frames: 3 },
|
||
[AnimationState.DOUBLE_JUMP]: { folder: 'Double Jump', prefix: 'Double Jump', frames: 3 },
|
||
[AnimationState.JUMP_ATTACK]: { folder: 'jump attack', prefix: 'jump attack', frames: 6 },
|
||
[AnimationState.DASH]: { folder: 'dash', prefix: 'dash', frames: 2 },
|
||
[AnimationState.HURT]: { folder: 'hurt', prefix: 'hurt', frames: 2 },
|
||
[AnimationState.DIE]: { folder: 'die', prefix: 'Die', frames: 8 },
|
||
[AnimationState.CLIMB]: { folder: 'climb', prefix: 'Climb', frames: 8 },
|
||
[AnimationState.SKILL1]: { folder: 'skill1', prefix: 'skill1', frames: 7 },
|
||
[AnimationState.SKILL1_JUMP]: { folder: 'skill1 jump', prefix: 'skill1 jump', frames: 7 },
|
||
[AnimationState.SKILL2]: { folder: 'skill2', prefix: 'skill2', frames: 5 },
|
||
[AnimationState.SKILL2_JUMP]: { folder: 'skill2 jump', prefix: 'skill2 jump', frames: 5 },
|
||
[AnimationState.SKILL3]: { folder: 'skill3', prefix: 'strike', frames: 3 },
|
||
[AnimationState.WALL_SLIDE]: { folder: 'Wall Slide', prefix: 'Wall Slide', frames: 1 },
|
||
},
|
||
attributes: { strength: 8, agility: 9, intelligence: 6, spirit: 5 },
|
||
personality: '骄傲、果决、重视荣誉。',
|
||
conversationStyle: conversationStyle({
|
||
guardStyle: 'blunt',
|
||
warmStyle: 'dry',
|
||
truthStyle: 'direct',
|
||
}),
|
||
adventureOpenings: {
|
||
[WorldType.WUXIA]: opening({
|
||
reason: '追查失落王庭誓剑流入江湖的踪迹',
|
||
goal: '在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人',
|
||
monologue: '你来到这个武侠世界,是为追查失落王庭誓剑流入江湖的踪迹。此行最重要的目标,是在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人。',
|
||
surfaceHook: '我追着一件不该流落在外的王庭旧物而来。',
|
||
immediateConcern: '前面盯着这条线的人不止一拨,走错一步就会被人截住。',
|
||
guardedMotive: '我来这里不是巡游散心,有件旧账必须先查清。',
|
||
}),
|
||
[WorldType.XIANXIA]: opening({
|
||
reason: '王庭圣印坠入云海裂隙,你循着残光闯入了仙域',
|
||
goal: '寻回圣印,截断借它开启天门禁制的野心',
|
||
monologue: '你来到这个仙侠世界,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
|
||
surfaceHook: '我循着一道王庭残光追到了这里。',
|
||
immediateConcern: '云海里的局势已经被人搅乱,圣印不会等你慢慢摸索。',
|
||
guardedMotive: '我来这里是为收回一件必须回到我手里的东西。',
|
||
}),
|
||
},
|
||
skills: [
|
||
defineSkill({ id: 'sword-princess-skill1', name: '王庭疾斩', animation: AnimationState.SKILL1, damage: 18, manaCost: 8, cooldownTurns: 1, range: 1.6, style: 'steady' }),
|
||
defineSkill({ id: 'sword-princess-skill1-jump', name: '凌空追刃', animation: AnimationState.SKILL1_JUMP, damage: 22, manaCost: 12, cooldownTurns: 2, range: 1.9, style: 'mobility' }),
|
||
defineSkill({
|
||
id: 'sword-princess-skill2',
|
||
name: '裂阵横斩',
|
||
animation: AnimationState.SKILL2,
|
||
damage: 24,
|
||
manaCost: 15,
|
||
cooldownTurns: 2,
|
||
range: 2.2,
|
||
style: 'steady',
|
||
effects: [
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/cross', { prefix: 'cross', frames: 2 }),
|
||
durationMs: 140,
|
||
sizePx: 76,
|
||
startYOffset: 54,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/cross_explose', { prefix: 'cross_explose', frames: 5 }),
|
||
durationMs: 360,
|
||
sizePx: 94,
|
||
startYOffset: 54,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'sword-princess-skill2-jump',
|
||
name: '跃空断锋',
|
||
animation: AnimationState.SKILL2_JUMP,
|
||
damage: 28,
|
||
manaCost: 18,
|
||
cooldownTurns: 3,
|
||
range: 2.4,
|
||
style: 'burst',
|
||
effects: [
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/cross', { prefix: 'cross', frames: 2 }),
|
||
durationMs: 140,
|
||
sizePx: 82,
|
||
startYOffset: 60,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/cross_explose', { prefix: 'cross_explose', frames: 5 }),
|
||
durationMs: 380,
|
||
sizePx: 102,
|
||
startYOffset: 60,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'sword-princess-skill3',
|
||
name: '王庭裁决',
|
||
animation: AnimationState.SKILL3,
|
||
damage: 36,
|
||
manaCost: 24,
|
||
cooldownTurns: 4,
|
||
range: 2.8,
|
||
style: 'finisher',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 180,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/wolf skill', { prefix: 'wolf skill', frames: 13 }),
|
||
durationMs: 480,
|
||
sizePx: 124,
|
||
startYOffset: 24,
|
||
endYOffset: 34,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/cross', { prefix: 'cross', frames: 2 }),
|
||
durationMs: 140,
|
||
sizePx: 82,
|
||
startYOffset: 54,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/cross_explose', { prefix: 'cross_explose', frames: 5 }),
|
||
durationMs: 360,
|
||
sizePx: 108,
|
||
startYOffset: 54,
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
},
|
||
{
|
||
id: 'archer-hero',
|
||
name: '神箭游侠',
|
||
title: '流风弓卫',
|
||
gender: 'male',
|
||
description: '擅长远距离压制与精准射击,节奏灵活,机动性很强。',
|
||
backstory: '曾是边境游骑与斥候,被一场伏击逼得离开旧军阵。如今他只信自己亲眼见过的风向与箭路,却仍背着守住边境故土的旧誓。',
|
||
avatar: 'AH',
|
||
portrait: '/character/Archer%20Hero/Original/Hero/idle/idle01.png',
|
||
assetFolder: 'Archer Hero',
|
||
assetVariant: 'Original',
|
||
groundOffsetY: 78,
|
||
animationMap: {
|
||
[AnimationState.ACQUIRE]: { folder: 'acquire', prefix: 'acquire', frames: 1 },
|
||
[AnimationState.IDLE]: { folder: 'idle', prefix: 'idle', frames: 4 },
|
||
[AnimationState.ATTACK]: { folder: 'attack', prefix: 'attack', frames: 6 },
|
||
[AnimationState.RUN]: { folder: 'run', prefix: 'run', frames: 8 },
|
||
[AnimationState.JUMP]: { folder: 'jump', prefix: 'jump', frames: 3 },
|
||
[AnimationState.DOUBLE_JUMP]: { folder: 'double jump', prefix: 'double jump', frames: 3 },
|
||
[AnimationState.JUMP_ATTACK]: { folder: 'jump attack', prefix: 'jump attack', frames: 6 },
|
||
[AnimationState.DASH]: { folder: 'dash', prefix: 'dash', frames: 2 },
|
||
[AnimationState.HURT]: { folder: 'hurt', prefix: 'hurt', frames: 3 },
|
||
[AnimationState.DIE]: { folder: 'die', prefix: 'die', frames: 8 },
|
||
[AnimationState.CLIMB]: { folder: 'climb', prefix: 'Climb', frames: 8 },
|
||
[AnimationState.SKILL1]: { folder: 'skill1', prefix: 'skill1', frames: 10 },
|
||
[AnimationState.SKILL1_JUMP]: { folder: 'skill1 jump', prefix: 'skill1 jump', frames: 10 },
|
||
[AnimationState.SKILL2]: { folder: 'skill2', prefix: 'skill2', frames: 8 },
|
||
[AnimationState.SKILL2_JUMP]: { folder: 'skill2 jump', prefix: 'skill2 jump', frames: 8 },
|
||
[AnimationState.SKILL3]: { folder: 'skill3', prefix: 'skill3', frames: 5, startFrame: 1 },
|
||
[AnimationState.WALL_SLIDE]: { folder: 'wallslide', prefix: 'wallslide', frames: 1 },
|
||
},
|
||
attributes: { strength: 6, agility: 9, intelligence: 7, spirit: 6 },
|
||
personality: '冷静、敏锐、极有耐心。',
|
||
conversationStyle: conversationStyle({
|
||
guardStyle: 'wary',
|
||
warmStyle: 'dry',
|
||
truthStyle: 'fragmented',
|
||
}),
|
||
adventureOpenings: {
|
||
[WorldType.WUXIA]: opening({
|
||
reason: '追着一份指向边军叛徒的密图进入江湖',
|
||
goal: '找出贩卖军情的人,并截回被转移的军械账册',
|
||
monologue: '你来到这个武侠世界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
|
||
surfaceHook: '我追着一份旧军密图走到了这片江湖。',
|
||
immediateConcern: '这条线上的人都擅长放假风声,前路不只一层埋伏。',
|
||
guardedMotive: '我在追一条和边军旧案有关的线,但还不到全说的时候。',
|
||
}),
|
||
[WorldType.XIANXIA]: opening({
|
||
reason: '星舟坠毁后,你顺着碎裂航迹漂进了仙域云海',
|
||
goal: '找回星图核心,查清是谁击落了你的船队',
|
||
monologue: '你来到这个仙侠世界,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
|
||
surfaceHook: '我是顺着一段断掉的航迹飘进来的。',
|
||
immediateConcern: '云海里残留的痕迹还没散干净,说明对方离得不远。',
|
||
guardedMotive: '我在追查一场坠毁后的尾线,暂时只确认到这里。',
|
||
}),
|
||
},
|
||
skills: [
|
||
defineSkill({
|
||
id: 'archer-hero-skill1',
|
||
name: '裂风连矢',
|
||
animation: AnimationState.SKILL1,
|
||
damage: 16,
|
||
manaCost: 7,
|
||
cooldownTurns: 1,
|
||
range: 4.6,
|
||
style: 'steady',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 220,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/arrow', { file: 'arrow.PNG' }),
|
||
durationMs: 360,
|
||
sizePx: 76,
|
||
startYOffset: 56,
|
||
endYOffset: 56,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/arrow disappear', { prefix: 'arrow disappear', frames: 5 }),
|
||
durationMs: 320,
|
||
sizePx: 84,
|
||
startYOffset: 56,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'archer-hero-skill1-jump',
|
||
name: '腾空散射',
|
||
animation: AnimationState.SKILL1_JUMP,
|
||
damage: 20,
|
||
manaCost: 11,
|
||
cooldownTurns: 2,
|
||
range: 4.8,
|
||
style: 'mobility',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 220,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/arrow', { file: 'arrow.PNG' }),
|
||
durationMs: 390,
|
||
sizePx: 76,
|
||
startYOffset: 72,
|
||
endYOffset: 60,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/arrow disappear', { prefix: 'arrow disappear', frames: 5 }),
|
||
durationMs: 320,
|
||
sizePx: 88,
|
||
startYOffset: 58,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'archer-hero-skill2',
|
||
name: '穿云贯射',
|
||
animation: AnimationState.SKILL2,
|
||
damage: 27,
|
||
manaCost: 16,
|
||
cooldownTurns: 3,
|
||
range: 5.4,
|
||
style: 'projectile',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 240,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/arrow', { file: 'arrow.PNG' }),
|
||
durationMs: 420,
|
||
sizePx: 84,
|
||
startYOffset: 56,
|
||
endYOffset: 56,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/arrow disappear', { prefix: 'arrow disappear', frames: 5 }),
|
||
durationMs: 360,
|
||
sizePx: 92,
|
||
startYOffset: 58,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'archer-hero-skill2-jump',
|
||
name: '踏风落箭',
|
||
animation: AnimationState.SKILL2_JUMP,
|
||
damage: 25,
|
||
manaCost: 14,
|
||
cooldownTurns: 2,
|
||
range: 5.1,
|
||
style: 'mobility',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 220,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/arrow', { file: 'arrow.PNG' }),
|
||
durationMs: 380,
|
||
sizePx: 84,
|
||
startYOffset: 72,
|
||
endYOffset: 60,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/arrow disappear', { prefix: 'arrow disappear', frames: 5 }),
|
||
durationMs: 340,
|
||
sizePx: 92,
|
||
startYOffset: 58,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'archer-hero-skill3',
|
||
name: '流星绝射',
|
||
animation: AnimationState.SKILL3,
|
||
damage: 34,
|
||
manaCost: 23,
|
||
cooldownTurns: 4,
|
||
range: 6,
|
||
style: 'finisher',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 180,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/dog', { prefix: 'dog', frames: 23 }),
|
||
durationMs: 560,
|
||
sizePx: 132,
|
||
startYOffset: 20,
|
||
endYOffset: 30,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/arrow disappear', { prefix: 'arrow disappear', frames: 5 }),
|
||
durationMs: 300,
|
||
sizePx: 92,
|
||
startYOffset: 56,
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
},
|
||
{
|
||
id: 'girl-hero',
|
||
name: '双刃旅者',
|
||
title: '疾影斥候',
|
||
gender: 'female',
|
||
description: '速度快、侵略性强,适合喜欢持续推进和连段压迫的玩家。',
|
||
backstory: '她在暗巷与帮派追杀中长大,学会靠速度、直觉和先手活下去。表面上轻快利落,心里却一直在追查那封改变命运的密信去向。',
|
||
avatar: 'GH',
|
||
portrait: '/character/Girl%20Hero%201/Original/Hero/Idle/Idle01.png',
|
||
assetFolder: 'Girl Hero 1',
|
||
assetVariant: 'Original',
|
||
groundOffsetY: 78,
|
||
animationMap: {
|
||
[AnimationState.ACQUIRE]: { folder: 'acquire', prefix: 'acquire', frames: 1 },
|
||
[AnimationState.IDLE]: { folder: 'Idle', prefix: 'Idle', frames: 4 },
|
||
[AnimationState.ATTACK]: { folder: 'attack', prefix: 'attack', frames: 9 },
|
||
[AnimationState.RUN]: { folder: 'Run', prefix: 'Run', frames: 8 },
|
||
[AnimationState.JUMP]: { folder: 'Jump', prefix: 'Jump', frames: 4 },
|
||
[AnimationState.DOUBLE_JUMP]: { folder: 'double jump', prefix: 'double jump', frames: 3 },
|
||
[AnimationState.JUMP_ATTACK]: { folder: 'jump attack', prefix: 'jump attack', frames: 9 },
|
||
[AnimationState.DASH]: { folder: 'dash', prefix: 'dash', frames: 2 },
|
||
[AnimationState.HURT]: { folder: 'hurt', prefix: 'hurt', frames: 3 },
|
||
[AnimationState.DIE]: { folder: 'die', prefix: 'die', frames: 10 },
|
||
[AnimationState.CLIMB]: { folder: 'climb', prefix: 'climb', frames: 8 },
|
||
[AnimationState.SKILL1]: { folder: 'skill1', prefix: 'skill1', frames: 19 },
|
||
[AnimationState.SKILL2]: { folder: 'skill2', prefix: 'skill2', frames: 8 },
|
||
[AnimationState.SKILL2_JUMP]: { folder: 'skill2 jump', prefix: 'skill2 jump', frames: 8 },
|
||
[AnimationState.SKILL3]: { folder: 'skill3', prefix: 'skill4', frames: 6 },
|
||
[AnimationState.SKILL3_JUMP]: { folder: 'skill3 jump', prefix: 'skill4 jump', frames: 6 },
|
||
[AnimationState.WALL_SLIDE]: { folder: 'wallslide', prefix: 'wallslide', frames: 1 },
|
||
},
|
||
attributes: { strength: 7, agility: 8, intelligence: 6, spirit: 7 },
|
||
personality: '大胆、直接、主动出击。',
|
||
conversationStyle: conversationStyle({
|
||
guardStyle: 'blunt',
|
||
warmStyle: 'teasing',
|
||
truthStyle: 'deflecting',
|
||
}),
|
||
adventureOpenings: {
|
||
[WorldType.WUXIA]: opening({
|
||
reason: '追着偷走密信的人潜入了这片雨夜江湖',
|
||
goal: '夺回密信,查清究竟是谁把你推上了被追杀的路',
|
||
monologue: '你来到这个武侠世界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
|
||
surfaceHook: '我追着一个偷走东西的人摸进了这里。',
|
||
immediateConcern: '前面这条路像是专门给人设的套,闯快了只会替别人探雷。',
|
||
guardedMotive: '我来这儿是为了追一封信,也顺便追查是谁想让我闭嘴。',
|
||
}),
|
||
[WorldType.XIANXIA]: opening({
|
||
reason: '密信指向一座只会在月湖现身的仙门残阵',
|
||
goal: '找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁',
|
||
monologue: '你来到这个仙侠世界,是因为那封密信把你引向了一座只会在月湖现身的仙门残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
|
||
surfaceHook: '有封信把我一路引到了月湖这一带。',
|
||
immediateConcern: '残阵现身的时间很短,再慢一点就只剩空壳。',
|
||
guardedMotive: '我来这里不只是找阵眼,还想弄明白一件跟我自己有关的怪事。',
|
||
}),
|
||
},
|
||
skills: [
|
||
defineSkill({
|
||
id: 'girl-hero-skill1',
|
||
name: '疾影连割',
|
||
animation: AnimationState.SKILL1,
|
||
damage: 17,
|
||
manaCost: 8,
|
||
cooldownTurns: 1,
|
||
range: 1.4,
|
||
style: 'steady',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 200,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/cat', { prefix: 'cat', frames: 4 }),
|
||
durationMs: 340,
|
||
sizePx: 92,
|
||
startYOffset: 26,
|
||
endYOffset: 32,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/cat explosion', { prefix: 'cat explosion', frames: 8 }),
|
||
durationMs: 420,
|
||
sizePx: 96,
|
||
startYOffset: 56,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({ id: 'girl-hero-skill2', name: '交错双锋', animation: AnimationState.SKILL2, damage: 23, manaCost: 13, cooldownTurns: 2, range: 1.8, style: 'burst' }),
|
||
defineSkill({ id: 'girl-hero-skill2-jump', name: '掠空斩落', animation: AnimationState.SKILL2_JUMP, damage: 26, manaCost: 15, cooldownTurns: 2, range: 2.1, style: 'mobility' }),
|
||
defineSkill({ id: 'girl-hero-skill3', name: '影袭回环', animation: AnimationState.SKILL3, damage: 30, manaCost: 18, cooldownTurns: 3, range: 2.4, style: 'burst' }),
|
||
defineSkill({ id: 'girl-hero-skill3-jump', name: '裂空追猎', animation: AnimationState.SKILL3_JUMP, damage: 34, manaCost: 22, cooldownTurns: 4, range: 2.8, style: 'finisher' }),
|
||
],
|
||
},
|
||
{
|
||
id: 'punch-hero',
|
||
name: '破军拳师',
|
||
title: '近战斗修',
|
||
gender: 'male',
|
||
description: '以贴身爆发和连续重击见长,适合偏爱近战硬碰硬的玩家。',
|
||
backstory: '出身市井拳馆,靠一双拳头打出名声,也替许多人扛过最难的时候。师门被毁后,他把寻找仇家与守住同门遗愿都压在了自己肩上。',
|
||
avatar: 'PH',
|
||
portrait: '/character/Punch%20Hero%203/Original/Hero/Idle/Idle01.png',
|
||
assetFolder: 'Punch Hero 3',
|
||
assetVariant: 'Original',
|
||
groundOffsetY: 78,
|
||
animationMap: {
|
||
[AnimationState.ACQUIRE]: { folder: 'Aquire', prefix: 'Aquire', frames: 1 },
|
||
[AnimationState.IDLE]: { folder: 'Idle', prefix: 'Idle', frames: 4 },
|
||
[AnimationState.ATTACK]: { folder: 'Attack', prefix: 'Attack', frames: 13 },
|
||
[AnimationState.RUN]: { folder: 'Run', prefix: 'Run', frames: 8 },
|
||
[AnimationState.JUMP]: { folder: 'Jump', prefix: 'Jump', frames: 3 },
|
||
[AnimationState.DOUBLE_JUMP]: { folder: 'Double Jump', prefix: 'Double Jump', frames: 2 },
|
||
[AnimationState.JUMP_ATTACK]: { folder: 'Jump Attack', prefix: 'Jump Attack', frames: 13 },
|
||
[AnimationState.DASH]: { folder: 'Dash', prefix: 'Dash', frames: 1 },
|
||
[AnimationState.HURT]: { folder: 'Hurt', prefix: 'Hurt', frames: 2 },
|
||
[AnimationState.DIE]: { folder: 'Die', prefix: 'Die', frames: 10 },
|
||
[AnimationState.CLIMB]: { folder: 'Climb', prefix: 'Climb', frames: 8 },
|
||
[AnimationState.SKILL1]: { folder: 'skill1', prefix: 'skill1', frames: 9 },
|
||
[AnimationState.SKILL1_JUMP]: { folder: 'skill1 Jump', prefix: 'skill1 Jump', frames: 9 },
|
||
[AnimationState.SKILL2]: { folder: 'skill2', prefix: 'skill2', frames: 4 },
|
||
[AnimationState.SKILL3]: { folder: 'skill3', prefix: 'skill3', frames: 13 },
|
||
[AnimationState.SKILL4]: { folder: 'skill4', prefix: 'skill4', frames: 11 },
|
||
[AnimationState.WALL_SLIDE]: { folder: 'Wall Slide', prefix: 'Wall Slide', frames: 1 },
|
||
},
|
||
attributes: { strength: 9, agility: 7, intelligence: 5, spirit: 6 },
|
||
personality: '直率、强硬、无所畏惧。',
|
||
conversationStyle: conversationStyle({
|
||
guardStyle: 'blunt',
|
||
warmStyle: 'steady',
|
||
truthStyle: 'direct',
|
||
}),
|
||
adventureOpenings: {
|
||
[WorldType.WUXIA]: opening({
|
||
reason: '循着毁掉拳馆的凶手线索来到了这片江湖',
|
||
goal: '找到凶手首领,让拳馆遗物和弟子名册不再被人践踏',
|
||
monologue: '你来到这个武侠世界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
|
||
surfaceHook: '我追着一帮砸了拳馆的人一路追到了这里。',
|
||
immediateConcern: '前面那股气味不对,像是有人刚动过手脚。',
|
||
guardedMotive: '我来这里是为了算账,也为了把该带回去的东西带回去。',
|
||
}),
|
||
[WorldType.XIANXIA]: opening({
|
||
reason: '师门遗物在灵火裂隙里传来回应,你一路追进了熔境',
|
||
goal: '夺回遗物中的真传拳谱,阻止它被人炼成杀器',
|
||
monologue: '你来到这个仙侠世界,是因为师门遗物在灵火裂隙里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
|
||
surfaceHook: '我顺着师门遗物的回应一路追到了熔境。',
|
||
immediateConcern: '灵火裂隙正往外吐东西,再拖下去只会更难收拾。',
|
||
guardedMotive: '我来这里是为了把师门留下的东西抢回来,别的以后再细说。',
|
||
}),
|
||
},
|
||
skills: [
|
||
defineSkill({
|
||
id: 'punch-hero-skill1',
|
||
name: '破军连捶',
|
||
animation: AnimationState.SKILL1,
|
||
damage: 19,
|
||
manaCost: 8,
|
||
cooldownTurns: 1,
|
||
range: 1.2,
|
||
style: 'steady',
|
||
effects: [
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/hit', { prefix: 'FX Hit', frames: 2 }),
|
||
durationMs: 180,
|
||
sizePx: 96,
|
||
startYOffset: 56,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'punch-hero-skill1-jump',
|
||
name: '腾身砸落',
|
||
animation: AnimationState.SKILL1_JUMP,
|
||
damage: 24,
|
||
manaCost: 12,
|
||
cooldownTurns: 2,
|
||
range: 1.5,
|
||
style: 'mobility',
|
||
effects: [
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/hit', { prefix: 'FX Hit', frames: 2 }),
|
||
durationMs: 180,
|
||
sizePx: 108,
|
||
startYOffset: 62,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'punch-hero-skill2',
|
||
name: '断岳寸劲',
|
||
animation: AnimationState.SKILL2,
|
||
damage: 28,
|
||
manaCost: 15,
|
||
cooldownTurns: 2,
|
||
range: 1.4,
|
||
style: 'burst',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 180,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX', { file: 'skill2_shoot.PNG' }),
|
||
durationMs: 320,
|
||
sizePx: 84,
|
||
startYOffset: 54,
|
||
endYOffset: 56,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/skill2_fx', { prefix: 'skill2_fx', frames: 5 }),
|
||
durationMs: 340,
|
||
sizePx: 96,
|
||
startYOffset: 56,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'punch-hero-skill3',
|
||
name: '千钧破阵',
|
||
animation: AnimationState.SKILL3,
|
||
damage: 33,
|
||
manaCost: 19,
|
||
cooldownTurns: 3,
|
||
range: 1.8,
|
||
style: 'burst',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 240,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: assetSequence('FX/skill3_shoot', { prefix: 'skill3_shoot', frames: 4 }),
|
||
durationMs: 360,
|
||
sizePx: 102,
|
||
startYOffset: 56,
|
||
endYOffset: 58,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/skill3_fx', { prefix: 'skill3_fx', frames: 6 }),
|
||
durationMs: 360,
|
||
sizePx: 112,
|
||
startYOffset: 54,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'punch-hero-skill4',
|
||
name: '裂地霸拳',
|
||
animation: AnimationState.SKILL4,
|
||
damage: 40,
|
||
manaCost: 25,
|
||
cooldownTurns: 4,
|
||
range: 2.2,
|
||
style: 'finisher',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 250,
|
||
effects: [
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: assetSequence('FX/skill4_light', { prefix: 'skill4_light', frames: 8 }),
|
||
durationMs: 520,
|
||
sizePx: 126,
|
||
startYOffset: 56,
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
},
|
||
{
|
||
id: 'fighter-4',
|
||
name: '玄甲战锋',
|
||
title: '重装先锋',
|
||
gender: 'male',
|
||
description: '攻守兼备,推进稳健,适合喜欢扎实前排风格的玩家。',
|
||
backstory: '他长期担任重装前锋,习惯站在最危险的位置替队伍扛下第一波冲击。旧部溃散后,他仍按军纪行事,只是如今要守护的对象变成了自己认定的道路。',
|
||
avatar: 'F4',
|
||
portrait: '/character/Fighter%204/original/Hero/idle/idle01.png',
|
||
assetFolder: 'Fighter 4',
|
||
assetVariant: 'original',
|
||
groundOffsetY: 78,
|
||
animationMap: {
|
||
[AnimationState.ACQUIRE]: { folder: 'acquire', prefix: 'acquire', frames: 1 },
|
||
[AnimationState.IDLE]: { folder: 'idle', prefix: 'idle', frames: 4 },
|
||
[AnimationState.ATTACK]: { folder: 'attack', prefix: 'attack', frames: 5 },
|
||
[AnimationState.RUN]: { folder: 'run', prefix: 'run', frames: 8 },
|
||
[AnimationState.JUMP]: { folder: 'jump', prefix: 'jump', frames: 4 },
|
||
[AnimationState.DOUBLE_JUMP]: { folder: 'double jump', prefix: 'double jump', frames: 3 },
|
||
[AnimationState.JUMP_ATTACK]: { folder: 'jump attack', prefix: 'jump attack', frames: 5 },
|
||
[AnimationState.DASH]: { folder: 'dash', prefix: 'dash', frames: 2 },
|
||
[AnimationState.HURT]: { folder: 'hurt', prefix: 'hurt', frames: 3 },
|
||
[AnimationState.DIE]: { folder: 'die', prefix: 'die', frames: 11 },
|
||
[AnimationState.CLIMB]: { folder: 'Climb', prefix: 'Climb', frames: 8 },
|
||
[AnimationState.SKILL1]: { folder: 'skill1', prefix: 'skill1', frames: 9 },
|
||
[AnimationState.SKILL1_BULLET]: { folder: 'skill1 bullet', prefix: 'skill1 bullet', frames: 3 },
|
||
[AnimationState.SKILL1_BULLET_FX]: { folder: 'skill1 bullet FX', prefix: 'skill1 bullet FX', frames: 5 },
|
||
[AnimationState.SKILL1_JUMP]: { folder: 'skill1 jump', prefix: 'skill1 jump', frames: 9 },
|
||
[AnimationState.SKILL2]: { folder: 'skill2', prefix: 'skill2', frames: 7 },
|
||
[AnimationState.SKILL3]: { folder: 'skill3', prefix: 'skill3', frames: 9 },
|
||
[AnimationState.SKILL3_BULLET]: { folder: 'skill3 bullet', prefix: 'skill3 bullet', frames: 5 },
|
||
[AnimationState.SKILL3_BULLET_FX]: { folder: 'skill3 bullet FX', prefix: 'skill3 bullet FX', frames: 7 },
|
||
[AnimationState.WALL_SLIDE]: { folder: 'wallslide', prefix: 'wallslide', frames: 1 },
|
||
},
|
||
attributes: { strength: 8, agility: 6, intelligence: 7, spirit: 7 },
|
||
personality: '沉稳、坚韧、纪律严明。',
|
||
conversationStyle: conversationStyle({
|
||
guardStyle: 'measured',
|
||
warmStyle: 'steady',
|
||
truthStyle: 'fragmented',
|
||
}),
|
||
adventureOpenings: {
|
||
[WorldType.WUXIA]: opening({
|
||
reason: '奉旧部最后一道军令,独自赶来守住山门防线',
|
||
goal: '找回失散军旗,重新拼起已经溃散的同袍',
|
||
monologue: '你来到这个武侠世界,是奉着旧部最后一道军令赶来守住山门防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
|
||
surfaceHook: '我奉着一条没法搁下的旧军令守在这里。',
|
||
immediateConcern: '山门前的防线已经松了,再往前走的人很可能被卷进去。',
|
||
guardedMotive: '我来这里不是巡查,是在补一段还没补上的旧阵线。',
|
||
}),
|
||
[WorldType.XIANXIA]: opening({
|
||
reason: '雷坛异动引发旧式甲胄共鸣,你被迫一路追进了仙域',
|
||
goal: '封住失控雷坛,避免整支旧军的甲魂被拿去驱使',
|
||
monologue: '你来到这个仙侠世界,是因为雷坛异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
|
||
surfaceHook: '我顺着旧甲的共鸣一路追到了雷坛附近。',
|
||
immediateConcern: '这里的雷势还在往上走,再晚一点就不是一两个人能压住的事。',
|
||
guardedMotive: '我来这里是为了封住一处失控源头,也为了不让旧军的东西落到错的人手里。',
|
||
}),
|
||
},
|
||
skills: [
|
||
defineSkill({ id: 'fighter-4-skill1', name: '玄甲横扫', animation: AnimationState.SKILL1, damage: 18, manaCost: 8, cooldownTurns: 1, range: 1.6, style: 'steady' }),
|
||
defineSkill({
|
||
id: 'fighter-4-skill1-bullet',
|
||
name: '盾锋震射',
|
||
animation: AnimationState.SKILL1_BULLET,
|
||
casterAnimation: AnimationState.SKILL1,
|
||
damage: 22,
|
||
manaCost: 12,
|
||
cooldownTurns: 2,
|
||
range: 2.4,
|
||
style: 'projectile',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 180,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: animationSequence(AnimationState.SKILL1_BULLET),
|
||
durationMs: 340,
|
||
sizePx: 88,
|
||
startYOffset: 56,
|
||
endYOffset: 56,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: animationSequence(AnimationState.SKILL1_BULLET_FX),
|
||
durationMs: 300,
|
||
sizePx: 96,
|
||
startYOffset: 56,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'fighter-4-skill1-bullet-fx',
|
||
name: '震射余波',
|
||
animation: AnimationState.SKILL1_BULLET_FX,
|
||
casterAnimation: AnimationState.SKILL1,
|
||
damage: 24,
|
||
manaCost: 13,
|
||
cooldownTurns: 2,
|
||
range: 2.6,
|
||
style: 'projectile',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 200,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: animationSequence(AnimationState.SKILL1_BULLET),
|
||
durationMs: 380,
|
||
sizePx: 96,
|
||
startYOffset: 56,
|
||
endYOffset: 58,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: animationSequence(AnimationState.SKILL1_BULLET_FX),
|
||
durationMs: 360,
|
||
sizePx: 112,
|
||
startYOffset: 58,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({ id: 'fighter-4-skill1-jump', name: '跃盾重劈', animation: AnimationState.SKILL1_JUMP, damage: 26, manaCost: 15, cooldownTurns: 2, range: 1.9, style: 'mobility' }),
|
||
defineSkill({ id: 'fighter-4-skill2', name: '阵线推进', animation: AnimationState.SKILL2, damage: 28, manaCost: 16, cooldownTurns: 3, range: 2.1, style: 'steady' }),
|
||
defineSkill({ id: 'fighter-4-skill3', name: '玄甲断城', animation: AnimationState.SKILL3, damage: 33, manaCost: 20, cooldownTurns: 3, range: 2.5, style: 'burst' }),
|
||
defineSkill({
|
||
id: 'fighter-4-skill3-bullet',
|
||
name: '裂阵冲击',
|
||
animation: AnimationState.SKILL3_BULLET,
|
||
casterAnimation: AnimationState.SKILL3,
|
||
damage: 35,
|
||
manaCost: 21,
|
||
cooldownTurns: 4,
|
||
range: 2.8,
|
||
style: 'projectile',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 220,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: animationSequence(AnimationState.SKILL3_BULLET),
|
||
durationMs: 380,
|
||
sizePx: 104,
|
||
startYOffset: 58,
|
||
endYOffset: 60,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: animationSequence(AnimationState.SKILL3_BULLET_FX),
|
||
durationMs: 340,
|
||
sizePx: 118,
|
||
startYOffset: 60,
|
||
}),
|
||
],
|
||
}),
|
||
defineSkill({
|
||
id: 'fighter-4-skill3-bullet-fx',
|
||
name: '城壁崩响',
|
||
animation: AnimationState.SKILL3_BULLET_FX,
|
||
casterAnimation: AnimationState.SKILL3,
|
||
damage: 38,
|
||
manaCost: 24,
|
||
cooldownTurns: 4,
|
||
range: 3,
|
||
style: 'finisher',
|
||
delivery: 'ranged',
|
||
releaseDelayMs: 240,
|
||
effects: [
|
||
effect({
|
||
phase: 'travel',
|
||
anchor: 'caster',
|
||
motion: 'projectile',
|
||
sequence: animationSequence(AnimationState.SKILL3_BULLET),
|
||
durationMs: 420,
|
||
sizePx: 110,
|
||
startYOffset: 58,
|
||
endYOffset: 60,
|
||
}),
|
||
effect({
|
||
phase: 'impact',
|
||
anchor: 'target',
|
||
sequence: animationSequence(AnimationState.SKILL3_BULLET_FX),
|
||
durationMs: 380,
|
||
sizePx: 132,
|
||
startYOffset: 60,
|
||
}),
|
||
],
|
||
}),
|
||
],
|
||
},
|
||
];
|
||
|
||
const BASE_CHARACTER_HOME_SCENE_BY_WORLD: Record<string, Record<WorldTemplateType, string>> = {
|
||
'sword-princess': {
|
||
[WorldType.WUXIA]: 'wuxia-palace-court',
|
||
[WorldType.XIANXIA]: 'xianxia-celestial-corridor',
|
||
},
|
||
'archer-hero': {
|
||
[WorldType.WUXIA]: 'wuxia-border-camp',
|
||
[WorldType.XIANXIA]: 'xianxia-star-vessel',
|
||
},
|
||
'girl-hero': {
|
||
[WorldType.WUXIA]: 'wuxia-rain-street',
|
||
[WorldType.XIANXIA]: 'xianxia-waterfall-cliff',
|
||
},
|
||
'punch-hero': {
|
||
[WorldType.WUXIA]: 'wuxia-forge-works',
|
||
[WorldType.XIANXIA]: 'xianxia-molten-realm',
|
||
},
|
||
'fighter-4': {
|
||
[WorldType.WUXIA]: 'wuxia-mountain-gate',
|
||
[WorldType.XIANXIA]: 'xianxia-thunder-altar',
|
||
},
|
||
};
|
||
|
||
const BASE_CHARACTER_NPC_SCENES_BY_WORLD: Record<string, Partial<Record<WorldTemplateType, string[]>>> = {
|
||
'sword-princess': {
|
||
[WorldType.WUXIA]: ['wuxia-palace-court', 'wuxia-temple-forecourt'],
|
||
[WorldType.XIANXIA]: ['xianxia-celestial-corridor', 'xianxia-ancient-ruins'],
|
||
},
|
||
'archer-hero': {
|
||
[WorldType.WUXIA]: ['wuxia-border-camp', 'wuxia-bamboo-road', 'wuxia-mist-woods'],
|
||
[WorldType.XIANXIA]: ['xianxia-star-vessel', 'xianxia-cloud-gate'],
|
||
},
|
||
'girl-hero': {
|
||
[WorldType.WUXIA]: ['wuxia-rain-street', 'wuxia-ferry-bridge'],
|
||
[WorldType.XIANXIA]: ['xianxia-waterfall-cliff', 'xianxia-moon-lake'],
|
||
},
|
||
'punch-hero': {
|
||
[WorldType.WUXIA]: ['wuxia-forge-works', 'wuxia-mine-depths'],
|
||
[WorldType.XIANXIA]: ['xianxia-molten-realm', 'xianxia-thunder-altar'],
|
||
},
|
||
'fighter-4': {
|
||
[WorldType.WUXIA]: ['wuxia-mountain-gate', 'wuxia-border-camp', 'wuxia-temple-forecourt'],
|
||
[WorldType.XIANXIA]: ['xianxia-thunder-altar', 'xianxia-cloud-gate', 'xianxia-celestial-corridor'],
|
||
},
|
||
};
|
||
|
||
const BASE_CHARACTER_COMBAT_TAGS: Record<string, string[]> = {
|
||
'sword-princess': ['快剑', '突进', '压制'],
|
||
'archer-hero': ['远射', '游击', '风行'],
|
||
'girl-hero': ['快袭', '连段', '追击'],
|
||
'punch-hero': ['重击', '爆发', '压血'],
|
||
'fighter-4': ['守御', '护体', '先锋'],
|
||
};
|
||
|
||
const SKILL_BUILD_BUFFS_BY_ID: Record<string, CharacterSkillDefinition['buildBuffs']> = {
|
||
'sword-princess-skill1-jump': [
|
||
{ id: 'buff-sword-princess-skill1-jump', sourceType: 'skill', sourceId: 'sword-princess-skill1-jump', name: '凌空追势', tags: ['突进', '追击'], durationTurns: 2 },
|
||
],
|
||
'sword-princess-skill2': [
|
||
{ id: 'buff-sword-princess-skill2', sourceType: 'skill', sourceId: 'sword-princess-skill2', name: '裂阵剑压', tags: ['快剑', '压制'], durationTurns: 2 },
|
||
],
|
||
'archer-hero-skill1': [
|
||
{ id: 'buff-archer-hero-skill1', sourceType: 'skill', sourceId: 'archer-hero-skill1', name: '裂风箭势', tags: ['远射', '游击'], durationTurns: 2 },
|
||
],
|
||
'archer-hero-skill2': [
|
||
{ id: 'buff-archer-hero-skill2', sourceType: 'skill', sourceId: 'archer-hero-skill2', name: '风行拉扯', tags: ['风行', '机动'], durationTurns: 2 },
|
||
],
|
||
'girl-hero-skill1': [
|
||
{ id: 'buff-girl-hero-skill1', sourceType: 'skill', sourceId: 'girl-hero-skill1', name: '疾影连袭', tags: ['快袭', '连段'], durationTurns: 2 },
|
||
],
|
||
'girl-hero-skill3-jump': [
|
||
{ id: 'buff-girl-hero-skill3-jump', sourceType: 'skill', sourceId: 'girl-hero-skill3-jump', name: '裂空狩猎', tags: ['追击', '爆发'], durationTurns: 2 },
|
||
],
|
||
'punch-hero-skill2': [
|
||
{ id: 'buff-punch-hero-skill2', sourceType: 'skill', sourceId: 'punch-hero-skill2', name: '破军重势', tags: ['重击', '爆发'], durationTurns: 2 },
|
||
],
|
||
'punch-hero-skill4': [
|
||
{ id: 'buff-punch-hero-skill4', sourceType: 'skill', sourceId: 'punch-hero-skill4', name: '压血狂轰', tags: ['压血', '爆发'], durationTurns: 2 },
|
||
],
|
||
'fighter-4-skill2': [
|
||
{ id: 'buff-fighter-4-skill2', sourceType: 'skill', sourceId: 'fighter-4-skill2', name: '阵线稳推', tags: ['先锋', '压制'], durationTurns: 2 },
|
||
],
|
||
'fighter-4-skill3': [
|
||
{ id: 'buff-fighter-4-skill3', sourceType: 'skill', sourceId: 'fighter-4-skill3', name: '玄甲护阵', tags: ['守御', '护体'], durationTurns: 2 },
|
||
],
|
||
};
|
||
|
||
function enrichCharacterSkills(skills: CharacterSkillDefinition[]) {
|
||
return skills.map(skill => ({
|
||
...skill,
|
||
buildBuffs: skill.buildBuffs ?? SKILL_BUILD_BUFFS_BY_ID[skill.id] ?? [],
|
||
}));
|
||
}
|
||
|
||
function mergeCharacterPreset(baseCharacter: Character): Character {
|
||
const override = CHARACTER_OVERRIDES[baseCharacter.id];
|
||
if (!override) {
|
||
return hydrateCharacterRoleData({
|
||
...baseCharacter,
|
||
combatTags: normalizeBuildTags(baseCharacter.combatTags ?? BASE_CHARACTER_COMBAT_TAGS[baseCharacter.id] ?? [], 3),
|
||
gender: resolveCharacterGender(baseCharacter.id, baseCharacter.gender),
|
||
skills: enrichCharacterSkills(baseCharacter.skills),
|
||
});
|
||
}
|
||
|
||
return hydrateCharacterRoleData({
|
||
...baseCharacter,
|
||
...override,
|
||
combatTags: normalizeBuildTags(override.combatTags ?? baseCharacter.combatTags ?? BASE_CHARACTER_COMBAT_TAGS[baseCharacter.id] ?? [], 3),
|
||
conversationStyle: override.conversationStyle ?? baseCharacter.conversationStyle ?? inferConversationStyleFromText(baseCharacter.personality),
|
||
gender: resolveCharacterGender(baseCharacter.id, override.gender, baseCharacter.gender),
|
||
attributes: {
|
||
...baseCharacter.attributes,
|
||
...(override.attributes ?? {}),
|
||
},
|
||
animationMap: override.animationMap
|
||
? {
|
||
...(baseCharacter.animationMap ?? {}),
|
||
...override.animationMap,
|
||
}
|
||
: baseCharacter.animationMap,
|
||
skills: enrichCharacterSkills(override.skills ?? baseCharacter.skills),
|
||
});
|
||
}
|
||
|
||
export const PRESET_CHARACTERS: Character[] = BASE_PRESET_CHARACTERS.map(mergeCharacterPreset);
|
||
const runtimeCharacterOverrides = new Map<string, Character>();
|
||
let runtimeCustomWorldCharacters: Character[] = [];
|
||
|
||
const CUSTOM_WORLD_RANGED_HINTS = [/远程|投掷|弓|箭|弩|枪|炮|符|阵|法|术|射/u];
|
||
const CUSTOM_WORLD_BURST_HINTS = [/爆发|重击|轰|炮击|强攻|斩杀|压制|雷/u];
|
||
const CUSTOM_WORLD_MOBILITY_HINTS = [/机动|腾挪|游击|疾|迅|快|闪|突进|位移/u];
|
||
const CUSTOM_WORLD_SUSTAIN_HINTS = [/续航|守|护|稳|调息|回复|控场|消耗|节奏/u];
|
||
|
||
function countPatternMatches(source: string, patterns: RegExp[]) {
|
||
return patterns.reduce((score, pattern) => score + (pattern.test(source) ? 1 : 0), 0);
|
||
}
|
||
|
||
function clampInteger(value: number, min: number, max: number) {
|
||
return Math.min(max, Math.max(min, Math.round(value)));
|
||
}
|
||
|
||
function buildCustomWorldSkillVariant(
|
||
profile: CustomWorldProfile,
|
||
baseCharacter: Character,
|
||
role: CustomWorldPlayableNpc,
|
||
skill: CharacterSkillDefinition,
|
||
index: number,
|
||
) {
|
||
const themeMode = detectCustomWorldThemeMode(profile);
|
||
const generatedSkill =
|
||
role.skills[index % Math.max(1, role.skills.length)] ?? null;
|
||
const contextText = [
|
||
profile.name,
|
||
profile.settingText,
|
||
profile.summary,
|
||
profile.tone,
|
||
profile.playerGoal,
|
||
role.title,
|
||
role.description,
|
||
role.backstory,
|
||
role.personality,
|
||
role.combatStyle,
|
||
role.backstoryReveal.publicSummary,
|
||
role.skills.map((item) => `${item.name} ${item.summary} ${item.style}`).join(' '),
|
||
role.initialItems.map((item) => `${item.name} ${item.category} ${item.description}`).join(' '),
|
||
role.tags.join(' '),
|
||
generatedSkill?.name ?? '',
|
||
generatedSkill?.summary ?? '',
|
||
generatedSkill?.style ?? '',
|
||
].join(' ');
|
||
const seed = hashText(`${contextText}:${baseCharacter.id}:${skill.id}:${index}`);
|
||
const isRangedSkill = skill.delivery === 'ranged' || skill.style === 'projectile';
|
||
const rangedBias = countPatternMatches(contextText, CUSTOM_WORLD_RANGED_HINTS);
|
||
const burstBias = countPatternMatches(contextText, CUSTOM_WORLD_BURST_HINTS);
|
||
const mobilityBias = countPatternMatches(contextText, CUSTOM_WORLD_MOBILITY_HINTS);
|
||
const sustainBias = countPatternMatches(contextText, CUSTOM_WORLD_SUSTAIN_HINTS);
|
||
const themeDamageBias = themeMode === 'machina' ? 3 : themeMode === 'rift' ? 4 : themeMode === 'arcane' ? 2 : 1;
|
||
const themeRangeBias = themeMode === 'arcane' || themeMode === 'rift'
|
||
? 1
|
||
: themeMode === 'machina' || themeMode === 'tide'
|
||
? 0.5
|
||
: 0;
|
||
const variance = (seed % 3) - 1;
|
||
const damageBoost = themeDamageBias + burstBias * 3 + (isRangedSkill ? rangedBias : mobilityBias) + variance - sustainBias;
|
||
const manaShift = (themeMode === 'arcane' || themeMode === 'rift' ? 1 : 0) + rangedBias + Math.max(0, burstBias - 1) - Math.min(2, sustainBias);
|
||
const cooldownShift = (burstBias >= 2 ? 1 : 0) - (mobilityBias >= 2 || sustainBias >= 2 ? 1 : 0);
|
||
const rangeBoost = isRangedSkill
|
||
? Math.min(2, rangedBias + Math.round(themeRangeBias))
|
||
: Math.min(1, mobilityBias > 0 ? 1 : 0);
|
||
|
||
return {
|
||
...skill,
|
||
name:
|
||
generatedSkill?.name?.trim()
|
||
|| buildThemedSkillName(profile, baseCharacter, skill, index, role),
|
||
damage: clampInteger(skill.damage + damageBoost, Math.max(6, skill.damage - 4), skill.damage + 12),
|
||
manaCost: clampInteger(skill.manaCost + manaShift, 0, skill.manaCost + 5),
|
||
cooldownTurns: clampInteger(skill.cooldownTurns + cooldownShift, 1, skill.cooldownTurns + 2),
|
||
range: clampInteger(skill.range + rangeBoost, 1, skill.range + (isRangedSkill ? 2 : 1)),
|
||
} satisfies CharacterSkillDefinition;
|
||
}
|
||
|
||
function buildCustomWorldAdventureOpening(
|
||
profile: CustomWorldProfile,
|
||
character: Character,
|
||
) {
|
||
const reason = `${character.name}决定亲自踏入${profile.name},因为${character.backstory}`;
|
||
const goal = profile.playerGoal;
|
||
const monologue = [
|
||
`${character.name} · ${character.title}`,
|
||
`你进入${profile.name},不是为了旁观,而是要${profile.playerGoal}。`,
|
||
`对你来说,这趟旅程真正的起点,是“${character.backstory}”。`,
|
||
`眼下世界的基调是${profile.tone},而你知道,自己已经没有退回原地的资格。`,
|
||
].join('\n');
|
||
|
||
return opening({
|
||
reason,
|
||
goal,
|
||
monologue,
|
||
surfaceHook: `${character.name}不是来旁观这片世界的。`,
|
||
immediateConcern: `${profile.name}眼下的动静和${profile.playerGoal}直接相关,越拖越难收拾。`,
|
||
guardedMotive: `${character.name}愿意先告诉你自己会继续追下去,但不会在刚见面时把全部底牌摊开。`,
|
||
});
|
||
}
|
||
|
||
function buildCustomWorldCharacter(baseCharacter: Character, profile: CustomWorldProfile, index: number) {
|
||
const role = profile.playableNpcs[index];
|
||
if (!role) {
|
||
return baseCharacter;
|
||
}
|
||
|
||
const combatTags = deriveCustomWorldCharacterCombatTags(profile, role, baseCharacter);
|
||
|
||
const opening = buildCustomWorldAdventureOpening(profile, {
|
||
...baseCharacter,
|
||
name: role.name,
|
||
title: role.title,
|
||
description: role.description,
|
||
backstory: role.backstory,
|
||
personality: role.personality,
|
||
});
|
||
|
||
return hydrateCharacterRoleData({
|
||
...baseCharacter,
|
||
name: role.name,
|
||
title: role.title,
|
||
description: role.description,
|
||
backstory: role.backstory,
|
||
backstoryReveal: role.backstoryReveal,
|
||
personality: role.personality,
|
||
conversationStyle: inferConversationStyleFromText([
|
||
role.personality,
|
||
role.description,
|
||
role.backstory,
|
||
role.combatStyle,
|
||
role.backstoryReveal.publicSummary,
|
||
role.skills.map((skill) => `${skill.name} ${skill.summary}`).join('、'),
|
||
role.initialItems.map((item) => `${item.name} ${item.description}`).join('、'),
|
||
role.tags.join('、'),
|
||
].join(' ')),
|
||
combatTags,
|
||
skills: baseCharacter.skills.map((skill, skillIndex) =>
|
||
buildCustomWorldSkillVariant(profile, baseCharacter, role, skill, skillIndex),
|
||
),
|
||
adventureOpenings: {
|
||
[WorldType.WUXIA]: opening,
|
||
[WorldType.XIANXIA]: opening,
|
||
[WorldType.CUSTOM]: opening,
|
||
},
|
||
}, {
|
||
customWorldProfile: profile,
|
||
customRole: role,
|
||
});
|
||
}
|
||
|
||
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
|
||
if (!profile) {
|
||
return PRESET_CHARACTERS;
|
||
}
|
||
|
||
if (profile.playableNpcs.length === 0) {
|
||
return PRESET_CHARACTERS;
|
||
}
|
||
|
||
return profile.playableNpcs.map((role, index) => {
|
||
const fallbackTemplateCharacter = PRESET_CHARACTERS[index % Math.max(1, PRESET_CHARACTERS.length)]
|
||
?? PRESET_CHARACTERS[0];
|
||
if (!fallbackTemplateCharacter) {
|
||
throw new Error('Missing preset characters for custom world generation');
|
||
}
|
||
const templateCharacter = PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId)
|
||
?? fallbackTemplateCharacter;
|
||
|
||
const customCharacter = buildCustomWorldCharacter(templateCharacter, {
|
||
...profile,
|
||
playableNpcs: [{
|
||
...role,
|
||
templateCharacterId: role.templateCharacterId ?? templateCharacter.id,
|
||
}],
|
||
}, 0);
|
||
|
||
return {
|
||
...customCharacter,
|
||
id: role.id,
|
||
} satisfies Character;
|
||
});
|
||
}
|
||
|
||
export function setRuntimeCharacterOverrides(characters: Character[] | null) {
|
||
runtimeCharacterOverrides.clear();
|
||
runtimeCustomWorldCharacters = characters ? [...characters] : [];
|
||
characters?.forEach(character => {
|
||
runtimeCharacterOverrides.set(character.id, character);
|
||
});
|
||
}
|
||
|
||
export function getCharacterById(characterId: string) {
|
||
return runtimeCharacterOverrides.get(characterId)
|
||
?? PRESET_CHARACTERS.find(character => character.id === characterId)
|
||
?? null;
|
||
}
|
||
|
||
export function getCharacterAdventureOpening(character: Character, worldType: WorldType | null) {
|
||
if (!worldType) return null;
|
||
if (worldType === WorldType.CUSTOM) {
|
||
return character.adventureOpenings?.[WorldType.CUSTOM]
|
||
?? character.adventureOpenings?.[WorldType.WUXIA]
|
||
?? character.adventureOpenings?.[WorldType.XIANXIA]
|
||
?? null;
|
||
}
|
||
return character.adventureOpenings?.[worldType] ?? null;
|
||
}
|
||
|
||
function truncateText(text: string, maxLength = 26) {
|
||
const normalized = text.trim().replace(/\s+/g, ' ');
|
||
if (normalized.length <= maxLength) {
|
||
return normalized;
|
||
}
|
||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||
}
|
||
|
||
function splitNarrativeSentences(text: string) {
|
||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||
if (!normalized) return [];
|
||
|
||
const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu);
|
||
return (matches ?? [normalized]).map(item => item.trim()).filter(Boolean);
|
||
}
|
||
|
||
function buildFallbackBackstoryRevealConfig(
|
||
character: Character,
|
||
worldType: WorldType | null,
|
||
): NonNullable<Character['backstoryReveal']> {
|
||
const opening = getCharacterAdventureOpening(character, worldType);
|
||
const normalizedBackstory = character.backstory.trim() || `${character.name}对自己的过去讳莫如深。`;
|
||
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
|
||
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
|
||
const backstoryDetail = backstorySentences.slice(0, 2).join('') || normalizedBackstory;
|
||
const publicSummary = character.description.trim() || truncateText(normalizedBackstory, 42);
|
||
|
||
return {
|
||
publicSummary,
|
||
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||
chapters: [
|
||
{
|
||
id: 'surface-hook',
|
||
title: '表层来意',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||
teaser: truncateText(opening?.surfaceHook ?? opening?.guardedMotive ?? backstoryLead),
|
||
content: [
|
||
opening?.surfaceHook ? `最先能看出来的,只是:${opening.surfaceHook}` : null,
|
||
opening?.guardedMotive ? `若继续追问,${character.name}最多只肯松口到这一步:${opening.guardedMotive}` : null,
|
||
opening?.immediateConcern ? `眼下更急的是:${opening.immediateConcern}` : null,
|
||
!opening ? backstoryLead : null,
|
||
].filter(Boolean).join(' '),
|
||
contextSnippet: opening?.guardedMotive
|
||
? `${character.name}表面只肯承认:${opening.guardedMotive}`
|
||
: `${character.name}只肯先提起一层表面来意。`,
|
||
},
|
||
{
|
||
id: 'old-scars',
|
||
title: '旧事残痕',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_FRIENDLY,
|
||
teaser: truncateText(backstoryLead),
|
||
content: backstoryDetail,
|
||
contextSnippet: `${character.name}的旧事里埋着一段尚未完全说开的经历:${truncateText(backstoryLead, 36)}`,
|
||
},
|
||
{
|
||
id: 'real-reason',
|
||
title: '真正来由',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_TRUSTED,
|
||
teaser: truncateText(opening?.reason ?? backstoryDetail),
|
||
content: opening?.reason
|
||
? `${character.name}来到此地真正的原因是:${opening.reason}`
|
||
: normalizedBackstory,
|
||
contextSnippet: opening?.reason
|
||
? `${character.name}来到此地的真正原因与“${opening.reason}”有关。`
|
||
: `${character.name}开始愿意谈及更深一层的来由。`,
|
||
},
|
||
{
|
||
id: 'current-goal',
|
||
title: '当前执念',
|
||
affinityRequired: BACKSTORY_UNLOCK_AFFINITY_CLOSE,
|
||
teaser: truncateText(opening?.goal ?? normalizedBackstory),
|
||
content: opening?.goal
|
||
? [
|
||
`${character.name}最不愿轻易对外明说的执念,是:${opening.goal}`,
|
||
opening?.reason ? `而这一点又和“${opening.reason}”直接相连。` : null,
|
||
].filter(Boolean).join(' ')
|
||
: normalizedBackstory,
|
||
contextSnippet: opening?.goal
|
||
? `${character.name}最深的执念指向:${opening.goal}`
|
||
: `${character.name}开始愿意谈及自己真正想守住的东西。`,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
export function getCharacterBackstoryRevealConfig(
|
||
character: Character,
|
||
worldType: WorldType | null,
|
||
) {
|
||
const fallback = buildFallbackBackstoryRevealConfig(character, worldType);
|
||
const configured = character.backstoryReveal;
|
||
if (!configured) {
|
||
return fallback;
|
||
}
|
||
|
||
return {
|
||
publicSummary: configured.publicSummary?.trim() || fallback.publicSummary,
|
||
privateChatUnlockAffinity:
|
||
configured.privateChatUnlockAffinity ?? fallback.privateChatUnlockAffinity,
|
||
chapters: fallback.chapters.map((fallbackChapter, index) => {
|
||
const chapter = configured.chapters?.[index];
|
||
const content = chapter?.content?.trim() || fallbackChapter.content || '';
|
||
return {
|
||
...fallbackChapter,
|
||
id: chapter?.id?.trim() || fallbackChapter.id || `chapter-${index + 1}`,
|
||
title:
|
||
chapter?.title?.trim() ||
|
||
fallbackChapter.title ||
|
||
`背景片段 ${index + 1}`,
|
||
affinityRequired: fallbackChapter.affinityRequired,
|
||
teaser: chapter?.teaser?.trim() || truncateText(content),
|
||
content,
|
||
contextSnippet:
|
||
chapter?.contextSnippet?.trim() || truncateText(content, 48),
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function getCharacterPublicBackstorySummary(
|
||
character: Character,
|
||
worldType: WorldType | null,
|
||
) {
|
||
return getCharacterBackstoryRevealConfig(character, worldType).publicSummary;
|
||
}
|
||
|
||
export function getCharacterPrivateChatUnlockAffinity(
|
||
character: Character,
|
||
worldType: WorldType | null,
|
||
) {
|
||
return getCharacterBackstoryRevealConfig(character, worldType).privateChatUnlockAffinity
|
||
?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY;
|
||
}
|
||
|
||
export function getUnlockedCharacterBackstoryChapters(
|
||
character: Character,
|
||
affinity: number,
|
||
worldType: WorldType | null,
|
||
) {
|
||
return getCharacterBackstoryRevealConfig(character, worldType)
|
||
.chapters
|
||
.filter(chapter => affinity >= chapter.affinityRequired);
|
||
}
|
||
|
||
export function getLockedCharacterBackstoryChapters(
|
||
character: Character,
|
||
affinity: number,
|
||
worldType: WorldType | null,
|
||
) {
|
||
return getCharacterBackstoryRevealConfig(character, worldType)
|
||
.chapters
|
||
.filter(chapter => affinity < chapter.affinityRequired);
|
||
}
|
||
|
||
export function getUnlockedCharacterBackstoryChapterIds(
|
||
character: Character,
|
||
affinity: number,
|
||
worldType: WorldType | null,
|
||
) {
|
||
return getUnlockedCharacterBackstoryChapters(character, affinity, worldType)
|
||
.map(chapter => chapter.id);
|
||
}
|
||
|
||
export function getNextLockedCharacterBackstoryChapter(
|
||
character: Character,
|
||
affinity: number,
|
||
worldType: WorldType | null,
|
||
) {
|
||
return getCharacterBackstoryRevealConfig(character, worldType)
|
||
.chapters
|
||
.find(chapter => affinity < chapter.affinityRequired) ?? null;
|
||
}
|
||
|
||
export function buildCharacterBackstoryPromptContext(
|
||
character: Character,
|
||
affinity: number,
|
||
worldType: WorldType | null,
|
||
) {
|
||
const revealConfig = getCharacterBackstoryRevealConfig(character, worldType);
|
||
return [
|
||
revealConfig.publicSummary,
|
||
...getUnlockedCharacterBackstoryChapters(character, affinity, worldType)
|
||
.map(chapter => chapter.contextSnippet.trim())
|
||
.filter(Boolean),
|
||
].filter((snippet): snippet is string => Boolean(snippet));
|
||
}
|
||
|
||
export function getCharacterHomeSceneId(worldType: WorldType, characterId: string) {
|
||
if (isCustomWorldType(worldType)) {
|
||
const profile = getRuntimeCustomWorldProfile();
|
||
if (!profile || profile.landmarks.length === 0) {
|
||
return 'custom-scene-camp';
|
||
}
|
||
const characterIndex = runtimeCustomWorldCharacters.findIndex(character => character.id === characterId);
|
||
const landmarkIndex = Math.max(0, characterIndex) % profile.landmarks.length;
|
||
return `custom-scene-landmark-${landmarkIndex + 1}`;
|
||
}
|
||
return CHARACTER_OVERRIDES[characterId]?.sceneBindings?.[worldType]?.homeSceneId
|
||
?? BASE_CHARACTER_HOME_SCENE_BY_WORLD[characterId]?.[worldType]
|
||
?? null;
|
||
}
|
||
|
||
export function getCharacterNpcSceneIds(worldType: WorldType, characterId: string) {
|
||
if (isCustomWorldType(worldType)) {
|
||
const profile = getRuntimeCustomWorldProfile();
|
||
if (!profile || profile.landmarks.length === 0) {
|
||
return ['custom-scene-camp'];
|
||
}
|
||
const characterIndex = runtimeCustomWorldCharacters.findIndex(character => character.id === characterId);
|
||
const firstScene = `custom-scene-landmark-${(Math.max(0, characterIndex) % profile.landmarks.length) + 1}`;
|
||
const secondScene = `custom-scene-landmark-${((Math.max(0, characterIndex) + 1) % profile.landmarks.length) + 1}`;
|
||
return ['custom-scene-camp', firstScene, secondScene];
|
||
}
|
||
return CHARACTER_OVERRIDES[characterId]?.sceneBindings?.[worldType]?.npcSceneIds
|
||
?? BASE_CHARACTER_NPC_SCENES_BY_WORLD[characterId]?.[worldType]
|
||
?? [];
|
||
}
|
||
|
||
export function getCharacterPresetOverrideById(characterId: string) {
|
||
return CHARACTER_OVERRIDES[characterId] ?? null;
|
||
}
|
||
|
||
export function buildCharacterNpc(
|
||
characterId: string,
|
||
worldType: WorldType | null = WorldType.WUXIA,
|
||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||
): SceneNpc | null {
|
||
const character = getCharacterById(characterId);
|
||
if (!character) return null;
|
||
|
||
return {
|
||
id: `character-npc-${character.id}`,
|
||
characterId: character.id,
|
||
name: character.name,
|
||
role: character.title,
|
||
gender: resolveCharacterGender(character.id, character.gender),
|
||
avatar: character.avatar,
|
||
description: `${character.title}在此地活动。${character.description}`,
|
||
attributeProfile: resolveCharacterAttributeProfile(character, worldType, customWorldProfile) ?? character.attributeProfile,
|
||
};
|
||
}
|