Files
Genarrative/src/data/characterPresets.ts

1942 lines
75 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}