1
This commit is contained in:
@@ -4,7 +4,14 @@ import {
|
||||
resolveActiveSceneActEncounterFocusNpcId,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../services/customWorldSceneActRuntime';
|
||||
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import {
|
||||
AnimationState,
|
||||
Encounter,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getRecruitedNpcIds } from './companionRoster';
|
||||
import {
|
||||
createSceneHostileNpcsFromEncounters,
|
||||
@@ -27,6 +34,41 @@ export const PREVIEW_ENTITY_X_METERS = 12;
|
||||
export const RESOLVED_ENTITY_X_METERS = 3.2;
|
||||
export const CALL_OUT_ENTRY_X_METERS = 18;
|
||||
export const TREASURE_ENCOUNTERS_ENABLED = false;
|
||||
const SCENE_ACT_BACK_ROW_BATTLE_X_METERS = Number(
|
||||
(RESOLVED_ENTITY_X_METERS + 1.08).toFixed(2),
|
||||
);
|
||||
const SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS = [62, -46] as const;
|
||||
|
||||
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 logNpcBattleFormation(
|
||||
label: string,
|
||||
monsters: Array<Pick<SceneHostileNpc, 'id' | 'xMeters' | 'yOffset' | 'encounter'>>,
|
||||
) {
|
||||
if (!isNpcBattleAlignmentDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[npc-battle-formation] ${label}`,
|
||||
monsters.map((monster) => ({
|
||||
id: monster.id,
|
||||
encounterId: monster.encounter?.id ?? null,
|
||||
encounterName: monster.encounter?.npcName ?? null,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function getNpcEncounterKey(encounter: Encounter) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
@@ -54,18 +96,138 @@ function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounte
|
||||
return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0;
|
||||
}
|
||||
|
||||
function resolveSceneActEncounterMembers(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
const currentSceneNpcs = state.currentScenePreset?.npcs ?? [];
|
||||
if (currentSceneNpcs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
if (activeActNpcIds.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenNpcIds = new Set<string>();
|
||||
|
||||
return currentSceneNpcs
|
||||
.filter((candidate) => {
|
||||
const candidateIds = [
|
||||
candidate.id,
|
||||
candidate.characterId,
|
||||
candidate.name,
|
||||
candidate.title,
|
||||
]
|
||||
.map((value) =>
|
||||
resolveCustomWorldRoleIdReference(state.customWorldProfile, value),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
return candidateIds.some((candidateId) => activeActNpcIds.includes(candidateId));
|
||||
})
|
||||
.filter((npc): npc is SceneNpc => Boolean(npc))
|
||||
.filter((npc) => {
|
||||
if (seenNpcIds.has(npc.id)) {
|
||||
return false;
|
||||
}
|
||||
seenNpcIds.add(npc.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function getSceneActBattleSlots(primaryX: number) {
|
||||
return [
|
||||
{
|
||||
xMeters: primaryX,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
xMeters: SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
|
||||
yOffset: SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS[0],
|
||||
},
|
||||
{
|
||||
xMeters: SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
|
||||
yOffset: SCENE_ACT_BACK_ROW_BATTLE_Y_OFFSETS[1],
|
||||
},
|
||||
] satisfies Array<Pick<SceneHostileNpc, 'xMeters' | 'yOffset'>>;
|
||||
}
|
||||
|
||||
export function buildNpcBattleFormationFromEncounter(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
mode?: 'fight' | 'spar';
|
||||
}) {
|
||||
const { state, encounter, mode = 'fight' } = params;
|
||||
const sceneActMembers = resolveSceneActEncounterMembers(state, encounter);
|
||||
const primaryX =
|
||||
sceneActMembers.length > 1
|
||||
? RESOLVED_ENTITY_X_METERS
|
||||
: encounter.xMeters ?? RESOLVED_ENTITY_X_METERS;
|
||||
const formationSourceEncounters =
|
||||
sceneActMembers.length > 1
|
||||
? sceneActMembers.map((member, index) =>
|
||||
buildEncounterFromSceneNpc(
|
||||
member,
|
||||
index === 0 ? primaryX : SCENE_ACT_BACK_ROW_BATTLE_X_METERS,
|
||||
),
|
||||
)
|
||||
: [encounter];
|
||||
const slots = getSceneActBattleSlots(primaryX);
|
||||
|
||||
const resolvedFormation = formationSourceEncounters.map((memberEncounter, index) => {
|
||||
const slot = slots[index] ?? slots[slots.length - 1];
|
||||
const npcState = getResolvedNpcState(state, memberEncounter);
|
||||
const battleMonster = createNpcBattleMonster(
|
||||
memberEncounter,
|
||||
npcState,
|
||||
mode,
|
||||
{
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...battleMonster,
|
||||
xMeters: slot.xMeters,
|
||||
yOffset: slot.yOffset,
|
||||
facing: getFacingTowardPlayer(slot.xMeters, PLAYER_BASE_X_METERS),
|
||||
encounter: battleMonster.encounter
|
||||
? {
|
||||
...battleMonster.encounter,
|
||||
xMeters: slot.xMeters,
|
||||
}
|
||||
: battleMonster.encounter,
|
||||
} satisfies SceneHostileNpc;
|
||||
});
|
||||
|
||||
logNpcBattleFormation(
|
||||
`buildNpcBattleFormationFromEncounter:${encounter.id ?? encounter.npcName}`,
|
||||
resolvedFormation,
|
||||
);
|
||||
|
||||
return resolvedFormation;
|
||||
}
|
||||
|
||||
function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) {
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
const battleNpcId = getNpcEncounterKey(encounter);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sceneHostileNpcs: [
|
||||
createNpcBattleMonster(encounter, npcState, 'fight', {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
}),
|
||||
],
|
||||
// 中文注释:幕预览和正式运行都统一走这一套 NPC 战斗编队生成,
|
||||
// 避免开战时把同幕后排角色压缩成单体,导致阵容缺失和站位突变。
|
||||
sceneHostileNpcs: buildNpcBattleFormationFromEncounter({
|
||||
state,
|
||||
encounter,
|
||||
mode: 'fight',
|
||||
}),
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
playerX: 0,
|
||||
|
||||
Reference in New Issue
Block a user