This commit is contained in:
2026-04-28 02:05:12 +08:00
parent 271db02e4a
commit 1eb090e4a5
39 changed files with 2671 additions and 165 deletions

View File

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

View File

@@ -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,