1
This commit is contained in:
@@ -14,6 +14,7 @@ import { getMonsterPresetsByWorld } from './hostileNpcPresets';
|
||||
import { createSceneHostileNpc } from './hostileNpcs';
|
||||
import { buildInitialNpcState } from './npcInteractions';
|
||||
import {
|
||||
buildNpcBattleFormationFromEncounter,
|
||||
createSceneEncounterPreview,
|
||||
hasAutoBattleSceneEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
@@ -495,4 +496,371 @@ describe('sceneEncounterPreviews', () => {
|
||||
expect(resolved.inBattle).toBe(false);
|
||||
expect(resolved.currentEncounter?.id).toBe('npc-hostile-opposite');
|
||||
});
|
||||
|
||||
it('builds active act npc battle formations with stable back-row slots', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '正面对手',
|
||||
title: '刀客',
|
||||
description: '正面对手',
|
||||
initialAffinity: -30,
|
||||
imageSrc: '',
|
||||
role: '敌对角色',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '后排甲',
|
||||
title: '弓手',
|
||||
description: '后排甲',
|
||||
initialAffinity: -25,
|
||||
imageSrc: '',
|
||||
role: '敌对角色',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '后排乙',
|
||||
title: '术士',
|
||||
description: '后排乙',
|
||||
initialAffinity: -20,
|
||||
imageSrc: '',
|
||||
role: '敌对角色',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentScenePreset: {
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
imageSrc: '/bridge.png',
|
||||
connectedSceneIds: [],
|
||||
treasureHints: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '正面对手',
|
||||
description: '正面对手',
|
||||
avatar: '正',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -30,
|
||||
hostile: true,
|
||||
attributeProfile: {
|
||||
attributes: {},
|
||||
combat: {
|
||||
maxHp: 96,
|
||||
attack: 12,
|
||||
defense: 8,
|
||||
speed: 10,
|
||||
},
|
||||
} as SceneNpc['attributeProfile'],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '后排甲',
|
||||
description: '后排甲',
|
||||
avatar: '甲',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -25,
|
||||
hostile: true,
|
||||
attributeProfile: {
|
||||
attributes: {},
|
||||
combat: {
|
||||
maxHp: 82,
|
||||
attack: 10,
|
||||
defense: 6,
|
||||
speed: 9,
|
||||
},
|
||||
} as SceneNpc['attributeProfile'],
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '后排乙',
|
||||
description: '后排乙',
|
||||
avatar: '乙',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
attributeProfile: {
|
||||
attributes: {},
|
||||
combat: {
|
||||
maxHp: 78,
|
||||
attack: 9,
|
||||
defense: 5,
|
||||
speed: 11,
|
||||
},
|
||||
} as SceneNpc['attributeProfile'],
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
},
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '正',
|
||||
context: '敌对角色',
|
||||
hostile: true,
|
||||
initialAffinity: -30,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-front': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -30,
|
||||
},
|
||||
'npc-back-1': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -25,
|
||||
},
|
||||
'npc-back-2': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const formation = buildNpcBattleFormationFromEncounter({
|
||||
state,
|
||||
encounter: state.currentEncounter!,
|
||||
});
|
||||
|
||||
expect(formation).toHaveLength(3);
|
||||
expect(formation.map((monster) => monster.encounter?.id)).toEqual([
|
||||
'npc-front',
|
||||
'npc-back-1',
|
||||
'npc-back-2',
|
||||
]);
|
||||
expect(
|
||||
formation.map((monster) => ({
|
||||
id: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
|
||||
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
|
||||
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps scene-act formation order even when the clicked encounter comes from the back row', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '海底遗址',
|
||||
description: '海底遗址',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '海底章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentScenePreset: {
|
||||
id: 'landmark-raw-1',
|
||||
name: '海底遗址',
|
||||
description: '海底遗址',
|
||||
imageSrc: '/underwater.png',
|
||||
connectedSceneIds: [],
|
||||
treasureHints: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '珊瑚祭司',
|
||||
description: '前排祭司',
|
||||
avatar: '祭',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '赤发护卫',
|
||||
description: '后排护卫',
|
||||
avatar: '卫',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '潮歌侍从',
|
||||
description: '后排侍从',
|
||||
avatar: '侍',
|
||||
role: '敌对角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
},
|
||||
currentEncounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '赤发护卫',
|
||||
npcDescription: '后排护卫',
|
||||
npcAvatar: '卫',
|
||||
context: '敌对角色',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-front': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
'npc-back-1': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
'npc-back-2': {
|
||||
...buildInitialNpcState(createEncounter(), WorldType.CUSTOM),
|
||||
affinity: -20,
|
||||
},
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const formation = buildNpcBattleFormationFromEncounter({
|
||||
state,
|
||||
encounter: state.currentEncounter!,
|
||||
});
|
||||
|
||||
expect(
|
||||
formation.map((monster) => ({
|
||||
id: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: 'npc-front', xMeters: 3.2, yOffset: 0 },
|
||||
{ id: 'npc-back-1', xMeters: 4.28, yOffset: 62 },
|
||||
{ id: 'npc-back-2', xMeters: 4.28, yOffset: -46 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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