初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View 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);
});
});

View 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),
},
};
}

View 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');
});
});

View 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;
}

View 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,
};
}

View 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();
});
});

View 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;
}

View 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;
}

View 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}`;
}

View 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;

View 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;
}

View 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),
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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);
}
}

View 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,
};
}

View 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);
});
});

View 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,
};
}

View 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);
});
});

View 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,
};
}

View 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;
}

View 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]);
}

View 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,
};
}

View 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
View 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
View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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,
};
}