1
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
import { createNpcBattleMonster } from '../../data/npcInteractions';
|
||||
import {
|
||||
buildNpcBattleFormationFromEncounter,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getForwardScenePreset } from '../../data/scenePresets';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -11,9 +16,93 @@ import {
|
||||
type RuntimeStoryResponse,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
function isNpcBattleAlignmentDebugEnabled() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
|
||||
window.location.search.includes('npcBattleAlignmentDebug=1')
|
||||
);
|
||||
}
|
||||
|
||||
function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) {
|
||||
if (!isNpcBattleAlignmentDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[npc-battle-alignment] ${label}`,
|
||||
monsters.map((monster) => ({
|
||||
id: monster.id,
|
||||
encounterId: monster.encounter?.id ?? null,
|
||||
encounterName: monster.encounter?.npcName ?? null,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
facing: monster.facing,
|
||||
animation: monster.animation,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) {
|
||||
return monsters.map(
|
||||
(monster) =>
|
||||
({
|
||||
...monster,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
}
|
||||
: monster.encounter,
|
||||
}) satisfies SceneHostileNpc,
|
||||
);
|
||||
}
|
||||
|
||||
function alignBattleFormationToVisibleFormation(params: {
|
||||
visibleFormation: GameState['sceneHostileNpcs'];
|
||||
battleFormation: GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const { visibleFormation, battleFormation } = params;
|
||||
if (visibleFormation.length === 0 || battleFormation.length === 0) {
|
||||
return battleFormation;
|
||||
}
|
||||
|
||||
const visibleFormationByEncounterId = new Map(
|
||||
visibleFormation.map((monster) => [
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id,
|
||||
monster,
|
||||
]),
|
||||
);
|
||||
|
||||
return battleFormation.map((monster) => {
|
||||
const encounterKey =
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id;
|
||||
const visibleMonster = visibleFormationByEncounterId.get(encounterKey);
|
||||
if (!visibleMonster) {
|
||||
return monster;
|
||||
}
|
||||
|
||||
return {
|
||||
...monster,
|
||||
xMeters: visibleMonster.xMeters,
|
||||
yOffset: visibleMonster.yOffset,
|
||||
facing: visibleMonster.facing,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
xMeters:
|
||||
visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters,
|
||||
}
|
||||
: monster.encounter,
|
||||
} satisfies SceneHostileNpc;
|
||||
});
|
||||
}
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
@@ -120,6 +209,102 @@ function bridgeServerSceneTravelSnapshot(params: {
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
function bridgeServerNpcBattleSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'npc_fight' && functionId !== 'npc_spar') {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const snapshotState = hydratedSnapshot.gameState;
|
||||
const isNpcBattleActive =
|
||||
snapshotState.inBattle &&
|
||||
Boolean(snapshotState.currentBattleNpcId) &&
|
||||
Boolean(snapshotState.currentNpcBattleMode);
|
||||
const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0;
|
||||
const sourceEncounter =
|
||||
previousState.currentEncounter?.kind === 'npc'
|
||||
? previousState.currentEncounter
|
||||
: null;
|
||||
|
||||
// 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把
|
||||
// currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把
|
||||
// sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定
|
||||
// “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。
|
||||
// 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。
|
||||
if (!isNpcBattleActive || !sourceEncounter) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const fallbackNpcState =
|
||||
snapshotState.npcStates[
|
||||
snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ??
|
||||
previousState.npcStates[
|
||||
previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ?? {
|
||||
affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0),
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
};
|
||||
|
||||
const battleMode =
|
||||
snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight';
|
||||
const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({
|
||||
state: previousState,
|
||||
encounter: {
|
||||
...sourceEncounter,
|
||||
xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS,
|
||||
},
|
||||
mode: battleMode,
|
||||
});
|
||||
const fallbackFormation =
|
||||
previousState.sceneHostileNpcs.length > 0
|
||||
? cloneBattleFormation(previousState.sceneHostileNpcs)
|
||||
: fallbackFormationFromSceneAct.length > 0
|
||||
? fallbackFormationFromSceneAct
|
||||
: [
|
||||
createNpcBattleMonster(
|
||||
sourceEncounter,
|
||||
fallbackNpcState,
|
||||
battleMode,
|
||||
{
|
||||
worldType: snapshotState.worldType,
|
||||
customWorldProfile: snapshotState.customWorldProfile,
|
||||
},
|
||||
),
|
||||
];
|
||||
const resolvedBattleFormation = hasResolvedBattleMonster
|
||||
? alignBattleFormationToVisibleFormation({
|
||||
visibleFormation: previousState.sceneHostileNpcs,
|
||||
battleFormation: snapshotState.sceneHostileNpcs,
|
||||
})
|
||||
: fallbackFormation;
|
||||
|
||||
logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation);
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...snapshotState,
|
||||
// 中文注释:优先沿用进入战斗前已经可见的阵容与站位;
|
||||
// 若上一帧还没有 battle combatants,则从幕预览/当前遭遇恢复完整 NPC 编队,
|
||||
// 避免只补出一个前排角色,造成后排消失和敌方位置突变。
|
||||
sceneHostileNpcs: resolvedBattleFormation,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -204,7 +389,11 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
});
|
||||
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
hydratedSnapshot: bridgeServerNpcBattleSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
functionId: params.option.functionId,
|
||||
}),
|
||||
functionId: params.option.functionId,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user