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, 'unknown'>; export type CharacterPresetOverride = Partial> & { gender?: KnownCharacterGender; attributes?: Partial; skills?: CharacterSkillDefinition[]; animationMap?: Partial>; equipment?: CharacterEquipmentItem[]; inventory?: CharacterInventoryItem[]; inventoryByWorld?: Partial>; sceneBindings?: Partial>; }; const CHARACTER_OVERRIDES = characterOverridesJson as Record; 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 = { 'sword-princess': 'female', 'archer-hero': 'male', 'girl-hero': 'female', 'punch-hero': 'male', 'fighter-4': 'male', }; function pickKnownCharacterGender(...candidates: Array): KnownCharacterGender | null { for (const candidate of candidates) { if (candidate === 'male' || candidate === 'female') { return candidate; } } return null; } function resolveCharacterGender( characterId: string, ...candidates: Array ): 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, ) { 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 = { '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> = [ { 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> = { '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>> = { '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 = { 'sword-princess': ['快剑', '突进', '压制'], 'archer-hero': ['远射', '游击', '风行'], 'girl-hero': ['快袭', '连段', '追击'], 'punch-hero': ['重击', '爆发', '压血'], 'fighter-4': ['守御', '护体', '先锋'], }; const SKILL_BUILD_BUFFS_BY_ID: Record = { '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(); 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 { 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, }; }