470 lines
13 KiB
TypeScript
470 lines
13 KiB
TypeScript
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,
|
||
STATE_FUNCTION_RUNTIME_SOURCES,
|
||
} 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];
|
||
const STATE_FUNCTION_RUNTIME_SOURCE_MAP = new Map(
|
||
STATE_FUNCTION_RUNTIME_SOURCES.map((source) => [
|
||
source.definition.id,
|
||
source,
|
||
]),
|
||
);
|
||
|
||
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) => {
|
||
const runtime =
|
||
STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||
return runtime?.applyDefinitionAdjustments?.(definition) ?? 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 buildRuntimeMetrics(context: FunctionAvailabilityContext) {
|
||
return {
|
||
playerHpRatio: getPlayerHpRatio(context),
|
||
playerManaRatio: getPlayerManaRatio(context),
|
||
monsterHpRatio: getMonsterHpRatio(context),
|
||
};
|
||
}
|
||
|
||
function buildRuntimeEnvironment(context: FunctionAvailabilityContext) {
|
||
const monster = getPrimaryMonster(context);
|
||
const forwardScene = getForwardScenePreset(
|
||
context.worldType,
|
||
context.currentSceneId,
|
||
);
|
||
const travelScene = getTravelScenePreset(
|
||
context.worldType,
|
||
context.currentSceneId,
|
||
);
|
||
|
||
return {
|
||
sceneName: context.currentSceneName ?? '前路',
|
||
monsterName: monster?.name ?? '前方怪物',
|
||
hasForwardScene: Boolean(forwardScene),
|
||
travelSceneName: travelScene?.name ?? null,
|
||
travelSceneDescription: travelScene?.description ?? null,
|
||
};
|
||
}
|
||
|
||
function buildSuggestedActionText(
|
||
definition: StateFunctionDefinition,
|
||
context: FunctionAvailabilityContext,
|
||
) {
|
||
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||
return (
|
||
runtime?.buildSuggestedActionText?.({
|
||
definition,
|
||
metrics: buildRuntimeMetrics(context),
|
||
environment: buildRuntimeEnvironment(context),
|
||
}) ?? definition.text
|
||
);
|
||
}
|
||
|
||
function buildOptionDetailText(
|
||
definition: StateFunctionDefinition,
|
||
context: FunctionAvailabilityContext,
|
||
) {
|
||
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||
return runtime?.buildDetailText?.({
|
||
definition,
|
||
environment: buildRuntimeEnvironment(context),
|
||
});
|
||
}
|
||
|
||
function getFunctionPriority(
|
||
definition: StateFunctionDefinition,
|
||
context: FunctionAvailabilityContext,
|
||
) {
|
||
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||
return (
|
||
runtime?.getPriority?.({
|
||
definition,
|
||
metrics: buildRuntimeMetrics(context),
|
||
}) ?? 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];
|
||
}
|