Files
Genarrative/src/hooks/combatStoryUtils.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

430 lines
14 KiB
TypeScript

import { createFallbackOption } from '../data/hostileNpcs';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import {
getDefaultFunctionIdsForContext,
getFunctionById,
getFunctionEffect,
getFunctionSkillWeights,
resolveFunctionOption,
} from '../data/stateFunctions';
import { AnimationState, Character, GameState, StoryMoment, StoryOption } from '../types';
const FALLBACK_STORY: StoryMoment = {
text: '怪物守在你的正前方,现在只剩战斗或逃跑两类选择。',
options: [
createFallbackOption('battle_attack_basic', '普通攻击', AnimationState.ATTACK, 0, false),
createFallbackOption('battle_recover_breath', '恢复', AnimationState.IDLE, 0, false),
createFallbackOption('battle_escape_breakout', '逃跑', AnimationState.RUN, -0.6, false),
],
};
type CombatStyle = 'all_in' | 'steady' | 'escape';
function getDefaultSkillWeight(skill: Character['skills'][number], style: CombatStyle) {
if (style === 'escape') return 0;
if (style === 'all_in') {
if (skill.style === 'finisher') return 5;
if (skill.style === 'burst') return 4;
if (skill.style === 'mobility') return 2.5;
if (skill.style === 'projectile') return 2;
return 1.5;
}
if (skill.style === 'steady') return 4;
if (skill.style === 'mobility') return 2.5;
if (skill.style === 'projectile') return 2.2;
if (skill.style === 'burst') return 2;
return 1.4;
}
export function classifyCombatOption(option: StoryOption) {
const functionMeta = getFunctionById(option.functionId);
if (functionMeta?.category === 'escape') return 'escape' as const;
if (functionMeta?.state === 'idle') return 'idle' as const;
return 'battle' as const;
}
export function inferCombatStyle(option: StoryOption): CombatStyle {
const category = getFunctionById(option.functionId)?.category;
const text = option.text ?? option.actionText;
if (category === 'escape') return 'escape';
if (option.functionId === 'battle_all_in_crush' || option.functionId === 'battle_finisher_window') return 'all_in';
if (classifyCombatOption(option) === 'escape') return 'escape';
if (text.includes('全力') || text.includes('压上') || text.includes('猛攻')) return 'all_in';
return 'steady';
}
function getAvailableSkills(
character: Character,
mana: number,
cooldowns: Record<string, number>,
option: StoryOption,
) {
return character.skills
.filter(skill => (cooldowns[skill.id] ?? 0) <= 0 && mana >= skill.manaCost)
.map(skill => ({
skill,
weight: option.skillProbabilities?.[skill.id] ?? 0,
}));
}
export function chooseWeightedSkill(
character: Character,
mana: number,
cooldowns: Record<string, number>,
option: StoryOption,
) {
const available = getAvailableSkills(character, mana, cooldowns, option);
if (available.length === 0) return null;
const total = available.reduce((sum, item) => sum + Math.max(item.weight, 0.01), 0);
let roll = Math.random() * total;
for (const item of available) {
roll -= Math.max(item.weight, 0.01);
if (roll <= 0) {
return item.skill;
}
}
return available[available.length - 1]?.skill ?? null;
}
export function chooseWeightedSkillForStyle(
character: Character,
mana: number,
cooldowns: Record<string, number>,
style: CombatStyle,
) {
const available = character.skills
.filter(skill => (cooldowns[skill.id] ?? 0) <= 0 && mana >= skill.manaCost)
.map(skill => ({
skill,
weight: getDefaultSkillWeight(skill, style),
}));
if (available.length === 0) return null;
const total = available.reduce((sum, item) => sum + Math.max(item.weight, 0.01), 0);
let roll = Math.random() * total;
for (const item of available) {
roll -= Math.max(item.weight, 0.01);
if (roll <= 0) {
return item.skill;
}
}
return available[available.length - 1]?.skill ?? null;
}
export function normalizeSkillProbabilities(option: StoryOption, character: Character) {
const functionWeights = getFunctionSkillWeights(option.functionId);
const style = inferCombatStyle(option);
const weighted = character.skills.map(skill => {
const styleWeight = functionWeights?.[skill.style];
const rawWeight = typeof styleWeight === 'number' ? styleWeight : option.skillProbabilities?.[skill.id];
const fallbackWeight = getDefaultSkillWeight(skill, style);
return {
skillId: skill.id,
weight: Math.max(0, typeof rawWeight === 'number' ? rawWeight : fallbackWeight),
};
});
const total = weighted.reduce((sum, item) => sum + item.weight, 0);
const normalized = total > 0
? Object.fromEntries(weighted.map(item => [item.skillId, Number((item.weight / total).toFixed(4))]))
: Object.fromEntries(character.skills.map(skill => [skill.id, Number((1 / character.skills.length).toFixed(4))]));
return {
...option,
skillProbabilities: normalized,
};
}
function createSingleActionBattleOption(
functionId: string,
actionText: string,
playerAnimation: AnimationState,
detailText?: string,
extras: Partial<StoryOption> = {},
) {
return {
...createFallbackOption(functionId, actionText, playerAnimation, functionId === 'battle_escape_breakout' ? -0.6 : 0, functionId === 'battle_escape_breakout'),
detailText,
...extras,
} satisfies StoryOption;
}
function getBasicAttackDamage(character: Character) {
return Math.max(
8,
Math.round(
character.attributes.strength * 0.85 + character.attributes.agility * 0.45,
),
);
}
function pickPreferredBattleItem(state: GameState, character: Character) {
const hasCoolingSkill = Object.values(state.playerSkillCooldowns).some(
(turns) => turns > 0,
);
const playerHpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
const playerManaRatio = state.playerMana / Math.max(state.playerMaxMana, 1);
return state.playerInventory
.filter((item) => item.quantity > 0 && isInventoryItemUsable(item))
.map((item) => {
const effect = resolveInventoryItemUseEffect(item, character);
if (!effect) return null;
const score =
effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) +
effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) +
effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) +
effect.buildBuffs.length * 8;
return { item, effect, score };
})
.filter(
(
candidate,
): candidate is {
item: GameState['playerInventory'][number];
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>;
score: number;
} => Boolean(candidate),
)
.sort(
(left, right) =>
right.score - left.score ||
right.effect.hpRestore - left.effect.hpRestore ||
right.effect.manaRestore - left.effect.manaRestore ||
left.item.name.localeCompare(right.item.name, 'zh-CN'),
)[0] ?? null;
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
const parts = [
effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
effect.buildBuffs.length > 0
? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}`
: null,
].filter(Boolean);
return parts.join(' / ') || '立即结算一次物品效果';
}
function buildSingleActionBattleOptions(state: GameState, character: Character) {
const preferredItem = pickPreferredBattleItem(state, character);
return [
createSingleActionBattleOption(
'battle_attack_basic',
'普通攻击',
AnimationState.ATTACK,
`不耗蓝 / 伤害 ${getBasicAttackDamage(character)}`,
),
createSingleActionBattleOption(
'battle_recover_breath',
'恢复',
AnimationState.IDLE,
'回血 12 / 回蓝 9 / 冷却 -1',
),
preferredItem
? createSingleActionBattleOption(
'inventory_use',
`使用物品:${preferredItem.item.name}`,
AnimationState.ACQUIRE,
buildBattleItemSummary(preferredItem.effect),
{
runtimePayload: { itemId: preferredItem.item.id },
},
)
: createSingleActionBattleOption(
'inventory_use',
'使用物品',
AnimationState.ACQUIRE,
'当前没有可直接结算的战斗消耗品',
{
disabled: true,
disabledReason: '暂无可用物品',
},
),
...character.skills.map((skill) => {
const remainingCooldown = state.playerSkillCooldowns[skill.id] ?? 0;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${skill.damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: `冷却中,还需 ${remainingCooldown} 回合`,
},
);
}
if (skill.manaCost > state.playerMana) {
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
disabled: true,
disabledReason: '灵力不足',
},
);
}
return createSingleActionBattleOption(
'battle_use_skill',
skill.name,
skill.animation,
detailText,
{
runtimePayload: { skillId: skill.id },
},
);
}),
createSingleActionBattleOption(
'battle_escape_breakout',
'逃跑',
AnimationState.RUN,
'立刻脱离当前战斗',
),
];
}
export function getFallbackOptionsForState(state: GameState, character: Character) {
if (state.inBattle) {
return buildSingleActionBattleOptions(state, character);
}
if (!state.worldType) {
return FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
}
const functionContext = {
worldType: state.worldType,
playerCharacter: character,
inBattle: state.inBattle,
currentSceneId: state.currentScenePreset?.id ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
monsters: state.sceneHostileNpcs,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
};
const options = getDefaultFunctionIdsForContext(functionContext)
.map(functionId => resolveFunctionOption(functionId, functionContext))
.filter(Boolean) as StoryOption[];
return options.length > 0
? options.map(option => normalizeSkillProbabilities(option, character))
: FALLBACK_STORY.options.map(option => normalizeSkillProbabilities(option, character));
}
export function buildFallbackStoryMoment(state: GameState, character: Character): StoryMoment {
const primaryMonster = state.sceneHostileNpcs.find(monster => monster.hp > 0) ?? state.sceneHostileNpcs[0];
const text = state.inBattle && primaryMonster
? `${primaryMonster.name}${primaryMonster.action},战斗还没有结束。`
: `${state.currentScenePreset?.name ?? '前方区域'}暂时平静下来,你可以继续探索或前往新的地点。`;
return {
text,
options: getFallbackOptionsForState(state, character),
};
}
export function getOptionImpactSummary(
option: StoryOption,
character: Character,
hp: number,
maxHp: number,
mana: number,
maxMana: number,
cooldowns: Record<string, number>,
currentNpcBattleMode: GameState['currentNpcBattleMode'] = null,
) {
if (option.functionId === 'battle_attack_basic') {
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 0 / 伤害 ${getBasicAttackDamage(character)}`;
}
if (option.functionId === 'battle_use_skill') {
const skillId =
typeof option.runtimePayload?.skillId === 'string'
? option.runtimePayload.skillId
: '';
const skill = character.skills.find((candidate) => candidate.id === skillId);
if (!skill) return null;
return currentNpcBattleMode === 'spar'
? '切磋伤害 1'
: `耗蓝 ${skill.manaCost} / 伤害 ${skill.damage}`;
}
const functionMeta = getFunctionById(option.functionId);
if (!functionMeta) return null;
if (functionMeta.category === 'recovery') {
const effect = getFunctionEffect(option.functionId);
const parts: string[] = [];
if ((effect.healAmount ?? 0) > 0) {
const healAmount = Math.max(0, Math.min(effect.healAmount ?? 0, maxHp - hp));
parts.push(`回血 ${healAmount}`);
}
if ((effect.manaRestore ?? 0) > 0) {
const manaRestore = Math.max(0, Math.min(effect.manaRestore ?? 0, maxMana - mana));
parts.push(`回蓝 ${manaRestore}`);
}
if (parts.length === 0 && (effect.cooldownTickBonus ?? 0) > 0) {
parts.push(`减冷却 ${effect.cooldownTickBonus} 回合`);
}
return parts.length > 0 ? parts.join(' / ') : null;
}
if (functionMeta.category !== 'battle') return null;
if (currentNpcBattleMode === 'spar') {
return '切磋伤害 1';
}
const normalizedOption = normalizeSkillProbabilities(option, character);
const availableSkills = getAvailableSkills(character, mana, cooldowns, normalizedOption).sort(
(a, b) => b.weight - a.weight,
);
const topSkill = availableSkills[0]?.skill;
if (!topSkill) return '耗蓝 -- / 伤害 --';
const damageMultiplier = getFunctionEffect(option.functionId).damageMultiplier ?? 1;
const damage = Math.max(1, Math.round(topSkill.damage * damageMultiplier));
return `耗蓝 ${topSkill.manaCost} / 伤害 ${damage}`;
}