128
src/hooks/combat/battlePlan.test.ts
Normal file
128
src/hooks/combat/battlePlan.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {AnimationState, type Character, type GameState, type StoryOption, WorldType} from '../../types';
|
||||
import {buildBattlePlan} from './battlePlan';
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
name: 'Test Hero',
|
||||
title: 'Hero',
|
||||
description: 'A test character',
|
||||
backstory: 'A test backstory',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-basic',
|
||||
name: 'Basic Strike',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 10,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 1,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createTestCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createBattleOption(): StoryOption {
|
||||
return {
|
||||
functionId: 'battle_all_in_crush',
|
||||
actionText: 'Attack',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildBattlePlan', () => {
|
||||
it('short-circuits when there are no monsters', () => {
|
||||
const state = createBaseState();
|
||||
const plan = buildBattlePlan({
|
||||
state,
|
||||
option: createBattleOption(),
|
||||
character: createTestCharacter(),
|
||||
totalSequenceMs: 6000,
|
||||
turnVisualMs: 820,
|
||||
resetStageMs: 260,
|
||||
minTurnCount: 6,
|
||||
});
|
||||
|
||||
expect(plan.turns).toEqual([]);
|
||||
expect(plan.finalState.inBattle).toBe(false);
|
||||
expect(plan.finalState.sceneMonsters).toEqual([]);
|
||||
expect(plan.finalState.animationState).toBe(AnimationState.IDLE);
|
||||
});
|
||||
});
|
||||
|
||||
741
src/hooks/combat/battlePlan.ts
Normal file
741
src/hooks/combat/battlePlan.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
import { resolveCharacterAttributeProfile } from '../../data/attributeResolver';
|
||||
import { appendBuildBuffs, resolveCompanionOutgoingDamage, resolveMonsterOutgoingDamage, resolvePlayerOutgoingDamage, tickBuildBuffs } from '../../data/buildDamage';
|
||||
import {
|
||||
getSkillDelivery,
|
||||
} from '../../data/characterCombat';
|
||||
import {
|
||||
getCharacterById,
|
||||
getCharacterMaxMana,
|
||||
} from '../../data/characterPresets';
|
||||
import { getEquipmentBonuses } from '../../data/equipmentEffects';
|
||||
import {
|
||||
getClosestMonster,
|
||||
getFacingTowardPlayer,
|
||||
settleMonsterAnimations,
|
||||
} from '../../data/hostileNpcs';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CombatDelivery,
|
||||
CompanionState,
|
||||
GameState,
|
||||
SceneMonster,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
AnimationState,
|
||||
} from '../../types';
|
||||
import {
|
||||
chooseWeightedSkill,
|
||||
chooseWeightedSkillForStyle,
|
||||
inferCombatStyle,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
|
||||
type TurnActor = 'player' | 'companion' | 'monster';
|
||||
|
||||
export type BattlePlanStep =
|
||||
| {
|
||||
actor: 'player';
|
||||
targetHostileNpcId: string;
|
||||
originalPlayerX: number;
|
||||
strikeX: number;
|
||||
cooledDown: Record<string, number>;
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
}
|
||||
| {
|
||||
actor: 'companion';
|
||||
companionNpcId: string;
|
||||
targetHostileNpcId: string;
|
||||
strikeOffsetX: number;
|
||||
cooledDown: Record<string, number>;
|
||||
selectedSkillId: string | null;
|
||||
appliedCooldowns: Record<string, number>;
|
||||
damage: number;
|
||||
defeated: boolean;
|
||||
endsBattle: boolean;
|
||||
delivery: CombatDelivery;
|
||||
}
|
||||
| {
|
||||
actor: 'monster';
|
||||
monsterId: string;
|
||||
originalMonsterX: number;
|
||||
strikeX: number;
|
||||
target: 'player' | 'companion';
|
||||
targetCompanionNpcId?: string;
|
||||
targetX: number;
|
||||
damage: number;
|
||||
endsBattle: boolean;
|
||||
selectedSkillId: string | null;
|
||||
npcCharacterId: string | null;
|
||||
delivery: CombatDelivery;
|
||||
};
|
||||
|
||||
export type BattlePlan = {
|
||||
preparedState: GameState;
|
||||
turns: BattlePlanStep[];
|
||||
finalState: GameState;
|
||||
};
|
||||
|
||||
function createEmptyCooldowns(character: Character) {
|
||||
return Object.fromEntries(character.skills.map(skill => [skill.id, 0]));
|
||||
}
|
||||
|
||||
function normalizeCooldowns(character: Character, cooldowns: Record<string, number>) {
|
||||
return Object.fromEntries(character.skills.map(skill => [skill.id, Math.max(0, cooldowns[skill.id] ?? 0)]));
|
||||
}
|
||||
|
||||
function isCompanionAlive(companion: CompanionState) {
|
||||
return companion.hp > 0;
|
||||
}
|
||||
|
||||
export function resetCompanionCombatPresentation(companions: CompanionState[]) {
|
||||
return companions.map(companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.IDLE : AnimationState.DIE,
|
||||
actionMode: 'idle' as const,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
transitionMs: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateCompanionState(
|
||||
companions: CompanionState[],
|
||||
npcId: string,
|
||||
updater: (companion: CompanionState) => CompanionState,
|
||||
) {
|
||||
return companions.map(companion => companion.npcId === npcId ? updater(companion) : companion);
|
||||
}
|
||||
|
||||
function getCompanionSlotIndex(companions: CompanionState[], npcId: string) {
|
||||
return Math.max(0, companions.findIndex(companion => companion.npcId === npcId));
|
||||
}
|
||||
|
||||
export function getCompanionAnchorX(playerX: number, companions: CompanionState[], npcId: string) {
|
||||
const slotIndex = getCompanionSlotIndex(companions, npcId);
|
||||
return Number((playerX - (slotIndex % 2 === 0 ? 0.38 : 0.18)).toFixed(2));
|
||||
}
|
||||
|
||||
function getCompanionStrikeOffset(companions: CompanionState[], npcId: string) {
|
||||
const slotIndex = getCompanionSlotIndex(companions, npcId);
|
||||
return slotIndex % 2 === 0 ? 54 : 44;
|
||||
}
|
||||
|
||||
function getLivingPartyTargets(state: GameState) {
|
||||
const targets: Array<{ kind: 'player' } | { kind: 'companion'; npcId: string }> = [];
|
||||
if (state.playerHp > 0) {
|
||||
targets.push({ kind: 'player' });
|
||||
}
|
||||
if (state.currentNpcBattleMode === 'spar') {
|
||||
return targets;
|
||||
}
|
||||
state.companions.filter(isCompanionAlive).forEach(companion => {
|
||||
targets.push({ kind: 'companion', npcId: companion.npcId });
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
function chooseRandomPartyTarget(state: GameState) {
|
||||
const targets = getLivingPartyTargets(state);
|
||||
if (targets.length === 0) return null;
|
||||
return targets[Math.floor(Math.random() * targets.length)] ?? null;
|
||||
}
|
||||
|
||||
function getCombatActorKey(actor: TurnActor, id?: string) {
|
||||
return id ? `${actor}:${id}` : actor;
|
||||
}
|
||||
|
||||
function buildCombatTurnOrder(
|
||||
state: GameState,
|
||||
playerCharacter: Character,
|
||||
sequenceMs: number,
|
||||
turnVisualMs: number,
|
||||
resetStageMs: number,
|
||||
minTurnCount: number,
|
||||
) {
|
||||
const actorTimings = new Map<string, { actor: TurnActor; id?: string; nextAt: number; cadence: number }>();
|
||||
|
||||
actorTimings.set(getCombatActorKey('player'), {
|
||||
actor: 'player',
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max((resolveCharacterAttributeProfile(playerCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 1),
|
||||
});
|
||||
|
||||
state.companions
|
||||
.filter(companion => state.currentNpcBattleMode !== 'spar' && isCompanionAlive(companion))
|
||||
.forEach(companion => {
|
||||
const companionCharacter = getCharacterById(companion.characterId);
|
||||
if (!companionCharacter) return;
|
||||
actorTimings.set(getCombatActorKey('companion', companion.npcId), {
|
||||
actor: 'companion',
|
||||
id: companion.npcId,
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max((resolveCharacterAttributeProfile(companionCharacter, state.worldType, state.customWorldProfile)?.values.axis_b ?? 48) / 12, 1),
|
||||
});
|
||||
});
|
||||
|
||||
state.sceneMonsters.forEach(monster => {
|
||||
actorTimings.set(getCombatActorKey('monster', monster.id), {
|
||||
actor: 'monster',
|
||||
id: monster.id,
|
||||
nextAt: 0,
|
||||
cadence: 1400 / Math.max(monster.speed, 1),
|
||||
});
|
||||
});
|
||||
|
||||
const turnOrder: Array<{ actor: TurnActor; id?: string }> = [];
|
||||
|
||||
while (turnOrder.length < minTurnCount || turnOrder.length * (turnVisualMs + resetStageMs) < sequenceMs) {
|
||||
const availableActors = [...actorTimings.values()].filter(item => {
|
||||
if (item.actor === 'player') return state.playerHp > 0;
|
||||
if (item.actor === 'companion') {
|
||||
return state.companions.some(companion => companion.npcId === item.id && isCompanionAlive(companion));
|
||||
}
|
||||
return state.sceneMonsters.some(monster => monster.id === item.id && monster.hp > 0);
|
||||
});
|
||||
|
||||
if (availableActors.length === 0) break;
|
||||
|
||||
availableActors.sort((a, b) => a.nextAt - b.nextAt || a.cadence - b.cadence);
|
||||
const nextActor = availableActors[0];
|
||||
if (!nextActor) break;
|
||||
turnOrder.push({ actor: nextActor.actor, id: nextActor.id });
|
||||
nextActor.nextAt += nextActor.cadence;
|
||||
}
|
||||
|
||||
return turnOrder;
|
||||
}
|
||||
|
||||
export function applyDamageToPartyTarget(
|
||||
state: GameState,
|
||||
target: { kind: 'player' } | { kind: 'companion'; npcId: string },
|
||||
damage: number,
|
||||
) {
|
||||
if (target.kind === 'player') {
|
||||
const adjustedDamage = Math.max(
|
||||
1,
|
||||
Math.round(damage * getEquipmentBonuses(state.playerEquipment).incomingDamageMultiplier),
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerHp: Math.max(0, state.playerHp - adjustedDamage),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: updateCompanionState(
|
||||
state.companions,
|
||||
target.npcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
hp: Math.max(0, companion.hp - damage),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function tickSkillCooldowns(character: Character, cooldowns: Record<string, number>) {
|
||||
const normalized = normalizeCooldowns(character, cooldowns);
|
||||
return Object.fromEntries(
|
||||
Object.entries(normalized).map(([skillId, turns]) => [skillId, Math.max(0, turns - 1)]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getFacingForPlayer(playerX: number, monster: SceneMonster | null) {
|
||||
if (!monster) return 'right' as const;
|
||||
return monster.xMeters >= playerX ? 'right' : 'left';
|
||||
}
|
||||
|
||||
export function getMeleeStrikeX(attackerX: number, defenderX: number) {
|
||||
return defenderX > attackerX
|
||||
? Number((defenderX - 0.1).toFixed(1))
|
||||
: Number((defenderX + 0.1).toFixed(1));
|
||||
}
|
||||
|
||||
export function getSkillStrikeX(skill: CharacterSkillDefinition, attackerX: number, defenderX: number) {
|
||||
return getSkillDelivery(skill) === 'ranged'
|
||||
? attackerX
|
||||
: getMeleeStrikeX(attackerX, defenderX);
|
||||
}
|
||||
|
||||
export function resetCombatPresentation(monsters: SceneMonster[], playerX: number) {
|
||||
return settleMonsterAnimations(monsters).map(monster => ({
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyRecoveryEffectToState(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
functionId: string,
|
||||
) {
|
||||
const effect = getFunctionEffect(functionId);
|
||||
if (
|
||||
(effect.healAmount ?? 0) <= 0 &&
|
||||
(effect.manaRestore ?? 0) <= 0 &&
|
||||
(effect.cooldownTickBonus ?? 0) <= 0
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
let cooldowns = state.playerSkillCooldowns;
|
||||
|
||||
for (let index = 0; index < (effect.cooldownTickBonus ?? 0); index += 1) {
|
||||
cooldowns = tickSkillCooldowns(character, cooldowns);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerHp: Math.min(state.playerMaxHp, state.playerHp + (effect.healAmount ?? 0)),
|
||||
playerMana: Math.min(state.playerMaxMana, state.playerMana + (effect.manaRestore ?? 0)),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBattlePlan({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
totalSequenceMs,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
minTurnCount,
|
||||
}: {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
totalSequenceMs: number;
|
||||
turnVisualMs: number;
|
||||
resetStageMs: number;
|
||||
minTurnCount: number;
|
||||
}): BattlePlan {
|
||||
const targetMonster = getClosestMonster(state.playerX, state.sceneMonsters);
|
||||
if (!targetMonster) {
|
||||
return {
|
||||
preparedState: state,
|
||||
turns: [],
|
||||
finalState: {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneMonsters: [],
|
||||
companions: resetCompanionCombatPresentation(state.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const functionEffect = getFunctionEffect(option.functionId);
|
||||
const isNpcSpar = state.currentNpcBattleMode === 'spar';
|
||||
const sequenceMs = Math.round(totalSequenceMs * (functionEffect.turnTimeMultiplier ?? 1));
|
||||
const turnOrder = buildCombatTurnOrder(state, character, sequenceMs, turnVisualMs, resetStageMs, minTurnCount);
|
||||
const normalizedOption = normalizeSkillProbabilities(option, character);
|
||||
const npcBattleResources = new Map<string, {
|
||||
character: Character;
|
||||
mana: number;
|
||||
cooldowns: Record<string, number>;
|
||||
}>();
|
||||
|
||||
state.sceneMonsters.forEach(monster => {
|
||||
const npcCharacterId = monster.encounter?.characterId ?? null;
|
||||
const npcCharacter = npcCharacterId ? getCharacterById(npcCharacterId) : null;
|
||||
if (!npcCharacter) return;
|
||||
|
||||
npcBattleResources.set(monster.id, {
|
||||
character: npcCharacter,
|
||||
mana: getCharacterMaxMana(npcCharacter),
|
||||
cooldowns: createEmptyCooldowns(npcCharacter),
|
||||
});
|
||||
});
|
||||
|
||||
let simulatedState: GameState = {
|
||||
...applyRecoveryEffectToState(state, character, option.functionId),
|
||||
companions: resetCompanionCombatPresentation(state.companions),
|
||||
sceneMonsters: resetCombatPresentation(state.sceneMonsters, state.playerX),
|
||||
activeCombatEffects: [],
|
||||
playerActionMode: 'idle' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
};
|
||||
const preparedState = simulatedState;
|
||||
const turns: BattlePlanStep[] = [];
|
||||
|
||||
for (const turn of turnOrder) {
|
||||
const currentTarget = getClosestMonster(simulatedState.playerX, simulatedState.sceneMonsters);
|
||||
if (!currentTarget) break;
|
||||
|
||||
if (turn.actor === 'player') {
|
||||
if (simulatedState.playerHp <= 0) continue;
|
||||
|
||||
const cooledDown = tickSkillCooldowns(character, simulatedState.playerSkillCooldowns);
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
playerSkillCooldowns: cooledDown,
|
||||
};
|
||||
|
||||
const selectedSkill = chooseWeightedSkill(character, simulatedState.playerMana, cooledDown, normalizedOption);
|
||||
if (!selectedSkill) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalPlayerX = simulatedState.playerX;
|
||||
const delivery = getSkillDelivery(selectedSkill);
|
||||
const strikeX = getSkillStrikeX(selectedSkill, originalPlayerX, currentTarget.xMeters);
|
||||
const appliedCooldowns = {
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
};
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolvePlayerOutgoingDamage(
|
||||
simulatedState,
|
||||
character,
|
||||
selectedSkill.damage,
|
||||
functionEffect.damageMultiplier ?? 1,
|
||||
);
|
||||
const wouldEndSpar = isNpcSpar && currentTarget.hp - damage <= 1;
|
||||
|
||||
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
|
||||
monster.id === currentTarget.id
|
||||
? {
|
||||
...monster,
|
||||
hp: isNpcSpar
|
||||
? Math.max(1, monster.hp - damage)
|
||||
: Math.max(0, monster.hp - damage),
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === currentTarget.id && monster.hp <= 0);
|
||||
const remainingMonsters = defeated
|
||||
? resolvedMonsters.filter(monster => !(monster.id === currentTarget.id && monster.hp <= 0))
|
||||
: resolvedMonsters;
|
||||
const nextTarget = getClosestMonster(originalPlayerX, remainingMonsters);
|
||||
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
playerX: originalPlayerX,
|
||||
playerFacing: getFacingForPlayer(originalPlayerX, nextTarget ?? null),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
tickBuildBuffs(simulatedState.activeBuildBuffs),
|
||||
selectedSkill.buildBuffs,
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
playerMana: Math.max(0, simulatedState.playerMana - selectedSkill.manaCost),
|
||||
playerSkillCooldowns: appliedCooldowns,
|
||||
sceneMonsters: remainingMonsters.map(monster => ({
|
||||
...monster,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
companions: resetCompanionCombatPresentation(simulatedState.companions),
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0,
|
||||
currentNpcBattleOutcome: wouldEndSpar
|
||||
? 'spar_complete'
|
||||
: (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
|
||||
? 'fight_victory'
|
||||
: simulatedState.currentNpcBattleOutcome),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'player',
|
||||
targetHostileNpcId: currentTarget.id,
|
||||
originalPlayerX,
|
||||
strikeX,
|
||||
cooledDown,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
appliedCooldowns,
|
||||
damage,
|
||||
defeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery,
|
||||
});
|
||||
|
||||
if (!simulatedState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (turn.actor === 'companion') {
|
||||
const companion = simulatedState.companions.find(item => item.npcId === turn.id && isCompanionAlive(item));
|
||||
if (!companion) continue;
|
||||
|
||||
const companionCharacter = getCharacterById(companion.characterId);
|
||||
if (!companionCharacter) continue;
|
||||
|
||||
const companionX = getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, companion.npcId);
|
||||
const targetMonster = getClosestMonster(companionX, simulatedState.sceneMonsters);
|
||||
if (!targetMonster) break;
|
||||
|
||||
const cooledDown = tickSkillCooldowns(companionCharacter, companion.skillCooldowns);
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
companions: updateCompanionState(
|
||||
simulatedState.companions,
|
||||
companion.npcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
skillCooldowns: cooledDown,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const selectedSkill = chooseWeightedSkillForStyle(
|
||||
companionCharacter,
|
||||
companion.mana,
|
||||
cooledDown,
|
||||
inferCombatStyle(option),
|
||||
);
|
||||
if (!selectedSkill) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const delivery = getSkillDelivery(selectedSkill);
|
||||
const strikeOffsetX = delivery === 'melee'
|
||||
? getCompanionStrikeOffset(simulatedState.companions, companion.npcId)
|
||||
: 0;
|
||||
const appliedCooldowns = {
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
};
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolveCompanionOutgoingDamage(
|
||||
companionCharacter,
|
||||
selectedSkill.damage,
|
||||
functionEffect.damageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
);
|
||||
const wouldEndSpar = isNpcSpar && targetMonster.hp - damage <= 1;
|
||||
|
||||
const resolvedMonsters = simulatedState.sceneMonsters.map(monster =>
|
||||
monster.id === targetMonster.id
|
||||
? {
|
||||
...monster,
|
||||
hp: isNpcSpar
|
||||
? Math.max(1, monster.hp - damage)
|
||||
: Math.max(0, monster.hp - damage),
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
const defeated = !isNpcSpar && resolvedMonsters.some(monster => monster.id === targetMonster.id && monster.hp <= 0);
|
||||
const remainingMonsters = defeated
|
||||
? resolvedMonsters.filter(monster => !(monster.id === targetMonster.id && monster.hp <= 0))
|
||||
: resolvedMonsters;
|
||||
|
||||
simulatedState = {
|
||||
...simulatedState,
|
||||
companions: updateCompanionState(
|
||||
resetCompanionCombatPresentation(simulatedState.companions),
|
||||
companion.npcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
skillCooldowns: appliedCooldowns,
|
||||
}),
|
||||
),
|
||||
sceneMonsters: remainingMonsters.map(monster => ({
|
||||
...monster,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : remainingMonsters.length > 0,
|
||||
currentNpcBattleOutcome: wouldEndSpar
|
||||
? 'spar_complete'
|
||||
: (!isNpcSpar && remainingMonsters.length === 0 && simulatedState.currentBattleNpcId
|
||||
? 'fight_victory'
|
||||
: simulatedState.currentNpcBattleOutcome),
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'companion',
|
||||
companionNpcId: companion.npcId,
|
||||
targetHostileNpcId: targetMonster.id,
|
||||
strikeOffsetX,
|
||||
cooledDown,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
appliedCooldowns,
|
||||
damage,
|
||||
defeated,
|
||||
endsBattle: wouldEndSpar,
|
||||
delivery,
|
||||
});
|
||||
|
||||
if (!simulatedState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const actingMonster = simulatedState.sceneMonsters.find(monster => monster.id === turn.id && monster.hp > 0);
|
||||
if (!actingMonster) continue;
|
||||
|
||||
const randomTarget = chooseRandomPartyTarget(simulatedState);
|
||||
if (!randomTarget) break;
|
||||
|
||||
const originalMonsterX = actingMonster.xMeters;
|
||||
const targetX = randomTarget.kind === 'player'
|
||||
? simulatedState.playerX
|
||||
: getCompanionAnchorX(simulatedState.playerX, simulatedState.companions, randomTarget.npcId);
|
||||
const npcCombatant = npcBattleResources.get(actingMonster.id);
|
||||
|
||||
if (npcCombatant) {
|
||||
const cooledDown = tickSkillCooldowns(npcCombatant.character, npcCombatant.cooldowns);
|
||||
const selectedSkill = chooseWeightedSkillForStyle(
|
||||
npcCombatant.character,
|
||||
npcCombatant.mana,
|
||||
cooledDown,
|
||||
inferCombatStyle(option),
|
||||
);
|
||||
|
||||
npcBattleResources.set(actingMonster.id, {
|
||||
...npcCombatant,
|
||||
cooldowns: cooledDown,
|
||||
});
|
||||
|
||||
if (selectedSkill) {
|
||||
const delivery = getSkillDelivery(selectedSkill);
|
||||
const strikeX = getSkillStrikeX(selectedSkill, originalMonsterX, targetX);
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolveCompanionOutgoingDamage(
|
||||
npcCombatant.character,
|
||||
selectedSkill.damage,
|
||||
functionEffect.incomingDamageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
);
|
||||
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
|
||||
|
||||
npcBattleResources.set(actingMonster.id, {
|
||||
character: npcCombatant.character,
|
||||
mana: npcCombatant.mana,
|
||||
cooldowns: {
|
||||
...cooledDown,
|
||||
[selectedSkill.id]: selectedSkill.cooldownTurns,
|
||||
},
|
||||
});
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
|
||||
simulatedState = {
|
||||
...damagedState,
|
||||
companions: resetCompanionCombatPresentation(damagedState.companions),
|
||||
sceneMonsters: simulatedState.sceneMonsters.map(monster => ({
|
||||
...monster,
|
||||
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(
|
||||
monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
simulatedState.playerX,
|
||||
),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
playerHp: isNpcSpar && randomTarget.kind === 'player'
|
||||
? Math.max(1, damagedState.playerHp)
|
||||
: damagedState.playerHp,
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle,
|
||||
currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome,
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'monster',
|
||||
monsterId: actingMonster.id,
|
||||
originalMonsterX,
|
||||
strikeX,
|
||||
target: randomTarget.kind,
|
||||
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
|
||||
targetX,
|
||||
damage,
|
||||
endsBattle: wouldEndSpar,
|
||||
selectedSkillId: selectedSkill.id,
|
||||
npcCharacterId: npcCombatant.character.id,
|
||||
delivery,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const strikeX = getMeleeStrikeX(originalMonsterX, targetX);
|
||||
const damage = isNpcSpar
|
||||
? 1
|
||||
: resolveMonsterOutgoingDamage(
|
||||
actingMonster,
|
||||
9,
|
||||
functionEffect.incomingDamageMultiplier ?? 1,
|
||||
state.worldType,
|
||||
state.customWorldProfile,
|
||||
);
|
||||
const wouldEndSpar = isNpcSpar && randomTarget.kind === 'player' && simulatedState.playerHp - damage <= 1;
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(simulatedState, randomTarget, damage);
|
||||
simulatedState = {
|
||||
...damagedState,
|
||||
companions: resetCompanionCombatPresentation(damagedState.companions),
|
||||
sceneMonsters: simulatedState.sceneMonsters.map(monster => ({
|
||||
...monster,
|
||||
xMeters: monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(
|
||||
monster.id === actingMonster.id ? originalMonsterX : monster.xMeters,
|
||||
simulatedState.playerX,
|
||||
),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
})),
|
||||
playerHp: isNpcSpar && randomTarget.kind === 'player'
|
||||
? Math.max(1, damagedState.playerHp)
|
||||
: damagedState.playerHp,
|
||||
inBattle: isNpcSpar ? !wouldEndSpar : damagedState.inBattle,
|
||||
currentNpcBattleOutcome: wouldEndSpar ? 'spar_complete' : simulatedState.currentNpcBattleOutcome,
|
||||
};
|
||||
|
||||
turns.push({
|
||||
actor: 'monster',
|
||||
monsterId: actingMonster.id,
|
||||
originalMonsterX,
|
||||
strikeX,
|
||||
target: randomTarget.kind,
|
||||
targetCompanionNpcId: randomTarget.kind === 'companion' ? randomTarget.npcId : undefined,
|
||||
targetX,
|
||||
damage,
|
||||
endsBattle: wouldEndSpar,
|
||||
selectedSkillId: null,
|
||||
npcCharacterId: null,
|
||||
delivery: 'melee',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
preparedState,
|
||||
turns,
|
||||
finalState: {
|
||||
...simulatedState,
|
||||
companions: resetCompanionCombatPresentation(simulatedState.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: simulatedState.currentNpcBattleOutcome === 'spar_complete'
|
||||
? false
|
||||
: simulatedState.sceneMonsters.length > 0,
|
||||
sceneMonsters: resetCombatPresentation(simulatedState.sceneMonsters, simulatedState.playerX),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
188
src/hooks/combat/escapeFlow.test.ts
Normal file
188
src/hooks/combat/escapeFlow.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../data/stateFunctions', () => ({
|
||||
getFunctionEffect: () => ({
|
||||
escapeDistance: 5,
|
||||
escapeDurationMs: 5000,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type SceneMonster,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildEscapeAfterSequence,
|
||||
playEscapeSequenceWithStorySync,
|
||||
} from './escapeFlow';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createMonster(): SceneMonster {
|
||||
return {
|
||||
id: 'wolf',
|
||||
name: 'Wolf',
|
||||
action: 'Growls',
|
||||
description: 'A test wolf.',
|
||||
animation: 'idle',
|
||||
xMeters: 3.5,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.2,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: {
|
||||
id: 'npc-1',
|
||||
kind: 'npc',
|
||||
npcName: 'Bandit',
|
||||
npcDescription: 'A bandit',
|
||||
npcAvatar: 'B',
|
||||
context: 'bandit',
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [createMonster()],
|
||||
playerX: 0.2,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-1',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createEscapeOption(): StoryOption {
|
||||
return {
|
||||
functionId: 'battle_escape_breakout',
|
||||
actionText: 'Run',
|
||||
text: 'Run',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: -0.6,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'left',
|
||||
scrollWorld: true,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('escapeFlow', () => {
|
||||
it('builds a deterministic escape state without playback timing state', () => {
|
||||
const state = createState();
|
||||
const resolved = buildEscapeAfterSequence(state, createEscapeOption());
|
||||
|
||||
expect(resolved.inBattle).toBe(false);
|
||||
expect(resolved.currentEncounter).toBeNull();
|
||||
expect(resolved.currentBattleNpcId).toBeNull();
|
||||
expect(resolved.playerFacing).toBe('right');
|
||||
expect(resolved.scrollWorld).toBe(false);
|
||||
expect(resolved.playerX).toBeLessThan(0.2);
|
||||
});
|
||||
|
||||
it('waits for the story response before settling the escape presentation', async () => {
|
||||
const state = createState();
|
||||
const option = createEscapeOption();
|
||||
const finalState = buildEscapeAfterSequence(state, option);
|
||||
const committedStates: GameState[] = [];
|
||||
let sleepCalls = 0;
|
||||
let resolveStoryResponse!: () => void;
|
||||
const waitForStoryResponse = new Promise<void>(resolve => {
|
||||
resolveStoryResponse = resolve;
|
||||
});
|
||||
|
||||
const result = await playEscapeSequenceWithStorySync({
|
||||
setGameState: (nextState: GameState) => {
|
||||
committedStates.push(nextState);
|
||||
},
|
||||
state,
|
||||
option,
|
||||
finalState,
|
||||
sync: { waitForStoryResponse },
|
||||
sleepMs: async () => {
|
||||
sleepCalls += 1;
|
||||
if (sleepCalls === 22) {
|
||||
resolveStoryResponse();
|
||||
}
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
expect(sleepCalls).toBeGreaterThan(21);
|
||||
expect(committedStates[0]?.animationState).toBe(AnimationState.RUN);
|
||||
expect(committedStates.at(-1)?.playerFacing).toBe('right');
|
||||
expect(result.scrollWorld).toBe(false);
|
||||
expect(result.playerFacing).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
150
src/hooks/combat/escapeFlow.ts
Normal file
150
src/hooks/combat/escapeFlow.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
getFacingTowardPlayer,
|
||||
settleMonsterAnimations,
|
||||
} from '../../data/hostileNpcs';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import {
|
||||
AnimationState,
|
||||
type GameState,
|
||||
type SceneMonster,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
const ESCAPE_RUN_MS = 5000;
|
||||
const ESCAPE_TICK_MS = 250;
|
||||
const ESCAPE_TURN_PAUSE_MS = 180;
|
||||
|
||||
export type EscapePlaybackSync = {
|
||||
waitForStoryResponse?: Promise<void>;
|
||||
};
|
||||
|
||||
type SetGameStateFn = Dispatch<SetStateAction<GameState>> | ((state: GameState) => void);
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function resetCombatPresentation(monsters: SceneMonster[], playerX: number) {
|
||||
return settleMonsterAnimations(monsters).map(monster => ({
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEscapeSettlePlayerX(state: GameState, option: StoryOption) {
|
||||
const escapeDistance = getFunctionEffect(option.functionId).escapeDistance ?? 5;
|
||||
const settleOffset = Math.max(1, Math.min(1.4, escapeDistance * 0.24));
|
||||
return Number(Math.max(-1.6, Math.min(state.playerX - settleOffset, -1)).toFixed(2));
|
||||
}
|
||||
|
||||
export function buildEscapeAfterSequence(
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) {
|
||||
const escapePlayerX = getEscapeSettlePlayerX(state, option);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sceneMonsters: resetCombatPresentation(state.sceneMonsters, escapePlayerX),
|
||||
playerX: escapePlayerX,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
export async function playEscapeSequenceWithStorySync(params: {
|
||||
setGameState: SetGameStateFn;
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
finalState: GameState;
|
||||
sync?: EscapePlaybackSync;
|
||||
sleepMs?: (ms: number) => Promise<void>;
|
||||
}) {
|
||||
const {
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
finalState,
|
||||
sync,
|
||||
sleepMs = sleep,
|
||||
} = params;
|
||||
let currentState = state;
|
||||
const functionEffect = getFunctionEffect(option.functionId);
|
||||
const escapeDurationMs = functionEffect.escapeDurationMs ?? ESCAPE_RUN_MS;
|
||||
const escapeDistance = functionEffect.escapeDistance ?? 5;
|
||||
const runTicks = Math.max(1, Math.ceil(escapeDurationMs / ESCAPE_TICK_MS));
|
||||
const playerStep = Number((-escapeDistance / runTicks).toFixed(2));
|
||||
const settlePlayerX = finalState.playerX;
|
||||
let storyResponseReady = !sync?.waitForStoryResponse;
|
||||
let elapsedMs = 0;
|
||||
|
||||
void sync?.waitForStoryResponse?.then(() => {
|
||||
storyResponseReady = true;
|
||||
});
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, currentState.playerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
while (elapsedMs < escapeDurationMs || !storyResponseReady) {
|
||||
const nextPlayerX = Number((currentState.playerX + playerStep).toFixed(2));
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: nextPlayerX,
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, nextPlayerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
elapsedMs += ESCAPE_TICK_MS;
|
||||
await sleepMs(ESCAPE_TICK_MS);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...finalState,
|
||||
playerX: settlePlayerX,
|
||||
playerFacing: 'left',
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
sceneMonsters: resetCombatPresentation(finalState.sceneMonsters, settlePlayerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleepMs(ESCAPE_TURN_PAUSE_MS);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerFacing: 'right',
|
||||
sceneMonsters: resetCombatPresentation(currentState.sceneMonsters, settlePlayerX),
|
||||
};
|
||||
setGameState(currentState);
|
||||
return currentState;
|
||||
}
|
||||
743
src/hooks/combat/playback.ts
Normal file
743
src/hooks/combat/playback.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
appendBuildBuffs,
|
||||
tickBuildBuffs,
|
||||
} from '../../data/buildDamage';
|
||||
import {
|
||||
getCharacterAnimationDurationMs,
|
||||
getSkillCasterAnimation,
|
||||
} from '../../data/characterCombat';
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
getClosestMonster,
|
||||
getFacingTowardPlayer,
|
||||
MONSTERS_BY_WORLD,
|
||||
} from '../../data/hostileNpcs';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CharacterSkillDefinition,
|
||||
type GameState,
|
||||
type SceneMonster,
|
||||
type StoryOption,
|
||||
type WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
classifyCombatOption,
|
||||
} from '../combatStoryUtils';
|
||||
import { playIdleSequence } from '../idleAdventureFlow';
|
||||
import {
|
||||
applyDamageToPartyTarget,
|
||||
applyRecoveryEffectToState,
|
||||
type BattlePlan,
|
||||
getFacingForPlayer,
|
||||
resetCompanionCombatPresentation,
|
||||
updateCompanionState,
|
||||
} from './battlePlan';
|
||||
import {
|
||||
type EscapePlaybackSync,
|
||||
playEscapeSequenceWithStorySync as playEscapeSequenceWithStorySyncFromEngine,
|
||||
} from './escapeFlow';
|
||||
import type { ResolvedChoiceState } from './resolvedChoice';
|
||||
import {
|
||||
buildSkillEffects,
|
||||
getSkillReleaseDelayMs,
|
||||
} from './skillEffects';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getSkillById(character: Character, skillId: string) {
|
||||
return character.skills.find(skill => skill.id === skillId) ?? null;
|
||||
}
|
||||
|
||||
function getMonsterAnimationDurationMs(
|
||||
worldType: WorldType | null,
|
||||
monsterId: string,
|
||||
animation: 'idle' | 'move' | 'attack' | 'die',
|
||||
turnVisualMs: number,
|
||||
) {
|
||||
if (!worldType) return turnVisualMs;
|
||||
const config = MONSTERS_BY_WORLD[worldType].find(monster => monster.id === monsterId);
|
||||
const clip = config?.animations[animation];
|
||||
if (!clip) return turnVisualMs;
|
||||
|
||||
const fps = clip.fps ?? 12;
|
||||
const stepCount = Math.max(1, clip.frames - 1);
|
||||
return Math.max(turnVisualMs, Math.ceil((stepCount * 1000) / fps + 120));
|
||||
}
|
||||
|
||||
function buildVisibleMonsterChanges(option: StoryOption, monsters: SceneMonster[]) {
|
||||
if (option.visuals.monsterChanges.length > 0) return option.visuals.monsterChanges;
|
||||
|
||||
const fallbackMonster = monsters[0];
|
||||
if (!fallbackMonster) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: fallbackMonster.id,
|
||||
action: classifyCombatOption(option) === 'escape'
|
||||
? `${fallbackMonster.name}立刻追了上来`
|
||||
: `${fallbackMonster.name}振身迎击玩家的进攻`,
|
||||
animation: classifyCombatOption(option) === 'escape' ? 'move' as const : 'attack' as const,
|
||||
moveMeters: classifyCombatOption(option) === 'escape' ? -0.2 : 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type CombatPlaybackParams = {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
turnVisualMs: number;
|
||||
resetStageMs: number;
|
||||
};
|
||||
|
||||
async function playSkillEffects(params: {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
currentState: GameState;
|
||||
attacker: {
|
||||
character: Character;
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
facing: 'left' | 'right';
|
||||
monsterId?: string;
|
||||
};
|
||||
target: {
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
monsterId?: string;
|
||||
};
|
||||
skill: CharacterSkillDefinition;
|
||||
}) {
|
||||
const {
|
||||
setGameState,
|
||||
attacker,
|
||||
target,
|
||||
skill,
|
||||
} = params;
|
||||
let currentState = params.currentState;
|
||||
const phases = buildSkillEffects(attacker, target, skill);
|
||||
|
||||
if (phases.cast.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: phases.cast,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(phases.castDurationMs);
|
||||
}
|
||||
|
||||
if (phases.travel.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: phases.travel,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(phases.travelDurationMs);
|
||||
}
|
||||
|
||||
if (phases.impact.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: phases.impact,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(phases.impactDurationMs);
|
||||
}
|
||||
|
||||
if (phases.cast.length > 0 || phases.travel.length > 0 || phases.impact.length > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
async function playBattleSequence(params: CombatPlaybackParams & {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
plan: BattlePlan;
|
||||
}) {
|
||||
const {
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
plan,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
} = params;
|
||||
let currentState = state;
|
||||
|
||||
if (plan.preparedState !== state) {
|
||||
currentState = plan.preparedState;
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
}
|
||||
|
||||
for (const step of plan.turns) {
|
||||
if (step.actor === 'player') {
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerSkillCooldowns: step.cooledDown,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
const skill = step.selectedSkillId ? getSkillById(character, step.selectedSkillId) : null;
|
||||
if (!skill) {
|
||||
await sleep(resetStageMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTarget = currentState.sceneMonsters.find(monster => monster.id === step.targetHostileNpcId);
|
||||
if (!currentTarget) {
|
||||
break;
|
||||
}
|
||||
|
||||
const casterAnimation = getSkillCasterAnimation(skill);
|
||||
const releaseDelay = (skill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(character, skill)
|
||||
: getCharacterAnimationDurationMs(character, casterAnimation);
|
||||
const shouldDashIntoMelee =
|
||||
step.delivery === 'melee' && Math.abs(step.strikeX - step.originalPlayerX) > 0.01;
|
||||
|
||||
if (shouldDashIntoMelee) {
|
||||
const dashDuration = Math.max(120, getCharacterAnimationDurationMs(character, AnimationState.DASH));
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: step.strikeX,
|
||||
playerFacing: getFacingForPlayer(step.strikeX, currentTarget),
|
||||
animationState: AnimationState.DASH,
|
||||
playerActionMode: 'melee',
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(dashDuration);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: step.strikeX,
|
||||
playerFacing: getFacingForPlayer(step.strikeX, currentTarget),
|
||||
animationState: casterAnimation,
|
||||
playerActionMode: step.delivery,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
tickBuildBuffs(currentState.activeBuildBuffs),
|
||||
skill.buildBuffs,
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
playerMana: currentState.playerMana,
|
||||
playerSkillCooldowns: step.appliedCooldowns,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(releaseDelay);
|
||||
|
||||
currentState = await playSkillEffects({
|
||||
setGameState,
|
||||
currentState,
|
||||
attacker: {
|
||||
character,
|
||||
xMeters: step.strikeX,
|
||||
origin: 'player',
|
||||
facing: currentState.playerFacing,
|
||||
},
|
||||
target: {
|
||||
xMeters: currentTarget.xMeters,
|
||||
origin: 'monster',
|
||||
monsterId: currentTarget.id,
|
||||
},
|
||||
skill,
|
||||
});
|
||||
|
||||
const resolvedMonsters = currentState.sceneMonsters.map(monster =>
|
||||
monster.id === step.targetHostileNpcId
|
||||
? {
|
||||
...monster,
|
||||
hp: Math.max(0, monster.hp - step.damage),
|
||||
action: step.defeated
|
||||
? `${monster.name}被${skill.name}击败了`
|
||||
: `${monster.name}受到了${skill.name}的打击`,
|
||||
animation: step.defeated ? ('die' as const) : monster.animation,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: resolvedMonsters,
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.defeated) {
|
||||
await sleep(getMonsterAnimationDurationMs(currentState.worldType, step.targetHostileNpcId, 'die', turnVisualMs));
|
||||
const remainingMonsters = resolvedMonsters.filter(
|
||||
monster => !(monster.id === step.targetHostileNpcId && monster.hp <= 0),
|
||||
);
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: remainingMonsters,
|
||||
inBattle: remainingMonsters.length > 0,
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextTarget = getClosestMonster(step.originalPlayerX, currentState.sceneMonsters);
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: step.originalPlayerX,
|
||||
playerFacing: getFacingForPlayer(step.originalPlayerX, nextTarget ?? null),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
if (!currentState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.actor === 'companion') {
|
||||
const companion = currentState.companions.find(item => item.npcId === step.companionNpcId);
|
||||
if (!companion || companion.hp <= 0) continue;
|
||||
|
||||
const companionCharacter = getCharacterById(companion.characterId);
|
||||
const currentTarget = currentState.sceneMonsters.find(monster => monster.id === step.targetHostileNpcId);
|
||||
const skill = companionCharacter && step.selectedSkillId ? getSkillById(companionCharacter, step.selectedSkillId) : null;
|
||||
if (!companionCharacter || !currentTarget || !skill) {
|
||||
await sleep(resetStageMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dashAnimation = companionCharacter.animationMap?.[AnimationState.DASH]
|
||||
? AnimationState.DASH
|
||||
: AnimationState.RUN;
|
||||
const dashDuration = Math.max(120, getCharacterAnimationDurationMs(companionCharacter, dashAnimation));
|
||||
const casterAnimation = getSkillCasterAnimation(skill);
|
||||
const releaseDelay = (skill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(companionCharacter, skill)
|
||||
: getCharacterAnimationDurationMs(companionCharacter, casterAnimation);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: updateCompanionState(
|
||||
resetCompanionCombatPresentation(currentState.companions),
|
||||
step.companionNpcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
skillCooldowns: step.cooledDown,
|
||||
}),
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.delivery === 'melee' && step.strikeOffsetX > 0) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: updateCompanionState(
|
||||
currentState.companions,
|
||||
step.companionNpcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
animationState: dashAnimation,
|
||||
actionMode: 'melee',
|
||||
offsetX: step.strikeOffsetX,
|
||||
transitionMs: dashDuration,
|
||||
}),
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(dashDuration);
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: updateCompanionState(
|
||||
currentState.companions,
|
||||
step.companionNpcId,
|
||||
currentCompanion => ({
|
||||
...currentCompanion,
|
||||
animationState: casterAnimation,
|
||||
actionMode: step.delivery,
|
||||
offsetX: step.delivery === 'melee' ? step.strikeOffsetX : 0,
|
||||
transitionMs: 0,
|
||||
mana: currentCompanion.mana,
|
||||
skillCooldowns: step.appliedCooldowns,
|
||||
}),
|
||||
),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(releaseDelay);
|
||||
|
||||
const resolvedMonsters = currentState.sceneMonsters.map(monster =>
|
||||
monster.id === step.targetHostileNpcId
|
||||
? {
|
||||
...monster,
|
||||
hp: Math.max(0, monster.hp - step.damage),
|
||||
action: step.defeated
|
||||
? `${monster.name}被${companionCharacter.name}当场击败了`
|
||||
: `${monster.name}被${companionCharacter.name}逼退了半步`,
|
||||
animation: step.defeated ? ('die' as const) : monster.animation,
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: monster,
|
||||
);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: resolvedMonsters,
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.defeated) {
|
||||
await sleep(getMonsterAnimationDurationMs(currentState.worldType, step.targetHostileNpcId, 'die', turnVisualMs));
|
||||
const remainingMonsters = resolvedMonsters.filter(
|
||||
monster => !(monster.id === step.targetHostileNpcId && monster.hp <= 0),
|
||||
);
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: remainingMonsters,
|
||||
inBattle: remainingMonsters.length > 0,
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
if (!currentState.inBattle) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const actingMonster = currentState.sceneMonsters.find(monster => monster.id === step.monsterId);
|
||||
if (!actingMonster) continue;
|
||||
|
||||
const npcCharacter = step.npcCharacterId ? getCharacterById(step.npcCharacterId) : null;
|
||||
const npcSkill = npcCharacter && step.selectedSkillId ? getSkillById(npcCharacter, step.selectedSkillId) : null;
|
||||
|
||||
if (npcCharacter && npcSkill) {
|
||||
const casterAnimation = getSkillCasterAnimation(npcSkill);
|
||||
const releaseDelay = (npcSkill.effects?.length ?? 0) > 0
|
||||
? getSkillReleaseDelayMs(npcCharacter, npcSkill)
|
||||
: getCharacterAnimationDurationMs(npcCharacter, casterAnimation);
|
||||
|
||||
const attackedMonsters = currentState.sceneMonsters.map(monster => {
|
||||
if (monster.id !== step.monsterId) {
|
||||
return {
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...monster,
|
||||
xMeters: step.strikeX,
|
||||
animation: step.delivery === 'melee' ? ('attack' as const) : ('idle' as const),
|
||||
action: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? `${monster.name}对${npcSkill.name}的目标发起突袭`
|
||||
: `${monster.name}施展了${npcSkill.name}`,
|
||||
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
|
||||
characterAnimation: casterAnimation,
|
||||
combatMode: step.delivery,
|
||||
};
|
||||
});
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: attackedMonsters,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
currentState.companions,
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
actionMode: 'idle',
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(currentState.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
playerFacing: getFacingForPlayer(
|
||||
currentState.playerX,
|
||||
attackedMonsters.find(monster => monster.id === step.monsterId) ?? actingMonster,
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(releaseDelay);
|
||||
|
||||
currentState = await playSkillEffects({
|
||||
setGameState,
|
||||
currentState,
|
||||
attacker: {
|
||||
character: npcCharacter,
|
||||
xMeters: step.strikeX,
|
||||
origin: 'monster',
|
||||
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
|
||||
monsterId: step.monsterId,
|
||||
},
|
||||
target: {
|
||||
xMeters: step.targetX,
|
||||
origin: 'player',
|
||||
},
|
||||
skill: npcSkill,
|
||||
});
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(
|
||||
currentState,
|
||||
step.target === 'player'
|
||||
? { kind: 'player' as const }
|
||||
: { kind: 'companion' as const, npcId: step.targetCompanionNpcId! },
|
||||
step.damage,
|
||||
);
|
||||
currentState = {
|
||||
...damagedState,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
resetCompanionCombatPresentation(damagedState.companions),
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(damagedState.companions),
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: attackedMonsters.map(monster =>
|
||||
monster.id === step.monsterId
|
||||
? {
|
||||
...monster,
|
||||
xMeters: step.originalMonsterX,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(step.originalMonsterX, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: {
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
},
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
};
|
||||
setGameState(currentState);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseChanges = buildVisibleMonsterChanges(option, currentState.sceneMonsters);
|
||||
const attackedMonsters = currentState.sceneMonsters.map(monster => {
|
||||
if (monster.id !== step.monsterId) {
|
||||
return {
|
||||
...monster,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
};
|
||||
}
|
||||
|
||||
const sourceChange = baseChanges.find(change => change.id === monster.id);
|
||||
return {
|
||||
...monster,
|
||||
xMeters: step.strikeX,
|
||||
animation: 'attack' as const,
|
||||
action: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? `${monster.name}閻氭稒澧ら崥鎴濇倱娴肩⒒`
|
||||
: (sourceChange?.action ?? `${monster.name}閻氭稒澧ゆ稉濠冩降`),
|
||||
facing: getFacingTowardPlayer(step.strikeX, step.targetX),
|
||||
};
|
||||
});
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: attackedMonsters,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
currentState.companions,
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
actionMode: 'idle',
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(currentState.companions),
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
playerFacing: getFacingForPlayer(
|
||||
currentState.playerX,
|
||||
attackedMonsters.find(monster => monster.id === step.monsterId) ?? actingMonster,
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(turnVisualMs);
|
||||
|
||||
const damagedState = applyDamageToPartyTarget(
|
||||
currentState,
|
||||
step.target === 'player'
|
||||
? { kind: 'player' as const }
|
||||
: { kind: 'companion' as const, npcId: step.targetCompanionNpcId! },
|
||||
step.damage,
|
||||
);
|
||||
currentState = {
|
||||
...damagedState,
|
||||
companions: step.target === 'companion' && step.targetCompanionNpcId
|
||||
? updateCompanionState(
|
||||
resetCompanionCombatPresentation(damagedState.companions),
|
||||
step.targetCompanionNpcId,
|
||||
companion => ({
|
||||
...companion,
|
||||
animationState: companion.hp > 0 ? AnimationState.HURT : AnimationState.DIE,
|
||||
}),
|
||||
)
|
||||
: resetCompanionCombatPresentation(damagedState.companions),
|
||||
inBattle: step.endsBattle ? false : currentState.inBattle,
|
||||
currentNpcBattleOutcome: step.endsBattle ? 'spar_complete' : currentState.currentNpcBattleOutcome,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
if (step.endsBattle) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
sceneMonsters: attackedMonsters.map(monster =>
|
||||
monster.id === step.monsterId
|
||||
? {
|
||||
...monster,
|
||||
xMeters: step.originalMonsterX,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(step.originalMonsterX, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
}
|
||||
: {
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
facing: getFacingTowardPlayer(monster.xMeters, currentState.playerX),
|
||||
characterAnimation: undefined,
|
||||
combatMode: undefined,
|
||||
},
|
||||
),
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(resetStageMs);
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
companions: resetCompanionCombatPresentation(currentState.companions),
|
||||
};
|
||||
setGameState(currentState);
|
||||
}
|
||||
|
||||
setGameState(plan.finalState);
|
||||
return plan.finalState;
|
||||
}
|
||||
|
||||
export function createCombatPlayback(params: CombatPlaybackParams) {
|
||||
const {
|
||||
setGameState,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
} = params;
|
||||
|
||||
const playResolvedChoice = async (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => {
|
||||
if (resolvedChoice.optionKind === 'battle') {
|
||||
return playBattleSequence({
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
plan: resolvedChoice.battlePlan!,
|
||||
turnVisualMs,
|
||||
resetStageMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedChoice.optionKind === 'escape') {
|
||||
return playEscapeSequenceWithStorySyncFromEngine({
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
finalState: resolvedChoice.afterSequence,
|
||||
sync,
|
||||
});
|
||||
}
|
||||
|
||||
return playIdleSequence({
|
||||
setGameState,
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
finalState: resolvedChoice.afterSequence,
|
||||
applyRecoveryEffectToState,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
playResolvedChoice,
|
||||
};
|
||||
}
|
||||
|
||||
235
src/hooks/combat/resolvedChoice.test.ts
Normal file
235
src/hooks/combat/resolvedChoice.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { scenes } = vi.hoisted(() => ({
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
description: 'A quiet camp.',
|
||||
imageSrc: '/camp.png',
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
name: 'Trail',
|
||||
description: 'A mountain trail.',
|
||||
imageSrc: '/trail.png',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../data/scenePresets', () => ({
|
||||
getForwardScenePreset: () => scenes[1],
|
||||
getScenePresetsByWorld: () => scenes,
|
||||
getTravelScenePreset: () => scenes[1],
|
||||
}));
|
||||
|
||||
vi.mock('../../data/stateFunctions', () => ({
|
||||
getFunctionById: (functionId: string) => ({
|
||||
id: functionId,
|
||||
state: functionId.startsWith('idle_') ? 'idle' : 'battle',
|
||||
category: functionId === 'battle_escape_breakout' ? 'escape' : functionId.startsWith('idle_') ? 'idle' : 'battle',
|
||||
}),
|
||||
getFunctionEffect: () => ({
|
||||
sceneShift: 0,
|
||||
escapeDistance: 5,
|
||||
escapeDurationMs: 5000,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import type { BattlePlan } from './battlePlan';
|
||||
import { buildResolvedChoiceState } from './resolvedChoice';
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
name: 'Test Hero',
|
||||
title: 'Hero',
|
||||
description: 'A test character',
|
||||
backstory: 'A test backstory',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-basic',
|
||||
name: 'Basic Strike',
|
||||
animation: AnimationState.ATTACK,
|
||||
damage: 10,
|
||||
manaCost: 0,
|
||||
cooldownTurns: 1,
|
||||
range: 1,
|
||||
style: 'steady',
|
||||
},
|
||||
],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createTestCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(functionId: string): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
text: functionId,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildResolvedChoiceState', () => {
|
||||
it('keeps battle planning in the pure resolution layer', () => {
|
||||
const state = createBaseState();
|
||||
const character = createTestCharacter();
|
||||
const battlePlan: BattlePlan = {
|
||||
preparedState: state,
|
||||
turns: [],
|
||||
finalState: {
|
||||
...state,
|
||||
inBattle: false,
|
||||
},
|
||||
};
|
||||
const buildBattlePlan = vi.fn(() => battlePlan);
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option: createOption('battle_all_in_crush'),
|
||||
character,
|
||||
buildBattlePlan,
|
||||
});
|
||||
|
||||
expect(buildBattlePlan).toHaveBeenCalledWith(state, expect.objectContaining({ functionId: 'battle_all_in_crush' }), character);
|
||||
expect(resolved.optionKind).toBe('battle');
|
||||
expect(resolved.battlePlan).toBe(battlePlan);
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
});
|
||||
|
||||
it('builds escape results without playback timing concerns', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneMonsters: [
|
||||
{
|
||||
id: 'monster-1',
|
||||
name: 'Wolf',
|
||||
action: 'growls',
|
||||
description: 'A wolf',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1,
|
||||
speed: 1,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option: createOption('battle_escape_breakout'),
|
||||
character: createTestCharacter(),
|
||||
buildBattlePlan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(resolved.optionKind).toBe('escape');
|
||||
expect(resolved.battlePlan).toBeNull();
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
||||
expect(resolved.afterSequence.playerFacing).toBe('right');
|
||||
});
|
||||
|
||||
it('keeps idle follow-up generation separate from combat planning', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
};
|
||||
|
||||
const resolved = buildResolvedChoiceState({
|
||||
state,
|
||||
option: createOption('idle_observe_signs'),
|
||||
character: createTestCharacter(),
|
||||
buildBattlePlan: vi.fn(),
|
||||
});
|
||||
|
||||
expect(resolved.optionKind).toBe('idle');
|
||||
expect(resolved.battlePlan).toBeNull();
|
||||
expect(resolved.afterSequence.inBattle).toBe(false);
|
||||
expect(resolved.afterSequence.currentEncounter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
123
src/hooks/combat/resolvedChoice.ts
Normal file
123
src/hooks/combat/resolvedChoice.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
getScenePresetsByWorld,
|
||||
getTravelScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import { getFunctionEffect } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { classifyCombatOption } from '../combatStoryUtils';
|
||||
import { buildIdleAfterSequence } from '../idleAdventureFlow';
|
||||
import {
|
||||
applyRecoveryEffectToState,
|
||||
type BattlePlan,
|
||||
} from './battlePlan';
|
||||
import { buildEscapeAfterSequence } from './escapeFlow';
|
||||
|
||||
type OptionKind = 'battle' | 'escape' | 'idle';
|
||||
|
||||
export type ResolvedChoiceState = {
|
||||
optionKind: OptionKind;
|
||||
battlePlan: BattlePlan | null;
|
||||
afterSequence: GameState;
|
||||
};
|
||||
|
||||
function getShiftedScenePreset(
|
||||
worldType: WorldType | null,
|
||||
currentScenePreset: GameState['currentScenePreset'],
|
||||
sceneShift: number | undefined,
|
||||
): GameState['currentScenePreset'] {
|
||||
if (!worldType || sceneShift === undefined || sceneShift === 0) return currentScenePreset;
|
||||
if (!currentScenePreset) return getScenePresetsByWorld(worldType)[0] ?? null;
|
||||
|
||||
if (sceneShift > 0) {
|
||||
let nextScene = currentScenePreset;
|
||||
for (let index = 0; index < sceneShift; index += 1) {
|
||||
nextScene = getForwardScenePreset(worldType, nextScene.id) ?? nextScene;
|
||||
}
|
||||
return nextScene;
|
||||
}
|
||||
|
||||
const scenes = getScenePresetsByWorld(worldType);
|
||||
if (scenes.length === 0) return currentScenePreset;
|
||||
const currentIndex = scenes.findIndex(scene => scene.id === currentScenePreset.id);
|
||||
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
const nextIndex = ((safeIndex + sceneShift) % scenes.length + scenes.length) % scenes.length;
|
||||
return scenes[nextIndex] ?? null;
|
||||
}
|
||||
|
||||
function getSceneTargetForFunction(
|
||||
worldType: WorldType | null,
|
||||
currentScenePreset: GameState['currentScenePreset'],
|
||||
functionId: string,
|
||||
): GameState['currentScenePreset'] {
|
||||
if (!worldType) return currentScenePreset;
|
||||
|
||||
if (functionId === 'idle_travel_next_scene') {
|
||||
return getTravelScenePreset(worldType, currentScenePreset?.id) ?? currentScenePreset;
|
||||
}
|
||||
|
||||
return getShiftedScenePreset(
|
||||
worldType,
|
||||
currentScenePreset,
|
||||
getFunctionEffect(functionId).sceneShift,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildResolvedChoiceState(params: {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
buildBattlePlan: (state: GameState, option: StoryOption, character: Character) => BattlePlan;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
buildBattlePlan,
|
||||
} = params;
|
||||
const nextScenePreset = getSceneTargetForFunction(
|
||||
state.worldType,
|
||||
state.currentScenePreset,
|
||||
option.functionId,
|
||||
);
|
||||
const optionKind = classifyCombatOption(option);
|
||||
|
||||
if (optionKind === 'battle') {
|
||||
const battlePlan = buildBattlePlan(state, option, character);
|
||||
const battleResult: GameState = {
|
||||
...battlePlan.finalState,
|
||||
currentScenePreset: nextScenePreset ?? battlePlan.finalState.currentScenePreset,
|
||||
};
|
||||
|
||||
return {
|
||||
optionKind,
|
||||
battlePlan,
|
||||
afterSequence: battleResult,
|
||||
} satisfies ResolvedChoiceState;
|
||||
}
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
return {
|
||||
optionKind,
|
||||
battlePlan: null,
|
||||
afterSequence: buildEscapeAfterSequence(state, option),
|
||||
} satisfies ResolvedChoiceState;
|
||||
}
|
||||
|
||||
return {
|
||||
optionKind,
|
||||
battlePlan: null,
|
||||
afterSequence: buildIdleAfterSequence({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
nextScenePreset,
|
||||
applyRecoveryEffectToState,
|
||||
}),
|
||||
} satisfies ResolvedChoiceState;
|
||||
}
|
||||
105
src/hooks/combat/skillEffects.ts
Normal file
105
src/hooks/combat/skillEffects.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
getCharacterAnimationDurationMs,
|
||||
getSequenceDurationMs,
|
||||
getSkillCasterAnimation,
|
||||
getSkillDelivery,
|
||||
resolveSequenceFrames,
|
||||
} from '../../data/characterCombat';
|
||||
import type {
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CombatVisualEffect,
|
||||
} from '../../types';
|
||||
|
||||
const RANGED_MONSTER_FOOT_LOCK_OFFSET_Y = -56;
|
||||
|
||||
function createCombatEffectId() {
|
||||
return `combat-effect-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefinition) {
|
||||
if (typeof skill.releaseDelayMs === 'number') return skill.releaseDelayMs;
|
||||
const animationDuration = getCharacterAnimationDurationMs(character, getSkillCasterAnimation(skill));
|
||||
return Math.min(260, Math.max(120, Math.round(animationDuration * 0.45)));
|
||||
}
|
||||
|
||||
export function buildSkillEffects(
|
||||
attacker: {
|
||||
character: Character;
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
facing: 'left' | 'right';
|
||||
monsterId?: string;
|
||||
},
|
||||
target: {
|
||||
xMeters: number;
|
||||
origin: 'player' | 'monster';
|
||||
monsterId?: string;
|
||||
},
|
||||
skill: CharacterSkillDefinition,
|
||||
) {
|
||||
const phases = {
|
||||
cast: [] as CombatVisualEffect[],
|
||||
travel: [] as CombatVisualEffect[],
|
||||
impact: [] as CombatVisualEffect[],
|
||||
castDurationMs: 0,
|
||||
travelDurationMs: 0,
|
||||
impactDurationMs: 0,
|
||||
};
|
||||
|
||||
const deliveryRanged = getSkillDelivery(skill) === 'ranged';
|
||||
|
||||
for (const effect of skill.effects ?? []) {
|
||||
const frames = resolveSequenceFrames(attacker.character, effect.sequence);
|
||||
if (frames.length === 0) continue;
|
||||
|
||||
const durationMs = effect.durationMs ?? getSequenceDurationMs(effect.sequence, frames.length);
|
||||
const origin = effect.anchor === 'target' ? target : attacker;
|
||||
const isProjectile =
|
||||
effect.motion === 'projectile'
|
||||
|| (effect.phase === 'travel' && deliveryRanged);
|
||||
const fallbackProjectileStartOffsetX = attacker.facing === 'right' ? 18 : -18;
|
||||
const startOffsetX = effect.startOffsetX ?? (isProjectile ? fallbackProjectileStartOffsetX : 0);
|
||||
const endOffsetX = effect.endOffsetX ?? (isProjectile ? 0 : startOffsetX);
|
||||
const startAnchorOffsetY = !isProjectile
|
||||
&& deliveryRanged
|
||||
&& origin.origin === 'monster'
|
||||
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
|
||||
: 0;
|
||||
const endAnchorOffsetY = isProjectile
|
||||
&& deliveryRanged
|
||||
&& target.origin === 'monster'
|
||||
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
|
||||
: startAnchorOffsetY;
|
||||
const instance: CombatVisualEffect = {
|
||||
id: createCombatEffectId(),
|
||||
frames,
|
||||
fps: effect.sequence.fps ?? 10,
|
||||
startX: isProjectile ? attacker.xMeters : origin.xMeters,
|
||||
endX: isProjectile ? target.xMeters : origin.xMeters,
|
||||
startOrigin: isProjectile ? attacker.origin : origin.origin,
|
||||
endOrigin: isProjectile ? target.origin : origin.origin,
|
||||
startMonsterId: isProjectile ? attacker.monsterId : origin.monsterId,
|
||||
endMonsterId: isProjectile ? target.monsterId : origin.monsterId,
|
||||
startAnchorOffsetY,
|
||||
endAnchorOffsetY,
|
||||
startOffsetX,
|
||||
endOffsetX,
|
||||
startYOffset: effect.startYOffset ?? 56,
|
||||
endYOffset: effect.endYOffset ?? effect.startYOffset ?? 56,
|
||||
durationMs,
|
||||
sizePx: effect.sizePx ?? 96,
|
||||
scale: effect.scale ?? 1,
|
||||
facing: attacker.facing,
|
||||
zIndex: effect.phase === 'impact' ? 28 : 24,
|
||||
traveling: isProjectile,
|
||||
};
|
||||
|
||||
phases[effect.phase].push(instance);
|
||||
if (effect.phase === 'cast') phases.castDurationMs = Math.max(phases.castDurationMs, durationMs);
|
||||
if (effect.phase === 'travel') phases.travelDurationMs = Math.max(phases.travelDurationMs, durationMs);
|
||||
if (effect.phase === 'impact') phases.impactDurationMs = Math.max(phases.impactDurationMs, durationMs);
|
||||
}
|
||||
|
||||
return phases;
|
||||
}
|
||||
234
src/hooks/combatStoryUtils.ts
Normal file
234
src/hooks/combatStoryUtils.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { createFallbackOption } from '../data/hostileNpcs';
|
||||
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_all_in_crush', '鎴樻枟锛氬叏鍔涜繘鏀伙紝鍘嬪灝瀵规墜', AnimationState.SKILL1, 0, false),
|
||||
createFallbackOption('battle_probe_pressure', '鎴樻枟锛氱ǔ鎵庣ǔ鎵擄紝杩炵暘璇曟帰', AnimationState.SKILL2, 0, false),
|
||||
createFallbackOption('battle_escape_breakout', '逃跑:抽身后撤,先脱离纠缠', AnimationState.IDLE, -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,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFallbackOptionsForState(state: GameState, character: 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.sceneMonsters,
|
||||
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.sceneMonsters.find(monster => monster.hp > 0) ?? state.sceneMonsters[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,
|
||||
) {
|
||||
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(`鍑廋D -${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}`;
|
||||
}
|
||||
9
src/hooks/generatedState.ts
Normal file
9
src/hooks/generatedState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type {Character, GameState} from '../types';
|
||||
|
||||
export type CommitGeneratedState = (
|
||||
nextState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
315
src/hooks/idleAdventureFlow.ts
Normal file
315
src/hooks/idleAdventureFlow.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs } from '../data/characterCombat';
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../data/encounterTransition';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneCallOutEncounter,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../data/sceneEncounterPreviews';
|
||||
import { AnimationState, Character, GameState, StoryOption } from '../types';
|
||||
|
||||
const TURN_VISUAL_MS = 820;
|
||||
const RESET_STAGE_MS = 260;
|
||||
const CALL_OUT_APPROACH_DURATION_MS = 1800;
|
||||
const CALL_OUT_APPROACH_TICK_MS = 180;
|
||||
const CALL_OUT_ALERT_PAUSE_MS = 260;
|
||||
const OBSERVE_SIGNS_DURATION_MS = 5000;
|
||||
const OBSERVE_SIGNS_MIN_PAUSE_MS = 500;
|
||||
const OBSERVE_SIGNS_MAX_PAUSE_MS = 2000;
|
||||
|
||||
type RecoveryApplier = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
functionId: string,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function randomBetween(min: number, max: number) {
|
||||
return Math.round(min + Math.random() * (max - min));
|
||||
}
|
||||
|
||||
async function playEncounterEntrySequence(
|
||||
setGameState: Dispatch<SetStateAction<GameState>>,
|
||||
startState: GameState,
|
||||
finalState: GameState,
|
||||
durationMs: number,
|
||||
tickMs: number,
|
||||
) {
|
||||
if (!hasEncounterEntity(finalState)) {
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
const runTicks = Math.max(1, Math.ceil(durationMs / tickMs));
|
||||
const tickDurationMs = Math.max(1, Math.round(durationMs / runTicks));
|
||||
let currentState = startState;
|
||||
|
||||
setGameState(currentState);
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
currentState = interpolateEncounterTransitionState(startState, finalState, progress);
|
||||
setGameState(currentState);
|
||||
await sleep(tickDurationMs);
|
||||
}
|
||||
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
export function buildIdleAfterSequence(params: {
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
nextScenePreset: GameState['currentScenePreset'];
|
||||
applyRecoveryEffectToState: RecoveryApplier;
|
||||
}) {
|
||||
const { state, option, character, nextScenePreset, applyRecoveryEffectToState } = params;
|
||||
let afterSequence = applyRecoveryEffectToState(state, character, option.functionId);
|
||||
|
||||
if (option.functionId === 'idle_call_out') {
|
||||
const baseState = {
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: nextScenePreset ?? afterSequence.currentScenePreset,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
} as GameState;
|
||||
const callOutState = {
|
||||
...baseState,
|
||||
...createSceneCallOutEncounter(baseState),
|
||||
} as GameState;
|
||||
|
||||
afterSequence = callOutState.sceneMonsters.length > 0 || callOutState.currentEncounter
|
||||
? resolveSceneEncounterPreview(callOutState)
|
||||
: baseState;
|
||||
} else if (option.functionId === 'idle_explore_forward') {
|
||||
afterSequence = resolveSceneEncounterPreview({
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: nextScenePreset ?? afterSequence.currentScenePreset,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
} as GameState);
|
||||
} else if (option.functionId === 'idle_travel_next_scene') {
|
||||
const travelBaseState = {
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: nextScenePreset,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
} as GameState;
|
||||
const travelEntryState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
} as GameState;
|
||||
afterSequence = hasEncounterEntity(travelEntryState)
|
||||
? resolveSceneEncounterPreview(travelEntryState)
|
||||
: travelBaseState;
|
||||
} else {
|
||||
afterSequence = {
|
||||
...afterSequence,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
currentScenePreset: nextScenePreset,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
return afterSequence;
|
||||
}
|
||||
|
||||
export async function playIdleSequence(params: {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
state: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
finalState: GameState;
|
||||
applyRecoveryEffectToState: RecoveryApplier;
|
||||
}) {
|
||||
const { setGameState, state, option, character, finalState, applyRecoveryEffectToState } = params;
|
||||
let currentState = applyRecoveryEffectToState(state, character, option.functionId);
|
||||
|
||||
if (currentState !== state) {
|
||||
setGameState(currentState);
|
||||
await sleep(RESET_STAGE_MS);
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_observe_signs') {
|
||||
let elapsedMs = 0;
|
||||
let nextFacing: 'left' | 'right' = currentState.playerFacing === 'left' ? 'right' : 'left';
|
||||
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: 'observe_signs',
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
while (elapsedMs < OBSERVE_SIGNS_DURATION_MS) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: 'observe_signs',
|
||||
playerFacing: nextFacing,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
setGameState(currentState);
|
||||
|
||||
const remainingMs = OBSERVE_SIGNS_DURATION_MS - elapsedMs;
|
||||
const pauseMs = Math.min(
|
||||
remainingMs,
|
||||
randomBetween(OBSERVE_SIGNS_MIN_PAUSE_MS, OBSERVE_SIGNS_MAX_PAUSE_MS),
|
||||
);
|
||||
await sleep(pauseMs);
|
||||
elapsedMs += pauseMs;
|
||||
nextFacing = nextFacing === 'left' ? 'right' : 'left';
|
||||
}
|
||||
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_explore_forward') {
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_call_out') {
|
||||
const callOutAcquireDurationMs = Math.max(
|
||||
CALL_OUT_ALERT_PAUSE_MS,
|
||||
getCharacterAnimationDurationMs(character, AnimationState.ACQUIRE),
|
||||
);
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
playerFacing: 'right',
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(callOutAcquireDurationMs);
|
||||
|
||||
const approachState = {
|
||||
...finalState,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
const entryState = buildEncounterEntryState(approachState, CALL_OUT_ENTRY_X_METERS);
|
||||
const approachedState = await playEncounterEntrySequence(
|
||||
setGameState,
|
||||
entryState,
|
||||
approachState,
|
||||
CALL_OUT_APPROACH_DURATION_MS,
|
||||
CALL_OUT_APPROACH_TICK_MS,
|
||||
);
|
||||
if (
|
||||
approachedState.animationState !== finalState.animationState ||
|
||||
approachedState.playerActionMode !== finalState.playerActionMode ||
|
||||
approachedState.scrollWorld !== finalState.scrollWorld
|
||||
) {
|
||||
setGameState(finalState);
|
||||
}
|
||||
return finalState;
|
||||
}
|
||||
|
||||
if (option.functionId === 'idle_travel_next_scene') {
|
||||
currentState = {
|
||||
...currentState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
playerFacing: 'right',
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(TURN_VISUAL_MS);
|
||||
|
||||
const entryState = buildEncounterEntryState(finalState, CALL_OUT_ENTRY_X_METERS);
|
||||
return playEncounterEntrySequence(
|
||||
setGameState,
|
||||
entryState,
|
||||
finalState,
|
||||
CALL_OUT_APPROACH_DURATION_MS,
|
||||
CALL_OUT_APPROACH_TICK_MS,
|
||||
);
|
||||
}
|
||||
|
||||
const nextPlayerX = Number((state.playerX + option.visuals.playerMoveMeters).toFixed(2));
|
||||
const shouldAnimateMove =
|
||||
option.visuals.playerAnimation !== AnimationState.IDLE || Math.abs(option.visuals.playerMoveMeters) > 0;
|
||||
|
||||
if (shouldAnimateMove) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
playerX: nextPlayerX,
|
||||
playerFacing: option.visuals.playerFacing,
|
||||
animationState: option.visuals.playerAnimation,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: option.visuals.scrollWorld,
|
||||
};
|
||||
setGameState(currentState);
|
||||
await sleep(TURN_VISUAL_MS);
|
||||
}
|
||||
|
||||
setGameState(finalState);
|
||||
return finalState;
|
||||
}
|
||||
376
src/hooks/story/characterChat.ts
Normal file
376
src/hooks/story/characterChat.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
} from '../../services/ai';
|
||||
import type {StoryGenerationContext} from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
CharacterChatRecord,
|
||||
CharacterChatTurn,
|
||||
GameState,
|
||||
} from '../../types';
|
||||
|
||||
const MAX_CHARACTER_CHAT_TURNS = 24;
|
||||
|
||||
export type CharacterChatTarget = {
|
||||
character: Character;
|
||||
npcId: string | null;
|
||||
roleLabel: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
export type CharacterChatModalState = {
|
||||
target: CharacterChatTarget;
|
||||
draft: string;
|
||||
messages: CharacterChatTurn[];
|
||||
suggestions: string[];
|
||||
summary: string;
|
||||
isSending: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export interface CharacterChatUi {
|
||||
modal: CharacterChatModalState | null;
|
||||
openChat: (target: CharacterChatTarget) => void;
|
||||
closeChat: () => void;
|
||||
setDraft: (value: string) => void;
|
||||
useSuggestion: (value: string) => void;
|
||||
refreshSuggestions: () => void;
|
||||
sendDraft: () => void;
|
||||
}
|
||||
|
||||
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
|
||||
return state.characterChats[characterId] ?? {
|
||||
history: [],
|
||||
summary: '',
|
||||
updatedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
return history.slice(-MAX_CHARACTER_CHAT_TURNS);
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSummary(
|
||||
character: Character,
|
||||
history: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
) {
|
||||
const latestTurns = history
|
||||
.slice(-4)
|
||||
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
? `${character.name} 在私下对话中变得更加开放。最近的交流:${latestTurns}`
|
||||
: `${character.name} 愿意继续私下对话,并逐渐更加信任玩家。`;
|
||||
|
||||
if (!previousSummary) {
|
||||
return currentSummary.slice(0, 118);
|
||||
}
|
||||
|
||||
return `${previousSummary} ${currentSummary}`.slice(0, 118);
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSuggestions(character: Character) {
|
||||
return [
|
||||
'请更清楚地告诉我你的意思。',
|
||||
`${character.name},你真正担心的是什么?`,
|
||||
'暂时放下大局。我想更好地了解你。',
|
||||
];
|
||||
}
|
||||
|
||||
function buildCharacterChatRecordUpdate(
|
||||
state: GameState,
|
||||
characterId: string,
|
||||
record: CharacterChatRecord,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
characterChats: {
|
||||
...state.characterChats,
|
||||
[characterId]: record,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CharacterChatTargetStatus = {
|
||||
roleLabel: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
|
||||
return {
|
||||
roleLabel: target.roleLabel,
|
||||
hp: target.hp,
|
||||
maxHp: target.maxHp,
|
||||
mana: target.mana,
|
||||
maxMana: target.maxMana,
|
||||
affinity: target.affinity ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCharacterChatFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
|
||||
}) {
|
||||
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
|
||||
|
||||
const loadCharacterChatSuggestions = async (
|
||||
target: CharacterChatTarget,
|
||||
messages: CharacterChatTurn[],
|
||||
summary: string,
|
||||
) => {
|
||||
if (!gameState.worldType || !gameState.playerCharacter) {
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
isLoadingSuggestions: true,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const suggestions = await generateCharacterPanelChatSuggestions(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
messages,
|
||||
summary,
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions,
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate character chat suggestions:', error);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
isLoadingSuggestions: false,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openCharacterChat = (target: CharacterChatTarget) => {
|
||||
const record = getCharacterChatRecord(gameState, target.character.id);
|
||||
|
||||
setCharacterChatModal({
|
||||
target,
|
||||
draft: '',
|
||||
messages: record.history,
|
||||
suggestions: buildLocalCharacterChatSuggestions(target.character),
|
||||
summary: record.summary,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
void loadCharacterChatSuggestions(target, record.history, record.summary);
|
||||
};
|
||||
|
||||
const sendCharacterChatDraft = async () => {
|
||||
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = characterChatModal.draft.trim();
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = characterChatModal.target;
|
||||
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
|
||||
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
|
||||
const nextMessages = trimCharacterChatHistory([
|
||||
...baseMessages,
|
||||
{
|
||||
speaker: 'player' as const,
|
||||
text: draft,
|
||||
},
|
||||
]);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft: '',
|
||||
messages: [...nextMessages, {speaker: 'character', text: ''}],
|
||||
suggestions: [],
|
||||
isSending: true,
|
||||
isLoadingSuggestions: true,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
let replyText = '';
|
||||
|
||||
try {
|
||||
replyText = await streamCharacterPanelChatReply(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
nextMessages,
|
||||
existingRecord.summary,
|
||||
draft,
|
||||
buildTargetStatus(target),
|
||||
{
|
||||
onUpdate: text => {
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: [...nextMessages, {speaker: 'character', text}],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream character panel chat reply:', error);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft,
|
||||
messages: baseMessages,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: false,
|
||||
error: error instanceof Error ? error.message : '未知 AI 错误',
|
||||
suggestions: current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalMessages = trimCharacterChatHistory([
|
||||
...nextMessages,
|
||||
{
|
||||
speaker: 'character' as const,
|
||||
text: replyText,
|
||||
},
|
||||
]);
|
||||
|
||||
let nextSummary = existingRecord.summary;
|
||||
try {
|
||||
nextSummary = await generateCharacterPanelChatSummary(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
target.character,
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState),
|
||||
finalMessages,
|
||||
existingRecord.summary,
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to summarize character chat:', error);
|
||||
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
|
||||
}
|
||||
|
||||
const nextRecord: CharacterChatRecord = {
|
||||
history: finalMessages,
|
||||
summary: nextSummary,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setGameState(current =>
|
||||
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
|
||||
);
|
||||
setCharacterChatModal(current =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: finalMessages,
|
||||
summary: nextSummary,
|
||||
isSending: false,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
await loadCharacterChatSuggestions(target, finalMessages, nextSummary);
|
||||
};
|
||||
|
||||
const characterChatUi: CharacterChatUi = {
|
||||
modal: characterChatModal,
|
||||
openChat: openCharacterChat,
|
||||
closeChat: () => setCharacterChatModal(null),
|
||||
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
refreshSuggestions: () => {
|
||||
if (!characterChatModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadCharacterChatSuggestions(
|
||||
characterChatModal.target,
|
||||
characterChatModal.messages,
|
||||
characterChatModal.summary,
|
||||
);
|
||||
},
|
||||
sendDraft: () => {
|
||||
void sendCharacterChatDraft();
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
characterChatModal,
|
||||
setCharacterChatModal,
|
||||
characterChatUi,
|
||||
openCharacterChat,
|
||||
sendCharacterChatDraft,
|
||||
loadCharacterChatSuggestions,
|
||||
clearCharacterChatModal: () => setCharacterChatModal(null),
|
||||
};
|
||||
}
|
||||
546
src/hooks/story/choiceActions.ts
Normal file
546
src/hooks/story/choiceActions.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { generateNextStep } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type {
|
||||
CommitGeneratedStateWithEncounterEntry,
|
||||
GenerateStoryForState,
|
||||
} from './progressionActions';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: { lastFunctionId?: string | null; observeSignsRequested?: boolean },
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildReasonedOptionCatalog(options: StoryOption[]) {
|
||||
const seenFunctionIds = new Set<string>();
|
||||
|
||||
return options.filter(option => {
|
||||
if (seenFunctionIds.has(option.functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenFunctionIds.add(option.functionId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'],
|
||||
): BattleRewardSummary | null {
|
||||
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
|
||||
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
|
||||
const defeatedHostileNpcs = activeHostileNpcs.filter(hostileNpc =>
|
||||
!nextHostileNpcs.some(nextHostileNpc => nextHostileNpc.id === hostileNpc.id),
|
||||
);
|
||||
|
||||
if (defeatedHostileNpcs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolledItems = rollHostileNpcLoot(
|
||||
state,
|
||||
defeatedHostileNpcs.map(hostileNpc => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
id: `battle-reward-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map(hostileNpc => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
}
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
startOpeningAdventure,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
startOpeningAdventure: () => Promise<void>;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean;
|
||||
handleTreasureInteraction: (option: StoryOption) => void | Promise<void> | boolean;
|
||||
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const handleCampTravelHome = async (option: StoryOption, character: Character) => {
|
||||
const targetScene = getCampCompanionTravelScene(gameState, character);
|
||||
if (!targetScene) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBattleReward(null);
|
||||
setAiError(null);
|
||||
|
||||
const companionName = isNpcEncounter(gameState.currentEncounter)
|
||||
? gameState.currentEncounter.npcName
|
||||
: fallbackCompanionName;
|
||||
const travelRunState: GameState = {
|
||||
...gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
const travelBaseState: GameState = incrementRuntimeStats({
|
||||
...gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}, {
|
||||
scenesTraveled: 1,
|
||||
});
|
||||
const travelPreviewState: GameState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
};
|
||||
const resolvedState = hasEncounterEntity(travelPreviewState)
|
||||
? resolveSceneEncounterPreview(travelPreviewState)
|
||||
: travelBaseState;
|
||||
const entryState = buildEncounterEntryState(resolvedState, CALL_OUT_ENTRY_X_METERS);
|
||||
|
||||
setIsLoading(true);
|
||||
setGameState(travelRunState);
|
||||
await sleep(turnVisualMs);
|
||||
|
||||
await commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
option.actionText,
|
||||
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
|
||||
option.functionId,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
const handleChoice = async (option: StoryOption) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (!gameState.worldType || !character || isLoading) return;
|
||||
|
||||
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
|
||||
setCurrentStory({
|
||||
...currentStory,
|
||||
options: currentStory.deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCampTravelHomeOption(option)) {
|
||||
await handleCampTravelHome(option, character);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isInitialCompanionEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
void startOpeningAdventure();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isRegularNpcEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
enterNpcInteraction(gameState.currentEncounter, option.actionText);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc') {
|
||||
setAiError(null);
|
||||
handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'treasure') {
|
||||
setAiError(null);
|
||||
handleTreasureInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
setBattleReward(null);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const baseChoiceState = (
|
||||
isRegularNpcEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
&& !option.interaction
|
||||
)
|
||||
? {
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
}
|
||||
: gameState;
|
||||
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = buildResolvedChoiceState(baseChoiceState, option, character);
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(
|
||||
projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' && !projectedState.inBattle)
|
||||
),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? {
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
|
||||
}
|
||||
: projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
character,
|
||||
);
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
gameState.worldType,
|
||||
character,
|
||||
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
history,
|
||||
option.actionText,
|
||||
buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: option.functionId,
|
||||
observeSignsRequested: option.functionId === 'idle_observe_signs',
|
||||
}),
|
||||
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(() => undefined, () => undefined);
|
||||
const playbackSync: EscapePlaybackSync | undefined = resolvedChoice.optionKind === 'escape'
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = playResolvedChoice(
|
||||
baseChoiceState,
|
||||
option,
|
||||
character,
|
||||
resolvedChoice,
|
||||
playbackSync,
|
||||
);
|
||||
const [actionResult, responseResult] = await Promise.allSettled([actionPromise, responsePromise]);
|
||||
|
||||
if (actionResult.status === 'rejected') {
|
||||
throw actionResult.reason;
|
||||
}
|
||||
|
||||
let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value;
|
||||
if (projectedBattleReward) {
|
||||
afterSequence = {
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
|
||||
};
|
||||
}
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase = baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleOptionCatalog = baseChoiceState.currentNpcBattleMode === 'spar' && nextState.currentEncounter
|
||||
? buildReasonedOptionCatalog(
|
||||
buildNpcStory(
|
||||
nextState,
|
||||
character,
|
||||
nextState.currentEncounter,
|
||||
).options,
|
||||
)
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
setGameState(nextState);
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: nextState,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: option.actionText,
|
||||
lastFunctionId: option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error('Failed to continue npc battle resolution story:', storyError);
|
||||
setAiError(storyError instanceof Error ? storyError.message : '未知 AI 错误');
|
||||
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId
|
||||
? []
|
||||
: getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map(hostileNpc => hostileNpc.id)
|
||||
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
|
||||
const nextHistory = [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = incrementRuntimeStats({
|
||||
...updateQuestLog(
|
||||
afterSequence,
|
||||
quests => applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
lastObserveSignsSceneId: option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
lastObserveSignsReport: option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
}, {
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
});
|
||||
|
||||
setGameState(nextState);
|
||||
if (projectedBattleReward) {
|
||||
setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildStoryFromResponse(
|
||||
nextState,
|
||||
character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
};
|
||||
}
|
||||
44
src/hooks/story/inventoryActions.ts
Normal file
44
src/hooks/story/inventoryActions.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { GameState } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { useEquipmentFlow } from '../useEquipmentFlow';
|
||||
import { useForgeFlow } from '../useForgeFlow';
|
||||
import { useInventoryFlow } from '../useInventoryFlow';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
|
||||
type TickCooldowns = (cooldowns: Record<string, number>) => Record<string, number>;
|
||||
|
||||
export function useStoryInventoryActions({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
tickCooldowns: TickCooldowns;
|
||||
}) {
|
||||
const inventoryFlow = useInventoryFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
});
|
||||
const equipmentFlow = useEquipmentFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
});
|
||||
const forgeFlow = useForgeFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
});
|
||||
|
||||
return {
|
||||
inventoryUi: {
|
||||
useInventoryItem: inventoryFlow.handleUseInventoryItem,
|
||||
equipInventoryItem: equipmentFlow.handleEquipInventoryItem,
|
||||
unequipItem: equipmentFlow.handleUnequipItem,
|
||||
forgeRecipes: forgeFlow.forgeRecipes,
|
||||
craftRecipe: forgeFlow.handleCraftRecipe,
|
||||
dismantleItem: forgeFlow.handleDismantleItem,
|
||||
reforgeItem: forgeFlow.handleReforgeItem,
|
||||
} satisfies InventoryFlowUi,
|
||||
};
|
||||
}
|
||||
837
src/hooks/story/npcEncounterActions.ts
Normal file
837
src/hooks/story/npcEncounterActions.ts
Normal file
@@ -0,0 +1,837 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { buildRelationState } from '../../data/attributeResolver';
|
||||
import { hasEncounterEntity } from '../../data/encounterTransition';
|
||||
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcChatResultText,
|
||||
buildNpcHelpResultText,
|
||||
buildNpcHelpReward,
|
||||
buildNpcLeaveResultText,
|
||||
buildNpcSparResultText,
|
||||
createNpcBattleMonster,
|
||||
getChatAffinityOutcome,
|
||||
getNpcLootItems,
|
||||
getNpcSparMaxHp,
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
NPC_SPAR_AFFINITY_GAIN,
|
||||
removeInventoryItem,
|
||||
} from '../../data/npcInteractions';
|
||||
import {
|
||||
acceptQuest,
|
||||
applyQuestProgressFromHostileNpcDefeat,
|
||||
applyQuestProgressFromSpar,
|
||||
buildQuestAcceptResultText,
|
||||
buildQuestForEncounter,
|
||||
buildQuestTurnInResultText,
|
||||
findQuestById,
|
||||
getQuestForIssuer,
|
||||
markQuestTurnedIn,
|
||||
} from '../../data/questFlow';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import {
|
||||
createSceneCallOutEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
NpcBattleMode,
|
||||
NpcBattleOutcome,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
|
||||
type CommitGeneratedStateWithEncounterEntry = (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
|
||||
type NpcInteractionFlowActions = {
|
||||
openTradeModal: (encounter: Encounter, actionText: string) => void;
|
||||
openGiftModal: (encounter: Encounter, actionText: string) => void;
|
||||
openRecruitModal: (encounter: Encounter, actionText: string) => void;
|
||||
startRecruitmentSequence: (
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
type BuildStoryContextExtras = {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
function buildCampCompanionChatResultText(
|
||||
encounter: Encounter,
|
||||
affinityGain: number,
|
||||
_nextAffinity: number,
|
||||
) {
|
||||
const teamworkText =
|
||||
affinityGain > 0
|
||||
? 'You also feel a little more confident about how you will work together next.'
|
||||
: 'You at least realign your rhythm for what comes next.';
|
||||
return `${encounter.npcName}閸滃奔缍樻禍銈嗗床娴滃棔绔存潪顔藉厒濞夋洩绱?{describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
|
||||
}
|
||||
|
||||
function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
export function createStoryNpcEncounterActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
getAvailableOptionsForState,
|
||||
sanitizeOptions,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision,
|
||||
npcInteractionFlow,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
||||
appendHistory: (
|
||||
state: GameState,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
) => GameState['storyHistory'];
|
||||
buildOpeningCampChatContext: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => BuildStoryContextExtras;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: BuildStoryContextExtras,
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneMonsters'];
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
sanitizeOptions: (
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) => StoryOption[];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (
|
||||
npcState: GameState['npcStates'][string],
|
||||
) => GameState['npcStates'][string],
|
||||
) => GameState;
|
||||
cloneInventoryItemForOwner: (
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity?: number,
|
||||
) => InventoryItem;
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
npcInteractionFlow: NpcInteractionFlowActions;
|
||||
}) {
|
||||
const updateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => ({
|
||||
...state,
|
||||
quests: updater(state.quests),
|
||||
});
|
||||
|
||||
const incrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
||||
) => ({
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
||||
});
|
||||
|
||||
const finalizeNpcBattleResult = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NpcBattleMode,
|
||||
battleOutcome: NpcBattleOutcome | null,
|
||||
) => {
|
||||
if (!state.currentBattleNpcId) return null;
|
||||
|
||||
const battleNpcId = state.currentBattleNpcId;
|
||||
const npcState = state.npcStates[battleNpcId];
|
||||
if (!npcState) return null;
|
||||
|
||||
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
|
||||
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
|
||||
const restoredEncounter = state.sparReturnEncounter;
|
||||
const progressedQuests = applyQuestProgressFromSpar(
|
||||
state.quests,
|
||||
battleNpcId,
|
||||
);
|
||||
const nextState = {
|
||||
...state,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: restoredEncounter,
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [],
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[battleNpcId]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
},
|
||||
},
|
||||
quests: progressedQuests,
|
||||
playerX: 0,
|
||||
playerHp: state.sparPlayerHpBefore ?? state.playerHp,
|
||||
playerMaxHp: state.sparPlayerMaxHpBefore ?? state.playerMaxHp,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: state.animationState,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
|
||||
return {
|
||||
nextState,
|
||||
resultText: buildNpcSparResultText(
|
||||
NPC_SPAR_AFFINITY_GAIN,
|
||||
nextAffinity,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const lootItems = getNpcLootItems(npcState, character).map((item) =>
|
||||
cloneInventoryItemForOwner(item, 'player'),
|
||||
);
|
||||
const defeatedHostileNpcIds = (
|
||||
state.sceneHostileNpcs ?? state.sceneMonsters
|
||||
).map((hostileNpc) => hostileNpc.id);
|
||||
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
|
||||
state.quests,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
);
|
||||
let nextNpcInventory = npcState.inventory;
|
||||
|
||||
for (const item of lootItems) {
|
||||
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
|
||||
}
|
||||
|
||||
const nextState: GameState = incrementRuntimeStats(
|
||||
{
|
||||
...state,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
||||
quests: progressedQuests,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[battleNpcId]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: 0,
|
||||
relationState: buildRelationState(0),
|
||||
recruited: false,
|
||||
inventory: nextNpcInventory,
|
||||
},
|
||||
},
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: state.animationState,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const lootText =
|
||||
lootItems.length > 0
|
||||
? lootItems.map((item) => item.name).join(', ')
|
||||
: '无战利品';
|
||||
return {
|
||||
nextState,
|
||||
resultText: `胜利奖励:${lootText}。`,
|
||||
};
|
||||
};
|
||||
|
||||
const commitNpcChatState = async (
|
||||
nextState: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
contextNpcStateOverride?: GameState['npcStates'][string] | null,
|
||||
) => {
|
||||
const provisionalHistory = appendHistory(gameState, actionText, resultText);
|
||||
const provisionalState = {
|
||||
...nextState,
|
||||
storyHistory: provisionalHistory,
|
||||
};
|
||||
const provisionalOpeningCampContext = buildOpeningCampChatContext(
|
||||
provisionalState,
|
||||
character,
|
||||
encounter,
|
||||
);
|
||||
|
||||
setGameState(provisionalState);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
let displayedText = '';
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (
|
||||
!streamCompleted ||
|
||||
displayedText.length < streamedTargetText.length
|
||||
) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
displayedText += nextChar;
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
dialogueText = await streamNpcChatDialogue(
|
||||
gameState.worldType!,
|
||||
character,
|
||||
encounter,
|
||||
getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId,
|
||||
...provisionalOpeningCampContext,
|
||||
encounterNpcStateOverride: contextNpcStateOverride,
|
||||
}),
|
||||
actionText,
|
||||
resultText,
|
||||
{
|
||||
onUpdate: (text) => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
|
||||
const finalHistory = appendHistory(
|
||||
gameState,
|
||||
actionText,
|
||||
dialogueText || resultText,
|
||||
);
|
||||
const finalState = {
|
||||
...nextState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
const availableOptions = getAvailableOptionsForState(
|
||||
finalState,
|
||||
character,
|
||||
);
|
||||
const finalOpeningCampContext = buildOpeningCampChatContext(
|
||||
finalState,
|
||||
character,
|
||||
encounter,
|
||||
);
|
||||
setGameState(finalState);
|
||||
|
||||
const response = await generateNextStep(
|
||||
gameState.worldType!,
|
||||
character,
|
||||
getStoryGenerationHostileNpcs(finalState),
|
||||
finalHistory,
|
||||
actionText,
|
||||
buildStoryContextFromState(finalState, {
|
||||
lastFunctionId,
|
||||
...finalOpeningCampContext,
|
||||
}),
|
||||
availableOptions ? { availableOptions } : undefined,
|
||||
);
|
||||
const resolvedOptions = sortOptions(
|
||||
availableOptions
|
||||
? response.options
|
||||
: sanitizeOptions(response.options, character, finalState),
|
||||
);
|
||||
|
||||
setCurrentStory({
|
||||
...buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
dialogueText || resultText,
|
||||
[buildContinueAdventureOption()],
|
||||
false,
|
||||
),
|
||||
deferredOptions: resolvedOptions,
|
||||
});
|
||||
} catch (error) {
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream npc chat story:', error);
|
||||
setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 对话 AI 不可用。',
|
||||
);
|
||||
const fallbackOptions =
|
||||
getAvailableOptionsForState(provisionalState, character) ?? [];
|
||||
setCurrentStory(
|
||||
displayedText
|
||||
? {
|
||||
...buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
displayedText,
|
||||
fallbackOptions.length > 0
|
||||
? [buildContinueAdventureOption()]
|
||||
: [],
|
||||
false,
|
||||
),
|
||||
deferredOptions:
|
||||
fallbackOptions.length > 0
|
||||
? sortOptions(fallbackOptions)
|
||||
: undefined,
|
||||
}
|
||||
: buildFallbackStoryForState(provisionalState, character, resultText),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
|
||||
if (!gameState.playerCharacter) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
actionText,
|
||||
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
|
||||
NPC_PREVIEW_TALK_FUNCTION.id,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleNpcInteraction = (option: StoryOption) => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
!option.interaction ||
|
||||
!isNpcEncounter(gameState.currentEncounter)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounter = gameState.currentEncounter;
|
||||
const npcState = getResolvedNpcState(gameState, encounter);
|
||||
const interactionDecision = resolveNpcInteractionDecision(
|
||||
gameState,
|
||||
option,
|
||||
);
|
||||
|
||||
if (interactionDecision.kind === 'trade_modal') {
|
||||
npcInteractionFlow.openTradeModal(encounter, option.actionText);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interactionDecision.kind === 'gift_modal') {
|
||||
npcInteractionFlow.openGiftModal(encounter, option.actionText);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interactionDecision.kind === 'recruit_modal') {
|
||||
npcInteractionFlow.openRecruitModal(encounter, option.actionText);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interactionDecision.kind === 'recruit_immediate') {
|
||||
void npcInteractionFlow.startRecruitmentSequence(
|
||||
encounter,
|
||||
option.actionText,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (option.interaction.action) {
|
||||
case 'help': {
|
||||
const reward = buildNpcHelpReward(encounter);
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (let index = 0; index < (reward.cooldownBonus ?? 0); index += 1) {
|
||||
cooldowns = Object.fromEntries(
|
||||
Object.entries(cooldowns).map(([skillId, turns]) => [
|
||||
skillId,
|
||||
Math.max(0, turns - 1),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
helpUsed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerHp: Math.min(
|
||||
nextState.playerMaxHp,
|
||||
nextState.playerHp + (reward.hp ?? 0),
|
||||
),
|
||||
playerMana: Math.min(
|
||||
nextState.playerMaxMana,
|
||||
nextState.playerMana + (reward.mana ?? 0),
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
playerInventory: reward.item
|
||||
? addInventoryItems(nextState.playerInventory, [
|
||||
cloneInventoryItemForOwner(reward.item, 'player'),
|
||||
])
|
||||
: nextState.playerInventory,
|
||||
};
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcHelpResultText(encounter, reward),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'chat': {
|
||||
const chatOutcome = getChatAffinityOutcome({
|
||||
playerCharacter: gameState.playerCharacter,
|
||||
encounter,
|
||||
npcState,
|
||||
actionText: option.actionText,
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
});
|
||||
const affinityGain = chatOutcome.affinityGain;
|
||||
const attributeSummary = chatOutcome.summary;
|
||||
let nextAffinity = npcState.affinity;
|
||||
const nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
(currentNpcState) => {
|
||||
nextAffinity = currentNpcState.affinity + affinityGain;
|
||||
return {
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
chattedCount: currentNpcState.chattedCount + 1,
|
||||
};
|
||||
},
|
||||
);
|
||||
void commitNpcChatState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.recruited
|
||||
? buildCampCompanionChatResultText(
|
||||
encounter,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
)
|
||||
: buildNpcChatResultText(
|
||||
encounter,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
attributeSummary,
|
||||
),
|
||||
option.functionId,
|
||||
npcState,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'quest_accept': {
|
||||
const existingQuest = getQuestForIssuer(
|
||||
gameState.quests,
|
||||
getNpcEncounterKey(encounter),
|
||||
);
|
||||
if (existingQuest) return true;
|
||||
|
||||
const quest = buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!quest) return true;
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
|
||||
encounter,
|
||||
(currentNpcState) =>
|
||||
markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
),
|
||||
{ questsAccepted: 1 },
|
||||
);
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'quest_turn_in': {
|
||||
const questId = option.interaction.questId;
|
||||
const quest = questId ? findQuestById(gameState.quests, questId) : null;
|
||||
if (!quest || quest.status !== 'completed') return true;
|
||||
|
||||
const nextState = {
|
||||
...updateQuestLog(gameState, (quests) =>
|
||||
markQuestTurnedIn(quests, quest.id),
|
||||
),
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: {
|
||||
...npcState,
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity + quest.reward.affinityBonus,
|
||||
relationState: buildRelationState(
|
||||
npcState.affinity + quest.reward.affinityBonus,
|
||||
),
|
||||
},
|
||||
},
|
||||
playerCurrency: gameState.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(
|
||||
gameState.playerInventory,
|
||||
quest.reward.items,
|
||||
),
|
||||
};
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestTurnInResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'leave': {
|
||||
const baseState: GameState = {
|
||||
...gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: gameState.animationState,
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
const entryState = {
|
||||
...baseState,
|
||||
...createSceneCallOutEncounter(baseState),
|
||||
} as GameState;
|
||||
const resolvedState = hasEncounterEntity(entryState)
|
||||
? resolveSceneEncounterPreview(entryState)
|
||||
: baseState;
|
||||
|
||||
void commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildNpcLeaveResultText(encounter),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'fight': {
|
||||
const nextState = {
|
||||
...gameState,
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
|
||||
npcState,
|
||||
),
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'fight'),
|
||||
],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
currentBattleNpcId: getNpcEncounterKey(encounter),
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
case 'spar': {
|
||||
const sparPlayerMaxHp = getNpcSparMaxHp(gameState.playerCharacter);
|
||||
const nextState = {
|
||||
...gameState,
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
|
||||
npcState,
|
||||
),
|
||||
},
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'spar'),
|
||||
],
|
||||
playerX: 0,
|
||||
playerHp: sparPlayerMaxHp,
|
||||
playerMaxHp: sparPlayerMaxHp,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
scrollWorld: false,
|
||||
inBattle: true,
|
||||
currentBattleNpcId: getNpcEncounterKey(encounter),
|
||||
currentNpcBattleMode: 'spar' as const,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: encounter,
|
||||
sparPlayerHpBefore: gameState.playerHp,
|
||||
sparPlayerMaxHpBefore: gameState.playerMaxHp,
|
||||
sparStoryHistoryBefore: gameState.storyHistory,
|
||||
};
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
};
|
||||
}
|
||||
668
src/hooks/story/npcInteraction.ts
Normal file
668
src/hooks/story/npcInteraction.ts
Normal file
@@ -0,0 +1,668 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { buildRelationState } from '../../data/attributeResolver';
|
||||
import {
|
||||
buildCompanionState,
|
||||
getCharacterById,
|
||||
resolveEncounterRecruitCharacter,
|
||||
} from '../../data/characterPresets';
|
||||
import { recruitCompanionToParty } from '../../data/companionRoster';
|
||||
import {
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from '../../data/economy';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcRecruitResultText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
getGiftCandidates,
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
removeInventoryItem,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcRecruitDialogue } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
StoryGenerationNpcUi,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type StoryNpcInteractionRuntime = {
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { lastFunctionId?: string | null },
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
};
|
||||
|
||||
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
|
||||
const releaseLine = releasedCompanionName
|
||||
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
|
||||
|
||||
return [
|
||||
'你:我不是一时起意,我想正式邀请你加入我的队伍。',
|
||||
`${encounter.npcName}:你这话说得够直接,不过我更在意,你是否真的清楚自己接下来要面对什么。`,
|
||||
releaseLine,
|
||||
`${encounter.npcName}:既然你已经想清楚了,那我就跟你走这一程,看看你能把这条路走到哪里。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function normalizeRecruitDialogue(
|
||||
encounter: Encounter,
|
||||
dialogueText: string,
|
||||
releasedCompanionName?: string | null,
|
||||
) {
|
||||
const rawLines = dialogueText
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
|
||||
const npcPrefix = `${encounter.npcName}:`;
|
||||
const playerPrefix = '你:';
|
||||
const releaseLine = releasedCompanionName
|
||||
? `${playerPrefix}我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: `${playerPrefix}我会把接下来的路安排好,和你并肩前行。`;
|
||||
const defaultLines = [
|
||||
`${playerPrefix}我是真心邀请你加入队伍,不是随口一提。`,
|
||||
`${npcPrefix}你的意思我已经听明白了,我更在意你是否真的准备好了。`,
|
||||
releaseLine,
|
||||
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
|
||||
];
|
||||
|
||||
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
|
||||
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
|
||||
const firstDefaultLine = defaultLines[0];
|
||||
if (firstDefaultLine) {
|
||||
workingLines.unshift(firstDefaultLine);
|
||||
}
|
||||
}
|
||||
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
|
||||
const secondDefaultLine = defaultLines[1];
|
||||
if (secondDefaultLine) {
|
||||
workingLines.push(secondDefaultLine);
|
||||
}
|
||||
}
|
||||
|
||||
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
|
||||
const lastWorkingLine = workingLines[workingLines.length - 1];
|
||||
if (
|
||||
workingLines.length === 0
|
||||
|| !lastWorkingLine?.startsWith(npcPrefix)
|
||||
|| refusalPattern.test(lastWorkingLine)
|
||||
) {
|
||||
workingLines.push(acceptanceLine);
|
||||
} else {
|
||||
workingLines[workingLines.length - 1] = acceptanceLine;
|
||||
}
|
||||
|
||||
const compactLines = workingLines.slice(0, 5);
|
||||
if (compactLines[compactLines.length - 1] !== acceptanceLine) {
|
||||
compactLines.push(acceptanceLine);
|
||||
}
|
||||
|
||||
return compactLines.slice(0, 6).join('\n');
|
||||
}
|
||||
|
||||
export function useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
commitGeneratedState,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
cloneInventoryItemForOwner,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
|
||||
) => GameState;
|
||||
cloneInventoryItemForOwner: (
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity?: number,
|
||||
) => InventoryItem;
|
||||
runtime: StoryNpcInteractionRuntime;
|
||||
}) {
|
||||
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
|
||||
const getTradeNpcItem = (state: GameState, modal: TradeModalState) => {
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null;
|
||||
};
|
||||
|
||||
const getTradePlayerItem = (state: GameState, modal: TradeModalState) =>
|
||||
state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null;
|
||||
|
||||
const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0;
|
||||
};
|
||||
|
||||
const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
return getTradeNpcItem(state, modal)?.quantity ?? 0;
|
||||
}
|
||||
|
||||
return getTradePlayerItem(state, modal)?.quantity ?? 0;
|
||||
};
|
||||
|
||||
const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => {
|
||||
const maxQuantity = getTradeMaxQuantity(state, modal);
|
||||
if (maxQuantity <= 0) return 1;
|
||||
return Math.max(1, Math.min(maxQuantity, Math.floor(quantity)));
|
||||
};
|
||||
|
||||
const buildRecruitmentOutcome = (
|
||||
encounter: Encounter,
|
||||
releasedNpcId?: string | null,
|
||||
) => {
|
||||
if (!gameState.playerCharacter) return null;
|
||||
|
||||
const npcState = getResolvedNpcState(gameState, encounter);
|
||||
const recruitKey = getNpcEncounterKey(encounter);
|
||||
let releasedCompanionName: string | null = null;
|
||||
|
||||
const nextNpcStates = {
|
||||
...gameState.npcStates,
|
||||
[recruitKey]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
recruited: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (releasedNpcId) {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
releasedCompanionName = releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
|
||||
if (!recruitCharacter) return null;
|
||||
|
||||
const recruitedCompanion = buildCompanionState(
|
||||
recruitKey,
|
||||
recruitCharacter,
|
||||
npcState.affinity,
|
||||
);
|
||||
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
|
||||
|
||||
const nextState: GameState = {
|
||||
...rosterState,
|
||||
npcStates: nextNpcStates,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: gameState.animationState,
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
ambientIdleMode: undefined,
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
|
||||
return {
|
||||
nextState,
|
||||
releasedCompanionName,
|
||||
};
|
||||
};
|
||||
|
||||
const executeRecruitment = (
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
releasedNpcId?: string | null,
|
||||
preludeText?: string | null,
|
||||
) => {
|
||||
if (!gameState.playerCharacter) return;
|
||||
|
||||
const outcome = buildRecruitmentOutcome(encounter, releasedNpcId);
|
||||
if (!outcome) return;
|
||||
|
||||
const recruitResultText = buildNpcRecruitResultText(encounter, outcome.releasedCompanionName);
|
||||
setRecruitModal(null);
|
||||
|
||||
if (!preludeText) {
|
||||
void commitGeneratedState(
|
||||
outcome.nextState,
|
||||
gameState.playerCharacter,
|
||||
actionText,
|
||||
recruitResultText,
|
||||
'npc_recruit',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHistory = [
|
||||
...gameState.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(preludeText, 'result'),
|
||||
createHistoryMoment(recruitResultText, 'result'),
|
||||
];
|
||||
const stateWithHistory = {
|
||||
...outcome.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
runtime.setAiError(null);
|
||||
|
||||
void runtime.generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character: gameState.playerCharacter,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId: 'npc_recruit',
|
||||
})
|
||||
.then(nextStory => {
|
||||
runtime.setCurrentStory(nextStory);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to continue recruit story:', error);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
runtime.setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const startRecruitmentSequence = async (
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
releasedNpcId?: string | null,
|
||||
) => {
|
||||
if (!gameState.playerCharacter) return;
|
||||
|
||||
const releasedCompanionName = releasedNpcId
|
||||
? (() => {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
return releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const provisionalState = {
|
||||
...gameState,
|
||||
};
|
||||
const recruitPromptSummary = releasedCompanionName
|
||||
? `如果对方答应加入,你会先让${releasedCompanionName}离队,为新同伴腾出位置。`
|
||||
: '如果对方答应加入,你们将立刻结伴同行。';
|
||||
|
||||
setRecruitModal(null);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
let displayedText = '';
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
displayedText += nextChar;
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true));
|
||||
await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)));
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
dialogueText = await streamNpcRecruitDialogue(
|
||||
gameState.worldType!,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId: 'npc_recruit',
|
||||
}),
|
||||
actionText,
|
||||
recruitPromptSummary,
|
||||
{
|
||||
onUpdate: text => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
} catch (error) {
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream recruit dialogue:', error);
|
||||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
}
|
||||
|
||||
const finalDialogueText = normalizeRecruitDialogue(
|
||||
encounter,
|
||||
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
releasedCompanionName,
|
||||
);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
executeRecruitment(encounter, actionText, releasedNpcId, finalDialogueText);
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
const npcState = getResolvedNpcState(gameState, encounter);
|
||||
setTradeModal({
|
||||
encounter,
|
||||
actionText,
|
||||
mode: 'buy',
|
||||
selectedNpcItemId: npcState.inventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null,
|
||||
selectedQuantity: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
setGiftModal({
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId: gameState.playerInventory[0]?.id ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||||
setRecruitModal({
|
||||
encounter,
|
||||
actionText,
|
||||
selectedReleaseNpcId: gameState.companions[0]?.npcId ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
const clearNpcInteractionUi = () => {
|
||||
setTradeModal(null);
|
||||
setGiftModal(null);
|
||||
setRecruitModal(null);
|
||||
};
|
||||
|
||||
const confirmTrade = () => {
|
||||
if (!tradeModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = tradeModal.encounter;
|
||||
const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity);
|
||||
const unitPrice = getTradeUnitPrice(gameState, tradeModal);
|
||||
const totalPrice = unitPrice * quantity;
|
||||
|
||||
if (tradeModal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(gameState, tradeModal);
|
||||
if (!npcItem || quantity <= 0) return;
|
||||
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
inventory: removeInventoryItem(currentNpcState.inventory, npcItem.id, quantity),
|
||||
}),
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerCurrency: nextState.playerCurrency - totalPrice,
|
||||
playerInventory: addInventoryItems(
|
||||
nextState.playerInventory,
|
||||
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
|
||||
),
|
||||
};
|
||||
|
||||
setTradeModal(null);
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
tradeModal.actionText,
|
||||
buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
'npc_trade',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(gameState, tradeModal);
|
||||
if (!playerItem || quantity <= 0) return;
|
||||
if (playerItem.quantity < quantity) return;
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
inventory: addInventoryItems(
|
||||
currentNpcState.inventory,
|
||||
[cloneInventoryItemForOwner(playerItem, 'npc', quantity)],
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerCurrency: nextState.playerCurrency + totalPrice,
|
||||
playerInventory: removeInventoryItem(nextState.playerInventory, playerItem.id, quantity),
|
||||
};
|
||||
|
||||
setTradeModal(null);
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
tradeModal.actionText,
|
||||
buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
'npc_trade',
|
||||
);
|
||||
};
|
||||
|
||||
const confirmGift = () => {
|
||||
if (!giftModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = giftModal.encounter;
|
||||
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
|
||||
if (!giftItem) return;
|
||||
|
||||
const giftCandidate = getGiftCandidates(gameState.playerInventory, encounter, {
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
})
|
||||
.find(candidate => candidate.item.id === giftItem.id);
|
||||
const affinityGain = giftCandidate?.affinityGain ?? 0;
|
||||
const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? null;
|
||||
let nextAffinity = 0;
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState => {
|
||||
nextAffinity = currentNpcState.affinity + affinityGain;
|
||||
return {
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
giftsGiven: currentNpcState.giftsGiven + 1,
|
||||
inventory: addInventoryItems(
|
||||
currentNpcState.inventory,
|
||||
[cloneInventoryItemForOwner(giftItem, 'npc')],
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1),
|
||||
};
|
||||
|
||||
setGiftModal(null);
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
giftModal.actionText,
|
||||
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
|
||||
'npc_gift',
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
npcUi: {
|
||||
tradeModal,
|
||||
giftModal,
|
||||
recruitModal,
|
||||
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
mode,
|
||||
selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
selectedNpcItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
...current,
|
||||
selectedPlayerItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
setTradeQuantity: (quantity: number) => setTradeModal(current => current
|
||||
? {
|
||||
...current,
|
||||
selectedQuantity: clampTradeQuantity(gameState, current, quantity),
|
||||
}
|
||||
: current),
|
||||
closeTradeModal: () => setTradeModal(null),
|
||||
confirmTrade,
|
||||
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
|
||||
closeGiftModal: () => setGiftModal(null),
|
||||
confirmGift,
|
||||
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
|
||||
closeRecruitModal: () => setRecruitModal(null),
|
||||
confirmRecruit: () => {
|
||||
if (!recruitModal) return;
|
||||
void startRecruitmentSequence(
|
||||
recruitModal.encounter,
|
||||
recruitModal.actionText,
|
||||
recruitModal.selectedReleaseNpcId,
|
||||
);
|
||||
},
|
||||
} satisfies StoryGenerationNpcUi,
|
||||
openTradeModal,
|
||||
openGiftModal,
|
||||
openRecruitModal,
|
||||
startRecruitmentSequence,
|
||||
clearNpcInteractionUi,
|
||||
};
|
||||
}
|
||||
328
src/hooks/story/openingAdventure.ts
Normal file
328
src/hooks/story/openingAdventure.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from '../../data/functionCatalog';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import { generateNextStep } from '../../services/ai';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
|
||||
const ENCOUNTER_ENTRY_TICK_MS = 180;
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type PreparedOpeningAdventure = {
|
||||
encounterKey: string;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
fallbackText: string;
|
||||
openingOptions: StoryOption[];
|
||||
};
|
||||
|
||||
export function buildPreparedOpeningAdventure({
|
||||
state,
|
||||
character,
|
||||
getNpcEncounterKey,
|
||||
appendHistory,
|
||||
buildCampCompanionOpeningOptions,
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
}: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
appendHistory: (
|
||||
state: GameState,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
) => GameState['storyHistory'];
|
||||
buildCampCompanionOpeningOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => StoryOption[];
|
||||
buildCampCompanionOpeningResultText: (
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: GameState['worldType'],
|
||||
) => string;
|
||||
buildInitialCompanionDialogueText: (
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: GameState['worldType'],
|
||||
) => string;
|
||||
}): PreparedOpeningAdventure | null {
|
||||
const encounter = state.currentEncounter;
|
||||
if (
|
||||
!encounter ||
|
||||
encounter.kind !== 'npc' ||
|
||||
encounter.specialBehavior !== 'initial_companion'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const campScene = state.worldType
|
||||
? getWorldCampScenePreset(state.worldType)
|
||||
: null;
|
||||
const actionText = '开始冒险';
|
||||
const resultText = buildCampCompanionOpeningResultText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
);
|
||||
const dialogueText = buildInitialCompanionDialogueText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
);
|
||||
const resolvedEncounter: Encounter = {
|
||||
...encounter,
|
||||
specialBehavior: 'camp_companion',
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
const resolvedState: GameState = {
|
||||
...state,
|
||||
currentScenePreset: campScene ?? state.currentScenePreset,
|
||||
currentEncounter: resolvedEncounter,
|
||||
npcInteractionActive: false,
|
||||
};
|
||||
const nextHistory = appendHistory(state, actionText, resultText);
|
||||
const stateWithHistory: GameState = {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
|
||||
return {
|
||||
encounterKey: getNpcEncounterKey(encounter),
|
||||
actionText,
|
||||
resultText,
|
||||
fallbackText: dialogueText,
|
||||
openingOptions: buildCampCompanionOpeningOptions(
|
||||
stateWithHistory,
|
||||
character,
|
||||
resolvedEncounter,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function playOpeningAdventureSequence({
|
||||
gameState,
|
||||
character,
|
||||
encounter,
|
||||
preparedStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
character: Character;
|
||||
encounter: Encounter;
|
||||
preparedStory: PreparedOpeningAdventure;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildDialogueStoryMoment: (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { lastFunctionId?: string | null },
|
||||
) => StoryGenerationContext;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneMonsters'];
|
||||
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
|
||||
inferOpeningCampFollowupOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => Promise<StoryOption[]>;
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
}) {
|
||||
const {
|
||||
fallbackText,
|
||||
openingOptions,
|
||||
resultText: openingBackground,
|
||||
} = preparedStory;
|
||||
const actionText = `在营地与 ${encounter.npcName} 交换开场判断`;
|
||||
const campScene = gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType)
|
||||
: null;
|
||||
const entryState: GameState = {
|
||||
...gameState,
|
||||
currentScenePreset: campScene ?? gameState.currentScenePreset,
|
||||
currentEncounter: {
|
||||
...encounter,
|
||||
xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS,
|
||||
},
|
||||
};
|
||||
const resolvedEncounter: Encounter = {
|
||||
...encounter,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
const storyEncounter: Encounter = {
|
||||
...resolvedEncounter,
|
||||
specialBehavior: 'camp_companion',
|
||||
};
|
||||
const resolvedState: GameState = {
|
||||
...gameState,
|
||||
currentScenePreset: campScene ?? gameState.currentScenePreset,
|
||||
currentEncounter: resolvedEncounter,
|
||||
npcInteractionActive: false,
|
||||
};
|
||||
|
||||
setGameState(entryState);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (hasEncounterEntity(resolvedState)) {
|
||||
const runTicks = Math.max(
|
||||
1,
|
||||
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
|
||||
);
|
||||
const tickDurationMs = Math.max(
|
||||
1,
|
||||
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
|
||||
);
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
setGameState(
|
||||
interpolateEncounterTransitionState(
|
||||
entryState,
|
||||
resolvedState,
|
||||
progress,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, tickDurationMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const storyState: GameState = {
|
||||
...resolvedState,
|
||||
currentEncounter: storyEncounter,
|
||||
npcInteractionActive: false,
|
||||
};
|
||||
|
||||
setGameState(storyState);
|
||||
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||||
|
||||
let openingText = fallbackText;
|
||||
let resolvedOpeningOptions = sortStoryOptionsByPriority(openingOptions);
|
||||
|
||||
try {
|
||||
const response = await generateNextStep(
|
||||
gameState.worldType!,
|
||||
character,
|
||||
getStoryGenerationHostileNpcs(storyState),
|
||||
gameState.storyHistory,
|
||||
actionText,
|
||||
buildStoryContextFromState(storyState, {
|
||||
lastFunctionId: OPENING_CAMP_DIALOGUE_FUNCTION_ID,
|
||||
}),
|
||||
{
|
||||
availableOptions: openingOptions,
|
||||
},
|
||||
);
|
||||
|
||||
const generatedText = response.storyText.trim();
|
||||
if (
|
||||
generatedText &&
|
||||
hasRenderableDialogueTurns(generatedText, encounter.npcName)
|
||||
) {
|
||||
openingText = generatedText;
|
||||
}
|
||||
if (response.options.length > 0) {
|
||||
resolvedOpeningOptions = sortStoryOptionsByPriority(response.options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to infer opening camp dialogue:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
}
|
||||
|
||||
const finalHistory = [
|
||||
...gameState.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(openingText, 'result', openingOptions),
|
||||
];
|
||||
const finalState: GameState = {
|
||||
...storyState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
|
||||
setGameState(finalState);
|
||||
const openingOptionsPromise = inferOpeningCampFollowupOptions(
|
||||
finalState,
|
||||
character,
|
||||
resolvedOpeningOptions,
|
||||
openingBackground,
|
||||
openingText,
|
||||
);
|
||||
|
||||
let displayedText = '';
|
||||
for (const nextChar of openingText) {
|
||||
displayedText += nextChar;
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
|
||||
const finalOpeningOptions = await openingOptionsPromise;
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
openingText,
|
||||
finalOpeningOptions,
|
||||
false,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to play opening adventure sequence:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setCurrentStory(
|
||||
buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
fallbackText,
|
||||
openingOptions,
|
||||
false,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
163
src/hooks/story/progressionActions.ts
Normal file
163
src/hooks/story/progressionActions.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
|
||||
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
|
||||
const ENCOUNTER_ENTRY_TICK_MS = 180;
|
||||
|
||||
export type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: GameState['storyHistory'];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export type CommitGeneratedStateWithEncounterEntry = (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void>;
|
||||
|
||||
export function appendStoryHistory(
|
||||
state: GameState,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
): GameState['storyHistory'] {
|
||||
return [
|
||||
...state.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
}
|
||||
|
||||
export function createStoryProgressionActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
generateStoryForState,
|
||||
buildFallbackStoryForState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
const commitGeneratedState: CommitGeneratedState = async (
|
||||
nextState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory: GameState = {
|
||||
...nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue scripted story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry = async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
setGameState(entryState);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (hasEncounterEntity(resolvedState)) {
|
||||
const runTicks = Math.max(1, Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS));
|
||||
const tickDurationMs = Math.max(1, Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks));
|
||||
|
||||
for (let tick = 1; tick <= runTicks; tick += 1) {
|
||||
const progress = tick / runTicks;
|
||||
setGameState(interpolateEncounterTransitionState(entryState, resolvedState, progress));
|
||||
await new Promise(resolve => window.setTimeout(resolve, tickDurationMs));
|
||||
}
|
||||
}
|
||||
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory: GameState = {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue encounter-entry story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
|
||||
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
};
|
||||
}
|
||||
176
src/hooks/story/sessionActions.test.ts
Normal file
176
src/hooks/story/sessionActions.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildInitialNpcState } from '../../data/npcInteractions';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
type QuestLogEntry,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
acknowledgeQuestCompletionState,
|
||||
applyQuestRewardClaim,
|
||||
} from './sessionActions';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(id: string, name: string, quantity = 1): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createQuest(status: QuestLogEntry['status']): QuestLogEntry {
|
||||
return {
|
||||
id: 'quest-1',
|
||||
issuerNpcId: 'npc-trader',
|
||||
issuerNpcName: 'Trader Lin',
|
||||
sceneId: 'scene-1',
|
||||
title: 'Deliver the cache',
|
||||
description: 'Deliver the cache safely.',
|
||||
summary: 'Help Trader Lin recover the cache.',
|
||||
objective: {
|
||||
kind: 'deliver_item',
|
||||
targetItemId: 'cache',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status,
|
||||
reward: {
|
||||
affinityBonus: 3,
|
||||
currency: 12,
|
||||
items: [createInventoryItem('reward-herb', 'Reward Herb', 2)],
|
||||
},
|
||||
rewardText: 'Trader Lin rewards you for the delivery.',
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
const encounter = {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc' as const,
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 5,
|
||||
playerInventory: [createInventoryItem('starter-potion', 'Starter Potion')],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-trader': {
|
||||
...buildInitialNpcState(encounter, WorldType.WUXIA),
|
||||
affinity: 4,
|
||||
},
|
||||
},
|
||||
quests: [createQuest('completed')],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('sessionActions', () => {
|
||||
it('marks quest completion notifications without changing unrelated quest state', () => {
|
||||
const nextState = acknowledgeQuestCompletionState(createBaseState(), 'quest-1');
|
||||
|
||||
expect(nextState.quests[0]?.completionNotified).toBe(true);
|
||||
expect(nextState.quests[0]?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('returns null when trying to claim a reward for a quest that is not completed', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
quests: [createQuest('active')],
|
||||
};
|
||||
|
||||
expect(applyQuestRewardClaim(state, 'quest-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('applies quest rewards to currency, inventory, and issuer affinity in one state transition', () => {
|
||||
const nextState = applyQuestRewardClaim(createBaseState(), 'quest-1');
|
||||
|
||||
expect(nextState).not.toBeNull();
|
||||
if (!nextState) {
|
||||
throw new Error('Expected quest reward claim state');
|
||||
}
|
||||
|
||||
expect(nextState.quests[0]?.status).toBe('turned_in');
|
||||
expect(nextState.playerCurrency).toBe(17);
|
||||
expect(nextState.playerInventory.find(item => item.id === 'reward-herb')?.quantity).toBe(2);
|
||||
expect(nextState.npcStates['npc-trader']?.affinity).toBe(7);
|
||||
});
|
||||
});
|
||||
139
src/hooks/story/sessionActions.ts
Normal file
139
src/hooks/story/sessionActions.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
findQuestById,
|
||||
markQuestCompletionNotified,
|
||||
markQuestTurnedIn,
|
||||
} from '../../data/questFlow';
|
||||
import type {
|
||||
GameState,
|
||||
StoryMoment,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: NonNullable<GameState['playerCharacter']>,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function acknowledgeQuestCompletionState(
|
||||
state: GameState,
|
||||
questId: string,
|
||||
): GameState {
|
||||
return {
|
||||
...state,
|
||||
quests: markQuestCompletionNotified(state.quests, questId),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyQuestRewardClaim(
|
||||
state: GameState,
|
||||
questId: string,
|
||||
): GameState | null {
|
||||
const quest = findQuestById(state.quests, questId);
|
||||
if (!quest || quest.status !== 'completed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issuerNpcState = state.npcStates[quest.issuerNpcId];
|
||||
|
||||
return {
|
||||
...state,
|
||||
quests: markQuestTurnedIn(state.quests, questId),
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
|
||||
npcStates: issuerNpcState
|
||||
? {
|
||||
...state.npcStates,
|
||||
[quest.issuerNpcId]: {
|
||||
...issuerNpcState,
|
||||
affinity: issuerNpcState.affinity + quest.reward.affinityBonus,
|
||||
},
|
||||
}
|
||||
: state.npcStates,
|
||||
};
|
||||
}
|
||||
|
||||
export function createStorySessionActions({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
clearStoryRuntimeUi,
|
||||
commitGeneratedState,
|
||||
buildFallbackStoryForState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
clearStoryRuntimeUi: () => void;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
const acknowledgeQuestCompletion = (questId: string) => {
|
||||
setGameState(currentState => acknowledgeQuestCompletionState(currentState, questId));
|
||||
};
|
||||
|
||||
const claimQuestReward = (questId: string) => {
|
||||
const nextState = applyQuestRewardClaim(gameState, questId);
|
||||
if (!nextState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setGameState(nextState);
|
||||
return true;
|
||||
};
|
||||
|
||||
const resetStoryState = () => {
|
||||
setCurrentStory(null);
|
||||
clearStoryRuntimeUi();
|
||||
};
|
||||
|
||||
const hydrateStoryState = (story: StoryMoment | null) => {
|
||||
setCurrentStory(story);
|
||||
clearStoryRuntimeUi();
|
||||
};
|
||||
|
||||
const travelToSceneFromMap = (sceneId: string) => {
|
||||
if (!gameState.playerCharacter || isLoading || gameState.inBattle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const travelResolution = buildMapTravelResolution(gameState, sceneId);
|
||||
if (!travelResolution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(
|
||||
travelResolution.nextState,
|
||||
gameState.playerCharacter,
|
||||
travelResolution.travelResultText,
|
||||
),
|
||||
);
|
||||
|
||||
void commitGeneratedState(
|
||||
travelResolution.nextState,
|
||||
gameState.playerCharacter,
|
||||
travelResolution.actionText,
|
||||
travelResolution.travelResultText,
|
||||
'idle_travel_next_scene',
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
};
|
||||
}
|
||||
261
src/hooks/story/storyGenerationState.test.ts
Normal file
261
src/hooks/story/storyGenerationState.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { scenes } = vi.hoisted(() => ({
|
||||
scenes: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
name: 'Camp',
|
||||
description: 'A quiet camp.',
|
||||
imageSrc: '/camp.png',
|
||||
connectedSceneIds: ['scene-2'],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
name: 'Trail',
|
||||
description: 'A mountain trail.',
|
||||
imageSrc: '/trail.png',
|
||||
connectedSceneIds: ['scene-1'],
|
||||
monsterIds: [],
|
||||
npcs: [],
|
||||
treasureHints: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../../data/scenePresets', () => ({
|
||||
getScenePresetById: (_worldType: unknown, sceneId: string) =>
|
||||
scenes.find(scene => scene.id === sceneId) ?? null,
|
||||
getSceneFriendlyNpcs: (scene: { npcs?: unknown[] } | null | undefined) => scene?.npcs ?? [],
|
||||
getSceneHostileNpcs: () => [],
|
||||
getScenePresetsByWorld: () => scenes,
|
||||
getWorldCampScenePreset: () => scenes[0] ?? null,
|
||||
}));
|
||||
|
||||
import { buildInitialNpcState, MAX_COMPANIONS } from '../../data/npcInteractions';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CompanionState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildMapTravelResolution,
|
||||
resolveNpcInteractionDecision,
|
||||
} from './storyGenerationState';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: 'Hero',
|
||||
title: 'Wanderer',
|
||||
description: 'A reliable test hero.',
|
||||
backstory: 'Travels the land.',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 7,
|
||||
},
|
||||
personality: 'steady',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createInventoryItem(id: string, name: string): InventoryItem {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
quantity: 1,
|
||||
category: 'misc',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-trader',
|
||||
kind: 'npc',
|
||||
npcName: 'Trader Lin',
|
||||
npcDescription: 'A traveling merchant.',
|
||||
npcAvatar: 'T',
|
||||
context: 'merchant',
|
||||
};
|
||||
}
|
||||
|
||||
function createCompanion(npcId: string): CompanionState {
|
||||
return {
|
||||
npcId,
|
||||
characterId: `character-${npcId}`,
|
||||
joinedAtAffinity: 10,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
mana: 5,
|
||||
maxMana: 5,
|
||||
skillCooldowns: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createBaseState(): GameState {
|
||||
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
||||
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: createEncounter(),
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: scenes[0] ?? null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 10,
|
||||
playerInventory: [createInventoryItem('player-potion', 'Potion')],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-trader': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.WUXIA),
|
||||
inventory: [createInventoryItem('npc-herb', 'Herb')],
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createInteractionOption(action: Extract<NonNullable<StoryOption['interaction']>, { kind: 'npc' }>['action']): StoryOption {
|
||||
return {
|
||||
functionId: `npc_${action}`,
|
||||
actionText: action,
|
||||
text: action,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-trader',
|
||||
action,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyGenerationState', () => {
|
||||
it('opens the trade modal with the first npc and player inventory items selected', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
createBaseState(),
|
||||
createInteractionOption('trade'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('trade_modal');
|
||||
if (decision.kind !== 'trade_modal') {
|
||||
throw new Error('Expected trade modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedNpcItemId).toBe('npc-herb');
|
||||
expect(decision.modal.selectedPlayerItemId).toBe('player-potion');
|
||||
expect(decision.modal.selectedQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('forces a recruit replacement modal when the active party is full', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
companions: Array.from({ length: MAX_COMPANIONS }, (_, index) => createCompanion(`npc-${index + 1}`)),
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createInteractionOption('recruit'),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('recruit_modal');
|
||||
if (decision.kind !== 'recruit_modal') {
|
||||
throw new Error('Expected recruit modal decision');
|
||||
}
|
||||
|
||||
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
|
||||
});
|
||||
|
||||
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
|
||||
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
|
||||
const sourceScene = scenes[0];
|
||||
const targetScene = scenes[1]!;
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: sourceScene ?? null,
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'battle-npc',
|
||||
currentNpcBattleMode: 'fight' as const,
|
||||
currentNpcBattleOutcome: 'fight_victory' as const,
|
||||
sparReturnEncounter: createEncounter(),
|
||||
};
|
||||
|
||||
const resolution = buildMapTravelResolution(state, targetScene.id);
|
||||
|
||||
expect(resolution).not.toBeNull();
|
||||
if (!resolution) {
|
||||
throw new Error('Expected map travel resolution');
|
||||
}
|
||||
|
||||
expect(resolution.nextState.currentScenePreset?.id).toBe(targetScene.id);
|
||||
expect(resolution.nextState.npcInteractionActive).toBe(false);
|
||||
expect(resolution.nextState.inBattle).toBe(false);
|
||||
expect(resolution.nextState.currentBattleNpcId).toBeNull();
|
||||
expect(resolution.nextState.currentNpcBattleMode).toBeNull();
|
||||
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
150
src/hooks/story/storyGenerationState.ts
Normal file
150
src/hooks/story/storyGenerationState.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
NPC_GIFT_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
NPC_TRADE_FUNCTION,
|
||||
shouldNpcRecruitOpenModal,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePresetById } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
TradeModalState,
|
||||
} from './uiTypes';
|
||||
|
||||
export type NpcInteractionDecision =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'trade_modal'; modal: TradeModalState }
|
||||
| { kind: 'gift_modal'; modal: GiftModalState }
|
||||
| { kind: 'recruit_modal'; modal: RecruitModalState }
|
||||
| { kind: 'recruit_immediate' };
|
||||
|
||||
export type MapTravelResolution = {
|
||||
nextState: GameState;
|
||||
actionText: string;
|
||||
travelResultText: string;
|
||||
};
|
||||
|
||||
function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
export function getNpcEncounterKey(encounter: Encounter) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
}
|
||||
|
||||
function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNpcInteractionDecision(
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
): NpcInteractionDecision {
|
||||
if (
|
||||
!state.playerCharacter ||
|
||||
!option.interaction ||
|
||||
!isNpcEncounter(state.currentEncounter)
|
||||
) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
const encounter = state.currentEncounter;
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
|
||||
switch (option.functionId) {
|
||||
case NPC_TRADE_FUNCTION.id:
|
||||
return {
|
||||
kind: 'trade_modal',
|
||||
modal: buildNpcTradeModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.inventory,
|
||||
),
|
||||
};
|
||||
case NPC_GIFT_FUNCTION.id:
|
||||
return {
|
||||
kind: 'gift_modal',
|
||||
modal: buildNpcGiftModalState(state, encounter, option.actionText),
|
||||
};
|
||||
case NPC_RECRUIT_FUNCTION.id:
|
||||
if (shouldNpcRecruitOpenModal(state.companions.length, MAX_COMPANIONS)) {
|
||||
return {
|
||||
kind: 'recruit_modal',
|
||||
modal: buildNpcRecruitModalState(state, encounter, option.actionText),
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'recruit_immediate' };
|
||||
default:
|
||||
return { kind: 'none' };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMapTravelResolution(
|
||||
state: GameState,
|
||||
sceneId: string,
|
||||
): MapTravelResolution | null {
|
||||
if (!state.worldType || !state.playerCharacter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetScene = getScenePresetById(state.worldType, sceneId);
|
||||
if (!targetScene || targetScene.id === state.currentScenePreset?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextState = ensureSceneEncounterPreview({
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
|
||||
scenesTraveled: 1,
|
||||
}),
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
});
|
||||
const travelResultText = `你离开${state.currentScenePreset?.name ?? '当前位置'},前往${targetScene.name}。`;
|
||||
|
||||
return {
|
||||
nextState,
|
||||
actionText: `前往${targetScene.name}`,
|
||||
travelResultText,
|
||||
};
|
||||
}
|
||||
84
src/hooks/story/uiTypes.ts
Normal file
84
src/hooks/story/uiTypes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
Encounter,
|
||||
InventoryItem,
|
||||
} from '../../types';
|
||||
|
||||
export type TradeModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
mode: 'buy' | 'sell';
|
||||
selectedNpcItemId: string | null;
|
||||
selectedPlayerItemId: string | null;
|
||||
selectedQuantity: number;
|
||||
};
|
||||
|
||||
export type GiftModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
selectedItemId: string | null;
|
||||
};
|
||||
|
||||
export type RecruitModalState = {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
selectedReleaseNpcId: string | null;
|
||||
};
|
||||
|
||||
export interface StoryGenerationNpcUi {
|
||||
tradeModal: TradeModalState | null;
|
||||
giftModal: GiftModalState | null;
|
||||
recruitModal: RecruitModalState | null;
|
||||
setTradeMode: (mode: 'buy' | 'sell') => void;
|
||||
selectTradeNpcItem: (itemId: string) => void;
|
||||
selectTradePlayerItem: (itemId: string) => void;
|
||||
setTradeQuantity: (quantity: number) => void;
|
||||
closeTradeModal: () => void;
|
||||
confirmTrade: () => void;
|
||||
selectGiftItem: (itemId: string) => void;
|
||||
closeGiftModal: () => void;
|
||||
confirmGift: () => void;
|
||||
selectRecruitRelease: (npcId: string) => void;
|
||||
closeRecruitModal: () => void;
|
||||
confirmRecruit: () => void;
|
||||
}
|
||||
|
||||
export interface InventoryFlowUi {
|
||||
useInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
equipInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
unequipItem: (slot: 'weapon' | 'armor' | 'relic') => Promise<boolean>;
|
||||
forgeRecipes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'synthesis' | 'forge';
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
}>;
|
||||
craftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
dismantleItem: (itemId: string) => Promise<boolean>;
|
||||
reforgeItem: (itemId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface QuestFlowUi {
|
||||
acknowledgeQuestCompletion: (questId: string) => void;
|
||||
claimQuestReward: (questId: string) => boolean;
|
||||
}
|
||||
|
||||
export interface BattleRewardSummary {
|
||||
id: string;
|
||||
defeatedHostileNpcs: Array<{ id: string; name: string }>;
|
||||
items: InventoryItem[];
|
||||
}
|
||||
|
||||
export interface BattleRewardUi {
|
||||
reward: BattleRewardSummary | null;
|
||||
dismiss: () => void;
|
||||
}
|
||||
215
src/hooks/useBackgroundMusic.ts
Normal file
215
src/hooks/useBackgroundMusic.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
type AudioWindow = Window & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
function scheduleTone(
|
||||
context: AudioContext,
|
||||
destination: GainNode,
|
||||
frequency: number,
|
||||
startTime: number,
|
||||
duration: number,
|
||||
options: {
|
||||
gain: number;
|
||||
type: OscillatorType;
|
||||
attack?: number;
|
||||
release?: number;
|
||||
detune?: number;
|
||||
},
|
||||
) {
|
||||
const oscillator = context.createOscillator();
|
||||
const gainNode = context.createGain();
|
||||
const attack = options.attack ?? 0.05;
|
||||
const release = options.release ?? Math.max(0.18, duration * 0.5);
|
||||
const peakGain = options.gain;
|
||||
const releaseStart = Math.max(startTime + attack, startTime + duration - release);
|
||||
|
||||
oscillator.type = options.type;
|
||||
oscillator.frequency.setValueAtTime(frequency, startTime);
|
||||
oscillator.detune.setValueAtTime(options.detune ?? 0, startTime);
|
||||
|
||||
gainNode.gain.setValueAtTime(0.0001, startTime);
|
||||
gainNode.gain.linearRampToValueAtTime(peakGain, startTime + attack);
|
||||
gainNode.gain.setValueAtTime(peakGain, releaseStart);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(destination);
|
||||
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + duration + 0.02);
|
||||
}
|
||||
|
||||
function scheduleChord(context: AudioContext, destination: GainNode, notes: number[], startTime: number) {
|
||||
notes.forEach((frequency, index) => {
|
||||
scheduleTone(context, destination, frequency, startTime, 2.45, {
|
||||
gain: index === 0 ? 0.028 : 0.022,
|
||||
type: index === 0 ? 'triangle' : 'sine',
|
||||
attack: 0.12,
|
||||
release: 1.4,
|
||||
detune: index === 1 ? -4 : index === 2 ? 4 : 0,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAccent(context: AudioContext, destination: GainNode, frequency: number, startTime: number) {
|
||||
scheduleTone(context, destination, frequency, startTime, 0.32, {
|
||||
gain: 0.032,
|
||||
type: 'triangle',
|
||||
attack: 0.01,
|
||||
release: 0.18,
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleBass(context: AudioContext, destination: GainNode, frequency: number, startTime: number) {
|
||||
scheduleTone(context, destination, frequency, startTime, 1.9, {
|
||||
gain: 0.024,
|
||||
type: 'sine',
|
||||
attack: 0.02,
|
||||
release: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackgroundMusic({
|
||||
active,
|
||||
volume,
|
||||
}: {
|
||||
active: boolean;
|
||||
volume: number;
|
||||
}) {
|
||||
const contextRef = useRef<AudioContext | null>(null);
|
||||
const masterGainRef = useRef<GainNode | null>(null);
|
||||
const loopTimerRef = useRef<number | null>(null);
|
||||
const stepRef = useRef(0);
|
||||
const activeRef = useRef(active);
|
||||
const volumeRef = useRef(volume);
|
||||
|
||||
const stopLoop = useCallback(() => {
|
||||
if (loopTimerRef.current !== null) {
|
||||
window.clearTimeout(loopTimerRef.current);
|
||||
loopTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureAudioGraph = useCallback(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const AudioContextCtor = window.AudioContext ?? (window as AudioWindow).webkitAudioContext;
|
||||
if (!AudioContextCtor) return null;
|
||||
|
||||
if (!contextRef.current) {
|
||||
contextRef.current = new AudioContextCtor();
|
||||
}
|
||||
|
||||
if (!masterGainRef.current) {
|
||||
const masterGain = contextRef.current.createGain();
|
||||
masterGain.gain.value = 0.0001;
|
||||
masterGain.connect(contextRef.current.destination);
|
||||
masterGainRef.current = masterGain;
|
||||
}
|
||||
|
||||
return {
|
||||
context: contextRef.current,
|
||||
masterGain: masterGainRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleLoop = useCallback(() => {
|
||||
const graph = ensureAudioGraph();
|
||||
if (!graph || !activeRef.current || volumeRef.current <= 0) {
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
const { context, masterGain } = graph;
|
||||
const progression = [
|
||||
[220, 277.18, 329.63],
|
||||
[246.94, 311.13, 369.99],
|
||||
[196, 246.94, 293.66],
|
||||
[174.61, 220, 261.63],
|
||||
];
|
||||
const chord = progression[stepRef.current % progression.length] ?? progression[0];
|
||||
if (!chord) {
|
||||
return;
|
||||
}
|
||||
const [bassNote, midNote, topNote] = chord;
|
||||
if (bassNote === undefined || midNote === undefined || topNote === undefined) {
|
||||
return;
|
||||
}
|
||||
const startTime = context.currentTime + 0.08;
|
||||
|
||||
scheduleChord(context, masterGain, chord, startTime);
|
||||
scheduleBass(context, masterGain, bassNote / 2, startTime);
|
||||
scheduleAccent(context, masterGain, topNote * 2, startTime + 0.24);
|
||||
scheduleAccent(context, masterGain, midNote * 2, startTime + 1.12);
|
||||
|
||||
stepRef.current += 1;
|
||||
loopTimerRef.current = window.setTimeout(scheduleLoop, 2200);
|
||||
}, [ensureAudioGraph, stopLoop]);
|
||||
|
||||
const updateMasterVolume = useCallback((graph?: { context: AudioContext; masterGain: GainNode } | null) => {
|
||||
const audioGraph = graph ?? ensureAudioGraph();
|
||||
if (!audioGraph) return;
|
||||
|
||||
const targetGain = activeRef.current && volumeRef.current > 0
|
||||
? Math.max(0.0001, volumeRef.current * 0.18)
|
||||
: 0.0001;
|
||||
|
||||
audioGraph.masterGain.gain.cancelScheduledValues(audioGraph.context.currentTime);
|
||||
audioGraph.masterGain.gain.linearRampToValueAtTime(targetGain, audioGraph.context.currentTime + 0.24);
|
||||
}, [ensureAudioGraph]);
|
||||
|
||||
const startPlayback = useCallback(async () => {
|
||||
const graph = ensureAudioGraph();
|
||||
if (!graph || !activeRef.current || volumeRef.current <= 0) return;
|
||||
|
||||
if (graph.context.state === 'suspended') {
|
||||
await graph.context.resume();
|
||||
}
|
||||
|
||||
updateMasterVolume(graph);
|
||||
|
||||
if (loopTimerRef.current === null) {
|
||||
scheduleLoop();
|
||||
}
|
||||
}, [ensureAudioGraph, scheduleLoop, updateMasterVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
volumeRef.current = volume;
|
||||
|
||||
if (!active || volume <= 0) {
|
||||
updateMasterVolume();
|
||||
stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
void startPlayback();
|
||||
|
||||
const handleUserGesture = () => {
|
||||
void startPlayback();
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', handleUserGesture);
|
||||
window.addEventListener('keydown', handleUserGesture);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handleUserGesture);
|
||||
window.removeEventListener('keydown', handleUserGesture);
|
||||
};
|
||||
}, [active, startPlayback, stopLoop, updateMasterVolume, volume]);
|
||||
|
||||
useEffect(() => () => {
|
||||
stopLoop();
|
||||
|
||||
if (masterGainRef.current && contextRef.current) {
|
||||
masterGainRef.current.gain.cancelScheduledValues(contextRef.current.currentTime);
|
||||
masterGainRef.current.gain.value = 0.0001;
|
||||
}
|
||||
|
||||
if (contextRef.current) {
|
||||
void contextRef.current.close();
|
||||
}
|
||||
}, [stopLoop]);
|
||||
}
|
||||
67
src/hooks/useCombatFlow.ts
Normal file
67
src/hooks/useCombatFlow.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryOption,
|
||||
} from '../types';
|
||||
import {
|
||||
applyRecoveryEffectToState,
|
||||
type BattlePlan,
|
||||
buildBattlePlan as buildBattlePlanFromEngine,
|
||||
} from './combat/battlePlan';
|
||||
import { createCombatPlayback } from './combat/playback';
|
||||
import type { EscapePlaybackSync } from './combat/escapeFlow';
|
||||
import {
|
||||
buildResolvedChoiceState as buildResolvedChoiceStateFromEngine,
|
||||
type ResolvedChoiceState,
|
||||
} from './combat/resolvedChoice';
|
||||
export { buildSkillEffects } from './combat/skillEffects';
|
||||
export type { ResolvedChoiceState } from './combat/resolvedChoice';
|
||||
|
||||
const TOTAL_SEQUENCE_MS = 6000;
|
||||
const TURN_VISUAL_MS = 820;
|
||||
const RESET_STAGE_MS = 260;
|
||||
const MIN_TURN_COUNT = 6;
|
||||
|
||||
export type ResolvedChoicePlaybackSync = EscapePlaybackSync;
|
||||
void applyRecoveryEffectToState;
|
||||
|
||||
export function useCombatFlow({
|
||||
setGameState,
|
||||
}: {
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
}) {
|
||||
const buildBattlePlan = (state: GameState, option: StoryOption, character: Character): BattlePlan =>
|
||||
buildBattlePlanFromEngine({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
totalSequenceMs: TOTAL_SEQUENCE_MS,
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
resetStageMs: RESET_STAGE_MS,
|
||||
minTurnCount: MIN_TURN_COUNT,
|
||||
});
|
||||
|
||||
const buildResolvedChoiceState = (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
): ResolvedChoiceState => buildResolvedChoiceStateFromEngine({
|
||||
state,
|
||||
option,
|
||||
character,
|
||||
buildBattlePlan,
|
||||
});
|
||||
|
||||
const { playResolvedChoice } = createCombatPlayback({
|
||||
setGameState,
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
resetStageMs: RESET_STAGE_MS,
|
||||
});
|
||||
|
||||
return {
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
};
|
||||
}
|
||||
133
src/hooks/useEquipmentFlow.ts
Normal file
133
src/hooks/useEquipmentFlow.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
removeInventoryItem,
|
||||
} from '../data/npcInteractions';
|
||||
import { EquipmentSlotId, GameState, InventoryItem } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
function normalizeEquippedItem(item: InventoryItem) {
|
||||
return {
|
||||
...item,
|
||||
quantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipResultText(
|
||||
item: InventoryItem,
|
||||
slot: EquipmentSlotId,
|
||||
replacedItem?: InventoryItem | null,
|
||||
) {
|
||||
return replacedItem
|
||||
? `你将${replacedItem.name}从${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}。`
|
||||
: `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`;
|
||||
}
|
||||
|
||||
function buildUnequipResultText(item: InventoryItem) {
|
||||
return `你卸下了${item.name},暂时收回背包。`;
|
||||
}
|
||||
|
||||
export function useEquipmentFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const handleEquipInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || item.quantity <= 0) return false;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot) return false;
|
||||
|
||||
const replacedItem = gameState.playerEquipment[slot];
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: normalizeEquippedItem(item),
|
||||
};
|
||||
|
||||
let nextInventory = removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
);
|
||||
if (replacedItem) {
|
||||
nextInventory = addInventoryItems(nextInventory, [replacedItem]);
|
||||
}
|
||||
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: nextInventory,
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`装备${item.name}`,
|
||||
buildEquipResultText(item, slot, replacedItem),
|
||||
EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleUnequipItem = useCallback(
|
||||
async (slot: EquipmentSlotId) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) return false;
|
||||
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: null,
|
||||
};
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: addInventoryItems(gameState.playerInventory, [
|
||||
equippedItem,
|
||||
]),
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`卸下${equippedItem.name}`,
|
||||
buildUnequipResultText(equippedItem),
|
||||
EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
handleEquipInventoryItem,
|
||||
handleUnequipItem,
|
||||
};
|
||||
}
|
||||
158
src/hooks/useForgeFlow.ts
Normal file
158
src/hooks/useForgeFlow.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
buildForgeSuccessText,
|
||||
executeDismantleItem,
|
||||
executeForgeRecipe,
|
||||
executeReforgeItem,
|
||||
getForgeRecipeViews,
|
||||
getReforgeCostView,
|
||||
} from '../data/forgeSystem';
|
||||
import {
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import type { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
export function useForgeFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const handleCraftRecipe = useCallback(
|
||||
async (recipeId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const result = executeForgeRecipe(
|
||||
gameState.playerInventory,
|
||||
recipeId,
|
||||
gameState.worldType,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: result.currency,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`制作${result.createdItem.name}`,
|
||||
buildForgeSuccessText('craft', {
|
||||
recipeName: recipe?.name ?? recipeId,
|
||||
createdItemName: result.createdItem.name,
|
||||
currencyText: recipe?.currencyText,
|
||||
}),
|
||||
FORGE_CRAFT_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, forgeRecipes, gameState],
|
||||
);
|
||||
|
||||
const handleDismantleItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeDismantleItem(gameState.playerInventory, itemId);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`拆解${sourceItem.name}`,
|
||||
buildForgeSuccessText('dismantle', {
|
||||
sourceItemName: sourceItem.name,
|
||||
outputNames: result.outputs.map((item) => item.name),
|
||||
}),
|
||||
FORGE_DISMANTLE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleReforgeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeReforgeItem(
|
||||
gameState.playerInventory,
|
||||
itemId,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: Math.max(
|
||||
0,
|
||||
gameState.playerCurrency - result.currencyCost,
|
||||
),
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
const reforgeCost = getReforgeCostView(sourceItem, gameState.worldType);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`重铸${sourceItem.name}`,
|
||||
buildForgeSuccessText('reforge', {
|
||||
sourceItemName: sourceItem.name,
|
||||
createdItemName: result.reforgedItem.name,
|
||||
currencyText: reforgeCost.currencyText,
|
||||
}),
|
||||
FORGE_REFORGE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
forgeRecipes,
|
||||
handleCraftRecipe,
|
||||
handleDismantleItem,
|
||||
handleReforgeItem,
|
||||
getReforgeCostView,
|
||||
};
|
||||
}
|
||||
233
src/hooks/useGameFlow.ts
Normal file
233
src/hooks/useGameFlow.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
createCharacterSkillCooldowns,
|
||||
getCharacterMaxMana,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { getInitialPlayerCurrency } from '../data/economy';
|
||||
import { applyEquipmentLoadoutToState, buildInitialEquipmentLoadout, createEmptyEquipmentLoadout } from '../data/equipmentEffects';
|
||||
import { buildInitialNpcState, buildInitialPlayerInventory } from '../data/npcInteractions';
|
||||
import { createInitialGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews';
|
||||
import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets';
|
||||
import { AnimationState, Character, CustomWorldProfile, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
|
||||
const PLAYER_MAX_HP = 180;
|
||||
|
||||
export type {BottomTab} from '../types/navigation';
|
||||
|
||||
function createInitialCampEncounter(
|
||||
worldType: WorldType | null,
|
||||
playerCharacter: Character,
|
||||
): Encounter | null {
|
||||
if (!worldType) return null;
|
||||
|
||||
const campScenePreset = getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
|
||||
const npcCandidates = (campScenePreset?.npcs ?? [])
|
||||
.filter((npc: SceneNpc) => Boolean(npc.characterId))
|
||||
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
|
||||
if (npcCandidates.length === 0) return null;
|
||||
|
||||
const npc = npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
|
||||
if (!npc) return null;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc',
|
||||
characterId: npc.characterId,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
specialBehavior: 'initial_companion',
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialGameState(): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: createInitialGameRuntimeStats(),
|
||||
currentScene: 'Selection',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: PLAYER_MAX_HP,
|
||||
playerMaxHp: PLAYER_MAX_HP,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 0,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGameFlow() {
|
||||
const [gameState, setGameState] = useState<GameState>(() => createInitialGameState());
|
||||
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
gameState.customWorldProfile ? buildCustomWorldPlayableCharacters(gameState.customWorldProfile) : null,
|
||||
);
|
||||
}, [gameState.customWorldProfile]);
|
||||
|
||||
const resetGame = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
};
|
||||
|
||||
const handleWorldSelect = (type: WorldType, customWorldProfile: CustomWorldProfile | null = null) => {
|
||||
const resolvedWorldType = customWorldProfile ? WorldType.CUSTOM : type;
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : null,
|
||||
);
|
||||
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
||||
setIsMapOpen(false);
|
||||
setGameState(prev =>
|
||||
ensureSceneEncounterPreview({
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
currentScenePreset: initialScenePreset,
|
||||
sceneMonsters: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
activeBuildBuffs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (character: Character) => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
|
||||
const initialScenePreset = gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType) ?? getScenePreset(gameState.worldType, 0)
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(gameState.worldType, character);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, gameState.worldType)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(character);
|
||||
|
||||
setGameState(prev =>
|
||||
ensureSceneEncounterPreview(
|
||||
applyEquipmentLoadoutToState({
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: initialScenePreset,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: PLAYER_MAX_HP,
|
||||
playerMaxHp: PLAYER_MAX_HP,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(gameState.worldType),
|
||||
playerInventory: buildInitialPlayerInventory(character, gameState.worldType),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates: initialEncounter && initialNpcState
|
||||
? {
|
||||
[initialEncounter.id!]: initialNpcState,
|
||||
}
|
||||
: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}, initialEquipment),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
};
|
||||
}
|
||||
213
src/hooks/useGamePersistence.ts
Normal file
213
src/hooks/useGamePersistence.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import { getCharacterMaxMana } from '../data/characterPresets';
|
||||
import { normalizeRoster } from '../data/companionRoster';
|
||||
import { getInitialPlayerCurrency } from '../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../data/equipmentEffects';
|
||||
import { normalizeNpcPersistentState } from '../data/npcInteractions';
|
||||
import { normalizeQuestLogEntries } from '../data/questFlow';
|
||||
import { normalizeGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews';
|
||||
import {clearSavedSnapshot, readSavedSnapshot, writeSavedSnapshot} from '../persistence/gameSaveStorage';
|
||||
import { GameState, StoryMoment } from '../types';
|
||||
import { BottomTab } from './useGameFlow';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function normalizeSavedStory(story: StoryMoment | null) {
|
||||
if (!story) return null;
|
||||
return {
|
||||
...story,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function normalizeCharacterChats(gameState: GameState) {
|
||||
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
|
||||
characterId,
|
||||
{
|
||||
history: Array.isArray(record?.history)
|
||||
? record.history
|
||||
.filter(turn => turn && typeof turn.text === 'string' && (turn.speaker === 'player' || turn.speaker === 'character'))
|
||||
.map(turn => ({
|
||||
speaker: turn.speaker,
|
||||
text: turn.text,
|
||||
}))
|
||||
: [],
|
||||
summary: typeof record?.summary === 'string' ? record.summary : '',
|
||||
updatedAt: typeof record?.updatedAt === 'string' ? record.updatedAt : null,
|
||||
},
|
||||
] as const);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function normalizeSavedGameState(gameState: GameState) {
|
||||
const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []);
|
||||
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && gameState.currentEncounter?.kind === 'treasure'
|
||||
? ensureSceneEncounterPreview({
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
sceneMonsters: [],
|
||||
inBattle: false,
|
||||
} as GameState)
|
||||
: gameState;
|
||||
const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, {
|
||||
isActiveRun: Boolean(
|
||||
normalizedEncounterState.playerCharacter &&
|
||||
normalizedEncounterState.currentScene === 'Story'
|
||||
),
|
||||
});
|
||||
const normalizedCommonState = {
|
||||
...normalizedEncounterState,
|
||||
customWorldProfile: normalizedEncounterState.customWorldProfile ?? null,
|
||||
runtimeStats: normalizedRuntimeStats,
|
||||
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
|
||||
playerCurrency: typeof gameState.playerCurrency === 'number'
|
||||
? gameState.playerCurrency
|
||||
: getInitialPlayerCurrency(gameState.worldType),
|
||||
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
|
||||
roster: normalizedRoster,
|
||||
npcStates: Object.fromEntries(
|
||||
Object.entries(normalizedEncounterState.npcStates ?? {}).map(([npcId, npcState]) => [
|
||||
npcId,
|
||||
normalizeNpcPersistentState(npcState),
|
||||
]),
|
||||
),
|
||||
characterChats: normalizeCharacterChats(normalizedEncounterState),
|
||||
activeBuildBuffs: normalizedEncounterState.activeBuildBuffs ?? [],
|
||||
} satisfies GameState;
|
||||
|
||||
if (!normalizedEncounterState.playerCharacter) {
|
||||
return {
|
||||
...normalizedCommonState,
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
const resolvedEquipment = normalizedEncounterState.playerEquipment
|
||||
? normalizedEncounterState.playerEquipment
|
||||
: buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter);
|
||||
|
||||
return applyEquipmentLoadoutToState({
|
||||
...normalizedCommonState,
|
||||
playerMaxHp: PLAYER_BASE_MAX_HP,
|
||||
playerHp: Math.min(normalizedEncounterState.playerHp, PLAYER_BASE_MAX_HP),
|
||||
playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
} as GameState, resolvedEquipment);
|
||||
}
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
return (
|
||||
gameState.currentScene === 'Story' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
Boolean(gameState.playerCharacter) &&
|
||||
story?.streaming !== true
|
||||
);
|
||||
}
|
||||
|
||||
export function useGamePersistence({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
isLoading: boolean;
|
||||
setGameState: (state: GameState) => void;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
hydrateStoryState: (story: StoryMoment | null) => void;
|
||||
resetStoryState: () => void;
|
||||
}) {
|
||||
const [hasSavedGame, setHasSavedGame] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasSavedGame(Boolean(readSavedSnapshot()));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const didSave = writeSavedSnapshot({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
});
|
||||
|
||||
if (didSave) {
|
||||
setHasSavedGame(true);
|
||||
}
|
||||
}, AUTO_SAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [bottomTab, currentStory, gameState, isLoading]);
|
||||
|
||||
const saveCurrentGame = useCallback((override?: {
|
||||
gameState?: GameState;
|
||||
bottomTab?: BottomTab;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) => {
|
||||
const nextGameState = override?.gameState ?? gameState;
|
||||
const nextBottomTab = override?.bottomTab ?? bottomTab;
|
||||
const nextStory = override?.currentStory ?? currentStory;
|
||||
|
||||
if (!canPersistSnapshot(nextGameState, nextStory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const didSave = writeSavedSnapshot({
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
});
|
||||
|
||||
if (didSave) {
|
||||
setHasSavedGame(true);
|
||||
}
|
||||
|
||||
return didSave;
|
||||
}, [bottomTab, currentStory, gameState]);
|
||||
|
||||
const clearSavedGame = useCallback(() => {
|
||||
clearSavedSnapshot();
|
||||
setHasSavedGame(false);
|
||||
}, []);
|
||||
|
||||
const continueSavedGame = useCallback(() => {
|
||||
const snapshot = readSavedSnapshot();
|
||||
if (!snapshot) {
|
||||
clearSavedGame();
|
||||
return false;
|
||||
}
|
||||
|
||||
resetStoryState();
|
||||
setGameState(normalizeSavedGameState(snapshot.gameState));
|
||||
setBottomTab(snapshot.bottomTab ?? 'adventure');
|
||||
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
|
||||
setHasSavedGame(true);
|
||||
return true;
|
||||
}, [clearSavedGame, hydrateStoryState, resetStoryState, setBottomTab, setGameState]);
|
||||
|
||||
return {
|
||||
hasSavedGame,
|
||||
saveCurrentGame,
|
||||
continueSavedGame,
|
||||
clearSavedGame,
|
||||
};
|
||||
}
|
||||
20
src/hooks/useGameSettings.ts
Normal file
20
src/hooks/useGameSettings.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import {clampVolume, readSavedSettings, writeSavedSettings} from '../persistence/gameSettingsStorage';
|
||||
|
||||
export function useGameSettings() {
|
||||
const [musicVolume, setMusicVolumeState] = useState(() => readSavedSettings().musicVolume);
|
||||
|
||||
useEffect(() => {
|
||||
writeSavedSettings({musicVolume});
|
||||
}, [musicVolume]);
|
||||
|
||||
const setMusicVolume = useCallback((value: number) => {
|
||||
setMusicVolumeState(clampVolume(value));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
musicVolume,
|
||||
setMusicVolume,
|
||||
};
|
||||
}
|
||||
99
src/hooks/useInventoryFlow.ts
Normal file
99
src/hooks/useInventoryFlow.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { appendBuildBuffs } from '../data/buildDamage';
|
||||
import { INVENTORY_USE_FUNCTION } from '../data/functionCatalog';
|
||||
import {
|
||||
buildInventoryUseResultText,
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import { removeInventoryItem } from '../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
type TickCooldowns = (
|
||||
cooldowns: Record<string, number>,
|
||||
) => Record<string, number>;
|
||||
|
||||
export function useInventoryFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
tickCooldowns: TickCooldowns;
|
||||
}) {
|
||||
const handleUseInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || !isInventoryItemUsable(item) || item.quantity <= 0)
|
||||
return false;
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(
|
||||
item,
|
||||
gameState.playerCharacter,
|
||||
);
|
||||
if (!effect) return false;
|
||||
|
||||
if (
|
||||
effect.hpRestore <= 0 &&
|
||||
effect.manaRestore <= 0 &&
|
||||
effect.cooldownReduction <= 0 &&
|
||||
effect.buildBuffs.length <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (let index = 0; index < effect.cooldownReduction; index += 1) {
|
||||
cooldowns = tickCooldowns(cooldowns);
|
||||
}
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerHp: Math.min(
|
||||
gameState.playerMaxHp,
|
||||
gameState.playerHp + effect.hpRestore,
|
||||
),
|
||||
playerMana: Math.min(
|
||||
gameState.playerMaxMana,
|
||||
gameState.playerMana + effect.manaRestore,
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
gameState.activeBuildBuffs,
|
||||
effect.buildBuffs,
|
||||
),
|
||||
playerInventory: removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
),
|
||||
runtimeStats: incrementGameRuntimeStats(gameState.runtimeStats, {
|
||||
itemsUsed: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`使用${item.name}`,
|
||||
buildInventoryUseResultText(item, effect),
|
||||
INVENTORY_USE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState, tickCooldowns],
|
||||
);
|
||||
|
||||
return {
|
||||
handleUseInventoryItem,
|
||||
};
|
||||
}
|
||||
257
src/hooks/useNpcInteractionFlow.ts
Normal file
257
src/hooks/useNpcInteractionFlow.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs } from '../data/characterCombat';
|
||||
import { getCharacterById } from '../data/characterPresets';
|
||||
import { AnimationState, CompanionRenderState, GameState } from '../types';
|
||||
|
||||
type CompanionRecruitPresentation = {
|
||||
animationState: AnimationState;
|
||||
entryOffsetX: number;
|
||||
entryOffsetY: number;
|
||||
transitionMs: number;
|
||||
recruitToken: number;
|
||||
};
|
||||
|
||||
const RECRUIT_ENTRY_OFFSET_X = 148;
|
||||
const RECRUIT_ENTRY_OFFSET_Y = 10;
|
||||
const MIN_RECRUIT_PHASE_MS = 180;
|
||||
const OBSERVE_MIN_PAUSE_MS = 500;
|
||||
const OBSERVE_MAX_PAUSE_MS = 2000;
|
||||
|
||||
function randomFacing() {
|
||||
return Math.random() > 0.5 ? 'left' as const : 'right' as const;
|
||||
}
|
||||
|
||||
function randomObservePause() {
|
||||
return Math.round(OBSERVE_MIN_PAUSE_MS + Math.random() * (OBSERVE_MAX_PAUSE_MS - OBSERVE_MIN_PAUSE_MS));
|
||||
}
|
||||
|
||||
export function useNpcInteractionFlow(gameState: GameState) {
|
||||
const [presentationByNpcId, setPresentationByNpcId] = useState<Record<string, CompanionRecruitPresentation>>({});
|
||||
const [observeFacingByNpcId, setObserveFacingByNpcId] = useState<Record<string, 'left' | 'right'>>({});
|
||||
const previousCompanionIdsRef = useRef<string[]>([]);
|
||||
const timerIdsRef = useRef<number[]>([]);
|
||||
const observeTimerIdsRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
|
||||
timerIdsRef.current = [];
|
||||
(Object.values(observeTimerIdsRef.current) as number[]).forEach(timerId => window.clearTimeout(timerId));
|
||||
observeTimerIdsRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const currentCompanions = gameState.companions ?? [];
|
||||
const previousIds = new Set(previousCompanionIdsRef.current);
|
||||
const currentIds = currentCompanions.map(companion => companion.npcId);
|
||||
const currentIdSet = new Set(currentIds);
|
||||
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
Object.keys(next).forEach(npcId => {
|
||||
if (!currentIdSet.has(npcId)) {
|
||||
delete next[npcId];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
currentCompanions.forEach(companion => {
|
||||
if (previousIds.has(companion.npcId)) return;
|
||||
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return;
|
||||
|
||||
const hasDash = Boolean(character.animationMap?.[AnimationState.DASH]);
|
||||
const hasAcquire = Boolean(character.animationMap?.[AnimationState.ACQUIRE]);
|
||||
const dashDuration = hasDash
|
||||
? Math.max(MIN_RECRUIT_PHASE_MS, getCharacterAnimationDurationMs(character, AnimationState.DASH))
|
||||
: 0;
|
||||
const acquireDuration = hasAcquire
|
||||
? Math.max(MIN_RECRUIT_PHASE_MS, getCharacterAnimationDurationMs(character, AnimationState.ACQUIRE))
|
||||
: 0;
|
||||
const recruitToken = Date.now() + Math.random();
|
||||
|
||||
if (hasDash) {
|
||||
setPresentationByNpcId(current => ({
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
animationState: AnimationState.DASH,
|
||||
entryOffsetX: RECRUIT_ENTRY_OFFSET_X,
|
||||
entryOffsetY: RECRUIT_ENTRY_OFFSET_Y,
|
||||
transitionMs: 0,
|
||||
recruitToken,
|
||||
},
|
||||
}));
|
||||
|
||||
const launchTimer = window.setTimeout(() => {
|
||||
setPresentationByNpcId(current => {
|
||||
const existing = current[companion.npcId];
|
||||
if (!existing) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
...existing,
|
||||
entryOffsetX: 0,
|
||||
entryOffsetY: 0,
|
||||
transitionMs: dashDuration,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, 16);
|
||||
timerIdsRef.current.push(launchTimer);
|
||||
|
||||
const afterDashTimer = window.setTimeout(() => {
|
||||
if (!hasAcquire) {
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
delete next[companion.npcId];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPresentationByNpcId(current => {
|
||||
const existing = current[companion.npcId];
|
||||
if (!existing) return current;
|
||||
|
||||
return {
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
...existing,
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
transitionMs: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, dashDuration + 24);
|
||||
timerIdsRef.current.push(afterDashTimer);
|
||||
|
||||
if (hasAcquire) {
|
||||
const clearTimer = window.setTimeout(() => {
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
delete next[companion.npcId];
|
||||
return next;
|
||||
});
|
||||
}, dashDuration + acquireDuration + 56);
|
||||
timerIdsRef.current.push(clearTimer);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAcquire) {
|
||||
setPresentationByNpcId(current => ({
|
||||
...current,
|
||||
[companion.npcId]: {
|
||||
animationState: AnimationState.ACQUIRE,
|
||||
entryOffsetX: 0,
|
||||
entryOffsetY: 0,
|
||||
transitionMs: 0,
|
||||
recruitToken,
|
||||
},
|
||||
}));
|
||||
|
||||
const clearTimer = window.setTimeout(() => {
|
||||
setPresentationByNpcId(current => {
|
||||
const next = { ...current };
|
||||
delete next[companion.npcId];
|
||||
return next;
|
||||
});
|
||||
}, acquireDuration + 40);
|
||||
timerIdsRef.current.push(clearTimer);
|
||||
}
|
||||
});
|
||||
|
||||
previousCompanionIdsRef.current = currentIds;
|
||||
}, [gameState.companions]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentCompanions = gameState.companions ?? [];
|
||||
const currentIds = new Set(currentCompanions.map(companion => companion.npcId));
|
||||
const isObserveActive = gameState.ambientIdleMode === 'observe_signs';
|
||||
|
||||
const clearObserveTimer = (npcId: string) => {
|
||||
const timerId = observeTimerIdsRef.current[npcId];
|
||||
if (timerId) {
|
||||
window.clearTimeout(timerId);
|
||||
delete observeTimerIdsRef.current[npcId];
|
||||
}
|
||||
};
|
||||
|
||||
Object.keys(observeTimerIdsRef.current).forEach(npcId => {
|
||||
if (!isObserveActive || !currentIds.has(npcId)) {
|
||||
clearObserveTimer(npcId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isObserveActive) {
|
||||
setObserveFacingByNpcId({});
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleNextObserveTurn = (npcId: string) => {
|
||||
clearObserveTimer(npcId);
|
||||
observeTimerIdsRef.current[npcId] = window.setTimeout(() => {
|
||||
setObserveFacingByNpcId(current => ({
|
||||
...current,
|
||||
[npcId]: randomFacing(),
|
||||
}));
|
||||
scheduleNextObserveTurn(npcId);
|
||||
}, randomObservePause());
|
||||
};
|
||||
|
||||
currentCompanions.forEach(companion => {
|
||||
if (observeTimerIdsRef.current[companion.npcId]) return;
|
||||
|
||||
setObserveFacingByNpcId(current => ({
|
||||
...current,
|
||||
[companion.npcId]: randomFacing(),
|
||||
}));
|
||||
scheduleNextObserveTurn(companion.npcId);
|
||||
});
|
||||
}, [gameState.ambientIdleMode, gameState.companions]);
|
||||
|
||||
const companionRenderStates: CompanionRenderState[] = (gameState.companions ?? [])
|
||||
.map((companion, index) => {
|
||||
const character = getCharacterById(companion.characterId);
|
||||
if (!character) return null;
|
||||
|
||||
const presentation = presentationByNpcId[companion.npcId];
|
||||
|
||||
return {
|
||||
npcId: companion.npcId,
|
||||
character,
|
||||
hp: companion.hp,
|
||||
maxHp: companion.maxHp,
|
||||
mana: companion.mana,
|
||||
maxMana: companion.maxMana,
|
||||
skillCooldowns: companion.skillCooldowns,
|
||||
animationState: presentation?.animationState ?? (
|
||||
companion.hp <= 0
|
||||
? companion.animationState ?? AnimationState.DIE
|
||||
: gameState.scrollWorld
|
||||
? AnimationState.RUN
|
||||
: gameState.inBattle
|
||||
? companion.animationState ?? AnimationState.IDLE
|
||||
: gameState.animationState
|
||||
),
|
||||
actionMode: companion.actionMode ?? 'idle',
|
||||
slot: index % 2 === 0 ? 'upper' : 'lower',
|
||||
facing: observeFacingByNpcId[companion.npcId] ?? gameState.playerFacing,
|
||||
entryOffsetX: (presentation?.entryOffsetX ?? 0) + (companion.offsetX ?? 0),
|
||||
entryOffsetY: (presentation?.entryOffsetY ?? 0) + (companion.offsetY ?? 0),
|
||||
transitionMs: presentation?.transitionMs ?? companion.transitionMs ?? 0,
|
||||
recruitToken: presentation?.recruitToken,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as CompanionRenderState[];
|
||||
|
||||
return {
|
||||
companionRenderStates,
|
||||
};
|
||||
}
|
||||
1608
src/hooks/useStoryGeneration.ts
Normal file
1608
src/hooks/useStoryGeneration.ts
Normal file
File diff suppressed because it is too large
Load Diff
60
src/hooks/useStoryOptions.ts
Normal file
60
src/hooks/useStoryOptions.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { sortStoryOptionsByPriority } from '../data/stateFunctions';
|
||||
import { StoryMoment } from '../types';
|
||||
|
||||
const OPTION_PAGE_SIZE = 3;
|
||||
|
||||
export function useStoryOptions(currentStory: StoryMoment | null) {
|
||||
const [optionPool, setOptionPool] = useState(currentStory?.options ?? []);
|
||||
const [optionWindowStart, setOptionWindowStart] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStory) {
|
||||
setOptionPool([]);
|
||||
setOptionWindowStart(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setOptionPool(sortStoryOptionsByPriority(currentStory.options));
|
||||
setOptionWindowStart(0);
|
||||
}, [currentStory]);
|
||||
|
||||
const activeOptionPool = useMemo(
|
||||
() => (optionPool.length > 0 ? optionPool : currentStory?.options ?? []),
|
||||
[currentStory?.options, optionPool],
|
||||
);
|
||||
|
||||
const displayedOptions = useMemo(
|
||||
() => activeOptionPool.slice(optionWindowStart, optionWindowStart + OPTION_PAGE_SIZE),
|
||||
[activeOptionPool, optionWindowStart],
|
||||
);
|
||||
|
||||
const canRefreshOptions = activeOptionPool.length > OPTION_PAGE_SIZE;
|
||||
|
||||
const handleRefreshOptions = useCallback(() => {
|
||||
if (activeOptionPool.length <= OPTION_PAGE_SIZE) return;
|
||||
|
||||
const nextStart = optionWindowStart + OPTION_PAGE_SIZE;
|
||||
if (nextStart < activeOptionPool.length) {
|
||||
setOptionWindowStart(nextStart);
|
||||
return;
|
||||
}
|
||||
|
||||
setOptionWindowStart(0);
|
||||
}, [activeOptionPool.length, optionWindowStart]);
|
||||
|
||||
const resetStoryOptions = useCallback(() => {
|
||||
setOptionPool([]);
|
||||
setOptionWindowStart(0);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeOptionPool,
|
||||
displayedOptions,
|
||||
optionWindowStart,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
resetStoryOptions,
|
||||
};
|
||||
}
|
||||
98
src/hooks/useTreasureFlow.ts
Normal file
98
src/hooks/useTreasureFlow.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { addInventoryItems } from '../data/npcInteractions';
|
||||
import {
|
||||
buildTreasureEncounterStoryMoment,
|
||||
buildTreasureResultText,
|
||||
resolveTreasureReward,
|
||||
} from '../data/treasureInteractions';
|
||||
import { Character, Encounter, GameState, StoryMoment, StoryOption } from '../types';
|
||||
import type {CommitGeneratedState} from './generatedState';
|
||||
|
||||
type ProgressTreasureQuest = (state: GameState, sceneId: string | null) => GameState;
|
||||
|
||||
export function isTreasureEncounter(encounter: GameState['currentEncounter']): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'treasure');
|
||||
}
|
||||
|
||||
export function buildTreasureStory(
|
||||
state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
return buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTreasureFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
progressTreasureQuest,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
progressTreasureQuest: ProgressTreasureQuest;
|
||||
}) {
|
||||
const handleTreasureInteraction = useCallback((option: StoryOption) => {
|
||||
if (!gameState.playerCharacter || option.interaction?.kind !== 'treasure' || gameState.currentEncounter?.kind !== 'treasure') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounter = gameState.currentEncounter;
|
||||
const action = option.interaction.action;
|
||||
const reward = action === 'leave'
|
||||
? null
|
||||
: resolveTreasureReward(gameState, encounter, action);
|
||||
const progressedState = action === 'leave'
|
||||
? gameState
|
||||
: progressTreasureQuest(gameState, gameState.currentScenePreset?.id ?? null);
|
||||
|
||||
const nextState: GameState = {
|
||||
...progressedState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: progressedState.animationState,
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: reward
|
||||
? Math.min(progressedState.playerMaxHp, progressedState.playerHp + reward.hp)
|
||||
: progressedState.playerHp,
|
||||
playerMana: reward
|
||||
? Math.min(progressedState.playerMaxMana, progressedState.playerMana + reward.mana)
|
||||
: progressedState.playerMana,
|
||||
playerCurrency: reward
|
||||
? progressedState.playerCurrency + reward.currency
|
||||
: progressedState.playerCurrency,
|
||||
playerInventory: reward
|
||||
? addInventoryItems(progressedState.playerInventory, reward.items)
|
||||
: progressedState.playerInventory,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildTreasureResultText(encounter, action, reward ?? undefined),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}, [commitGeneratedState, gameState, progressTreasureQuest]);
|
||||
|
||||
return {
|
||||
handleTreasureInteraction,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user