Files
Genarrative/src/data/stateFunctions.ts

580 lines
18 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 {
Character,
FunctionCategory,
PlayerStateMode,
SceneDirective,
SceneHostileNpc,
SkillStyle,
StoryOption,
WorldType,
} from '../types';
import {
CAMP_TRAVEL_HOME_FUNCTION,
NPC_CHAT_FUNCTION,
NPC_PREVIEW_TALK_FUNCTION,
NPC_RECRUIT_FUNCTION,
STATE_FUNCTION_DEFINITIONS as SPLIT_STATE_FUNCTION_DEFINITIONS,
STATE_FUNCTION_PROMPT_DESCRIPTIONS as SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS,
} from './functionCatalog';
import {
getForwardScenePreset,
getTravelScenePreset,
getWorldCampScenePreset,
} from './scenePresets';
import stateFunctionOverridesJson from './stateFunctionOverrides.json';
export interface FunctionEffectConfig {
damageMultiplier?: number;
incomingDamageMultiplier?: number;
healAmount?: number;
manaRestore?: number;
cooldownTickBonus?: number;
turnTimeMultiplier?: number;
skillWeights?: Partial<Record<SkillStyle, number>>;
escapeDurationMs?: number;
escapeDistance?: number;
monsterLagStart?: number;
monsterLagEnd?: number;
sceneShift?: number;
enterBattle?: boolean;
}
export type FunctionVisualConfig = Omit<SceneDirective, 'monsterChanges'> & {
monsterActionTemplate?: string;
monsterAnimation?: 'idle' | 'move' | 'attack';
monsterMoveMeters?: number;
};
export interface StateFunctionDefinition {
id: string;
state: PlayerStateMode;
category: FunctionCategory;
text: string;
description: string;
visual: FunctionVisualConfig;
effect: FunctionEffectConfig;
}
export type StateFunctionDefinitionOverride = Partial<
Pick<StateFunctionDefinition, 'state' | 'category' | 'text' | 'description'>
> & {
visual?: Partial<FunctionVisualConfig>;
effect?: Partial<FunctionEffectConfig>;
};
export type StateFunctionOverrideMap = Record<
string,
StateFunctionDefinitionOverride
>;
export interface FunctionAvailabilityContext {
worldType: WorldType;
playerCharacter: Character | null;
inBattle: boolean;
currentSceneId?: string | null;
currentSceneName?: string | null;
monsters: SceneHostileNpc[];
playerHp: number;
playerMaxHp: number;
playerMana: number;
playerMaxMana: number;
}
const FUNCTION_OPTION_PRIORITIES: Record<string, number> = {
[CAMP_TRAVEL_HOME_FUNCTION.id]: 4,
[NPC_PREVIEW_TALK_FUNCTION.id]: 3,
[NPC_RECRUIT_FUNCTION.id]: 3,
[NPC_CHAT_FUNCTION.id]: 1,
};
const MODEL_PRIORITY_LOCKED_OPTION_COUNT = 2;
export function getFunctionPromptDescription(
functionId: string,
fallback?: string,
) {
return (
SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS[functionId] ??
fallback ??
functionId
);
}
const STATE_FUNCTION_OVERRIDES =
stateFunctionOverridesJson as StateFunctionOverrideMap;
const BASE_FUNCTIONS = [...SPLIT_STATE_FUNCTION_DEFINITIONS];
function mergeStateFunctionDefinition(
definition: StateFunctionDefinition,
override?: StateFunctionDefinitionOverride,
): StateFunctionDefinition {
if (!override) {
return {
...definition,
visual: { ...definition.visual },
effect: {
...definition.effect,
skillWeights: definition.effect.skillWeights
? { ...definition.effect.skillWeights }
: undefined,
},
};
}
const mergedSkillWeights = override.effect?.skillWeights
? {
...(definition.effect.skillWeights ?? {}),
...override.effect.skillWeights,
}
: definition.effect.skillWeights
? { ...definition.effect.skillWeights }
: undefined;
return {
...definition,
...override,
visual: {
...definition.visual,
...(override.visual ?? {}),
},
effect: {
...definition.effect,
...(override.effect ?? {}),
skillWeights: mergedSkillWeights,
},
};
}
function applyRuntimeFunctionAdjustments(
definitions: StateFunctionDefinition[],
) {
return definitions
.filter((definition) => definition.id !== 'idle_follow_clue')
.map((definition) => {
if (definition.id === 'idle_explore_forward') {
return {
...definition,
text: '继续向前探索',
description:
'沿着当前场景继续深入,把前路真正探出来,下一刻就可能撞上新的危险或际遇。',
};
}
if (definition.id === 'idle_call_out') {
return {
...definition,
text: '主动出声试探',
description:
'主动朝前方喊话试探,可能把附近潜着的角色或怪物直接从远处引出来。',
};
}
return definition;
});
}
export function buildStateFunctionDefinitions(
overrides: StateFunctionOverrideMap = STATE_FUNCTION_OVERRIDES,
) {
return applyRuntimeFunctionAdjustments(
BASE_FUNCTIONS.map((definition) =>
mergeStateFunctionDefinition(definition, overrides[definition.id]),
),
);
}
const ALL_FUNCTIONS = buildStateFunctionDefinitions();
function hasAliveMonsters(monsters: SceneHostileNpc[]) {
return monsters.some((monster) => monster.hp > 0);
}
function getPrimaryMonster(context: FunctionAvailabilityContext) {
return (
context.monsters.find((monster) => monster.hp > 0) ??
context.monsters[0] ??
null
);
}
function getPlayerHpRatio(context: FunctionAvailabilityContext) {
return context.playerHp / Math.max(context.playerMaxHp, 1);
}
function getPlayerManaRatio(context: FunctionAvailabilityContext) {
return context.playerMana / Math.max(context.playerMaxMana, 1);
}
function getMonsterHpRatio(context: FunctionAvailabilityContext) {
const monster = getPrimaryMonster(context);
if (!monster) return 0;
return monster.hp / Math.max(monster.maxHp, 1);
}
function buildSuggestedActionText(
definition: StateFunctionDefinition,
context: FunctionAvailabilityContext,
) {
const monster = getPrimaryMonster(context);
const monsterName = monster?.name ?? '前方怪物';
const playerHpRatio = getPlayerHpRatio(context);
const playerManaRatio = getPlayerManaRatio(context);
const monsterHpRatio = getMonsterHpRatio(context);
const forwardScene = getForwardScenePreset(
context.worldType,
context.currentSceneId,
);
const travelScene = getTravelScenePreset(
context.worldType,
context.currentSceneId,
);
const sceneName = context.currentSceneName ?? '前路';
if (definition.id === 'idle_explore_forward') {
if (playerHpRatio <= 0.35)
return `按着伤口,沿着${sceneName}继续往深处摸去`;
if (forwardScene) return `顺着${sceneName}的路势,继续朝前方深处探去`;
return `拨开${sceneName}前的遮挡,继续朝更深处探去`;
}
if (definition.id === 'idle_call_out') {
return `冲着${sceneName}前方扬声试探,看是谁先被逼出来`;
}
switch (definition.id) {
case 'battle_finisher_window':
if (monsterHpRatio <= 0.25) return `完成对${monsterName}的残血收割`;
if (monsterHpRatio <= 0.45) return `抓住${monsterName}露出的破绽补上重击`;
return `盯住${monsterName}的空当准备终结一击`;
case 'battle_all_in_crush':
if (monsterHpRatio <= 0.25) return `压上去收掉${monsterName}最后一口气`;
if (playerHpRatio <= 0.35) return `顶着伤势强压${monsterName}赌一波强杀`;
return `正面强压${monsterName}不给喘息`;
case 'battle_guard_break':
if (monsterHpRatio <= 0.35) return `砸开${monsterName}的架势直接斩落`;
return `重击破开${monsterName}的招架`;
case 'battle_probe_pressure':
if (playerManaRatio <= 0.3)
return `稳住节奏试探${monsterName},先省下灵力`;
if (monsterHpRatio <= 0.3) return `稳步逼近,补掉${monsterName}残余血量`;
return `稳扎稳打继续试探${monsterName}`;
case 'battle_feint_step':
if (monsterHpRatio <= 0.35) return `虚晃切进去收掉${monsterName}`;
return `借假动作切进${monsterName}身前`;
case 'battle_recover_breath':
if (playerHpRatio <= 0.35) return '原地打坐恢复血量';
if (playerManaRatio <= 0.3) return '收势调息回一口灵力';
return '边守边调息稳住节奏';
case 'battle_escape_breakout':
if (playerHpRatio <= 0.35) return `撑着伤势先脱离${monsterName}的追杀`;
return `转身拉开距离,甩开${monsterName}`;
case 'idle_explore_forward':
if (forwardScene) return `继续向前探路`;
if (playerHpRatio <= 0.35) return '拖着伤势继续向前摸索';
return '继续向前探索前路';
case 'idle_travel_next_scene':
return travelScene ? `前往${travelScene.name}` : '前往其他场景';
case 'idle_rest_focus':
if (playerHpRatio <= 0.35) return '原地打坐恢复气血';
if (playerManaRatio <= 0.35) return '盘坐调息恢复灵力';
return '原地调息整理状态';
case 'idle_observe_signs':
return '停步观察附近的风吹草动';
case 'idle_follow_clue':
return '顺着可疑痕迹继续靠近';
case 'idle_call_out':
return '朝前方主动出声试探';
default:
return definition.text;
}
}
function buildOptionDetailText(
definition: StateFunctionDefinition,
context: FunctionAvailabilityContext,
) {
const forwardScene = getForwardScenePreset(
context.worldType,
context.currentSceneId,
);
const travelScene = getTravelScenePreset(
context.worldType,
context.currentSceneId,
);
const sceneName = context.currentSceneName ?? '当前区域';
if (definition.id === 'idle_explore_forward') {
return forwardScene
? `沿着${sceneName}继续往前压过去,真正把前方会遇到的人影、怪物或宝藏探出来。`
: `继续深入${sceneName}前方未探明的地带,下一刻就可能撞见新的动静。`;
}
if (definition.id === 'idle_call_out') {
return '主动打破寂静,把附近潜着的角色或怪物从屏幕外直接引到眼前。';
}
switch (definition.id) {
case 'idle_explore_forward':
return forwardScene
? `沿当前路径继续深入,可能会遇到角色、怪物、宝藏……`
: '继续向前试探这片区域,可能会遇到角色、怪物、宝藏……';
case 'idle_travel_next_scene':
return travelScene?.description ?? '离开当前区域,前往相邻场景继续冒险。';
case 'idle_observe_signs':
return '先确认附近是否潜伏着人影、怪物或其他值得靠近的东西。';
case 'idle_follow_clue':
return '沿着声音、脚印或灵气痕迹继续摸过去,可能更快接近前方目标。';
case 'idle_call_out':
return '主动打破寂静,看看附近是谁或什么东西先有反应。';
default:
return undefined;
}
}
function getFunctionPriority(
definition: StateFunctionDefinition,
context: FunctionAvailabilityContext,
) {
const playerHpRatio = getPlayerHpRatio(context);
const playerManaRatio = getPlayerManaRatio(context);
const monsterHpRatio = getMonsterHpRatio(context);
if (definition.id === 'idle_call_out') {
return 5;
}
switch (definition.id) {
case 'battle_recover_breath':
return (
(playerHpRatio <= 0.35 ? 10 : 0) + (playerManaRatio <= 0.3 ? 6 : 0)
);
case 'battle_finisher_window':
return monsterHpRatio <= 0.25 ? 10 : monsterHpRatio <= 0.45 ? 6 : 1;
case 'battle_all_in_crush':
return monsterHpRatio <= 0.25 ? 8 : playerHpRatio <= 0.35 ? 2 : 4;
case 'battle_guard_break':
return monsterHpRatio <= 0.4 ? 6 : 3;
case 'battle_probe_pressure':
return playerManaRatio <= 0.3 ? 8 : 4;
case 'battle_feint_step':
return monsterHpRatio <= 0.5 ? 5 : 3;
case 'battle_escape_breakout':
return playerHpRatio <= 0.2 ? 9 : playerHpRatio <= 0.35 ? 5 : 1;
case 'idle_rest_focus':
return playerHpRatio <= 0.35 || playerManaRatio <= 0.35 ? 8 : 2;
case 'idle_explore_forward':
return playerHpRatio > 0.45 ? 6 : 2;
case 'idle_travel_next_scene':
return playerHpRatio > 0.45 ? 5 : 3;
case 'idle_observe_signs':
return 4;
case 'idle_follow_clue':
return 5;
case 'idle_call_out':
return 3;
default:
return 0;
}
}
function matchesCategory(
definition: StateFunctionDefinition,
context: FunctionAvailabilityContext,
) {
switch (definition.category) {
case 'battle':
case 'escape':
return context.inBattle && hasAliveMonsters(context.monsters);
case 'idle':
return !context.inBattle;
case 'recovery':
return definition.state === 'battle'
? context.inBattle && hasAliveMonsters(context.monsters)
: !context.inBattle;
default:
return false;
}
}
function isCampSceneContext(context: FunctionAvailabilityContext) {
return (
getWorldCampScenePreset(context.worldType)?.id === context.currentSceneId
);
}
export function getPlayerStateMode(inBattle: boolean): PlayerStateMode {
return inBattle ? 'battle' : 'idle';
}
export function getAllStateFunctionDefinitions(
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return definitions;
}
export function getFunctionsForState(
state: PlayerStateMode,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return definitions.filter((item) => item.state === state);
}
export function getFunctionById(
functionId: string,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return definitions.find((item) => item.id === functionId) ?? null;
}
export function getExecutableFunctions(
context: FunctionAvailabilityContext,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
const state = getPlayerStateMode(context.inBattle);
return getFunctionsForState(state, definitions)
.filter((definition) => matchesCategory(definition, context))
.filter(
(definition) =>
!(
definition.id === 'idle_explore_forward' &&
isCampSceneContext(context)
),
)
.sort((a, b) => {
const scoreDiff =
getFunctionPriority(b, context) - getFunctionPriority(a, context);
if (scoreDiff !== 0) return scoreDiff;
return a.id.localeCompare(b.id);
});
}
export function isFunctionExecutable(
functionId: string,
context: FunctionAvailabilityContext,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return getExecutableFunctions(context, definitions).some(
(item) => item.id === functionId,
);
}
export function buildFunctionCatalogText(
context: FunctionAvailabilityContext,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return getExecutableFunctions(context, definitions)
.map(
(item) =>
`- ${item.id}${getFunctionPromptDescription(item.id, item.description)}`,
)
.join('\n');
}
export function resolveFunctionOption(
functionId: string,
context: FunctionAvailabilityContext,
actionText?: string,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
): StoryOption | null {
const definition = getFunctionById(functionId, definitions);
if (!definition || !isFunctionExecutable(functionId, context, definitions))
return null;
const primaryMonster =
context.monsters.find((monster) => monster.hp > 0) ?? context.monsters[0];
const monsterAction =
definition.visual.monsterActionTemplate && primaryMonster
? definition.visual.monsterActionTemplate.replaceAll(
'{monster}',
primaryMonster.name,
)
: '前方的气氛依旧紧绷';
const suggestedActionText = buildSuggestedActionText(definition, context);
const shouldForceSuggestedActionText =
definition.id === 'idle_explore_forward' ||
definition.id === 'idle_travel_next_scene';
const resolvedActionText = shouldForceSuggestedActionText
? suggestedActionText
: actionText?.trim() || suggestedActionText;
const detailText = buildOptionDetailText(definition, context);
return {
functionId: definition.id,
actionText: resolvedActionText,
text: resolvedActionText,
detailText,
priority: getStoryOptionPriority(definition.id),
visuals: {
playerAnimation: definition.visual.playerAnimation,
playerMoveMeters: definition.visual.playerMoveMeters,
playerOffsetY: definition.visual.playerOffsetY,
playerFacing: definition.visual.playerFacing,
scrollWorld: definition.visual.scrollWorld,
monsterChanges:
primaryMonster && definition.visual.monsterAnimation
? [
{
id: primaryMonster.id,
action: monsterAction,
animation: definition.visual.monsterAnimation,
moveMeters: definition.visual.monsterMoveMeters ?? 0,
yOffset: 0,
},
]
: [],
},
};
}
export function getDefaultFunctionIdsForContext(
context: FunctionAvailabilityContext,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return getExecutableFunctions(context, definitions)
.slice(0, 6)
.map((item) => item.id);
}
export function getFunctionEffect(
functionId: string,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return getFunctionById(functionId, definitions)?.effect ?? {};
}
export function getFunctionSkillWeights(
functionId: string,
definitions: StateFunctionDefinition[] = ALL_FUNCTIONS,
) {
return getFunctionById(functionId, definitions)?.effect.skillWeights ?? null;
}
export function getStoryOptionPriority(functionId: string) {
return FUNCTION_OPTION_PRIORITIES[functionId] ?? 1;
}
export function sortStoryOptionsByPriority(options: StoryOption[]) {
const normalizedOptions = options.map((option, index) => ({
option: {
...option,
priority: getStoryOptionPriority(option.functionId),
},
index,
}));
const lockedOptions = normalizedOptions
.slice(0, MODEL_PRIORITY_LOCKED_OPTION_COUNT)
.map((item) => item.option);
const prioritizedOptions = normalizedOptions
.slice(MODEL_PRIORITY_LOCKED_OPTION_COUNT)
.sort((a, b) => {
const priorityDiff = (b.option.priority ?? 1) - (a.option.priority ?? 1);
if (priorityDiff !== 0) return priorityDiff;
return a.index - b.index;
})
.map((item) => item.option);
return [...lockedOptions, ...prioritizedOptions];
}