import { resolveCustomWorldRoleIdReference } from '../services/customWorldRoleReferences'; import { canUseLimitedPrimaryNpcChat, resolveActiveSceneActEncounterFocusNpcId, resolveActiveSceneActEncounterNpcIds, } from '../services/customWorldSceneActRuntime'; import { AnimationState, Encounter, GameState, SceneHostileNpc, SceneNpc, WorldType, } from '../types'; import { getRecruitedNpcIds } from './companionRoster'; import { createSceneHostileNpcsFromEncounters, createSceneHostileNpcsFromIds, getFacingTowardPlayer, getMonsterGroupAnchorX, pickEncounterMonsterIds, PLAYER_BASE_X_METERS, } from './hostileNpcs'; import { buildInitialNpcState, createNpcBattleMonster } from './npcInteractions'; import { buildEncounterFromSceneNpc, getSceneFriendlyNpcs, getSceneHostileNpcs, getWorldCampScenePreset, } from './scenePresets'; export const EXPLORE_APPROACH_DURATION_MS = 4000; 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>, ) { 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; } function getResolvedNpcState(state: GameState, encounter: Encounter) { return state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType, state); } function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) { if (encounter.kind !== 'npc') return false; const npcState = getResolvedNpcState(state, encounter); const npcId = getNpcEncounterKey(encounter); if ( canUseLimitedPrimaryNpcChat({ profile: state.customWorldProfile, sceneId: state.currentScenePreset?.id ?? null, storyEngineMemory: state.storyEngineMemory, npcId, affinity: npcState.affinity, }) ) { return false; } 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(); 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>; } 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] ?? { xMeters: primaryX, yOffset: 0, }; 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 battleNpcId = getNpcEncounterKey(encounter); return { ...state, // 中文注释:幕预览和正式运行都统一走这一套 NPC 战斗编队生成, // 避免开战时把同幕后排角色压缩成单体,导致阵容缺失和站位突变。 sceneHostileNpcs: buildNpcBattleFormationFromEncounter({ state, encounter, mode: 'fight', }), currentEncounter: null, npcInteractionActive: false, playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: true, currentBattleNpcId: battleNpcId, currentNpcBattleMode: 'fight' as const, currentNpcBattleOutcome: null, // 中文注释:NPC 开战后要保留战前原始遭遇,供战斗收尾时恢复和平态站位。 // 这里复用现有 sparReturnEncounter 存槽,避免战后误把 battle encounter 的临时坐标带回场景。 sparReturnEncounter: encounter, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; } function pickRandomItem(items: T[]) { if (items.length === 0) return null; return items[Math.floor(Math.random() * items.length)] ?? null; } function createTreasureEncounter(state: GameState, treasureHint: string): Encounter { return { id: `treasure-${state.currentScenePreset?.id ?? 'unknown'}`, kind: 'treasure', npcName: '宝藏', npcDescription: `你发现了与${treasureHint}相关的线索,看起来像是有人故意藏起的宝物。`, npcAvatar: '/Icons/47_treasure.png', context: 'treasure', xMeters: PREVIEW_ENTITY_X_METERS, }; } function getAvailableFriendlySceneNpcs(state: GameState) { const recruitedNpcIds = getRecruitedNpcIds(state); const isCampScene = Boolean( state.worldType && state.currentScenePreset?.id && getWorldCampScenePreset(state.worldType)?.id === state.currentScenePreset.id, ); const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({ profile: state.customWorldProfile, sceneId: state.currentScenePreset?.id ?? null, storyEngineMemory: state.storyEngineMemory, }); const activeActNpcIdSet = new Set(activeActNpcIds); return getSceneFriendlyNpcs(state.currentScenePreset) .filter(candidate => !isCampScene || Boolean(candidate.characterId) || activeActNpcIdSet.has(candidate.id), ) .filter(candidate => candidate.characterId !== state.playerCharacter?.id) .filter(candidate => !recruitedNpcIds.has(candidate.id)) .filter(candidate => activeActNpcIdSet.size === 0 ? true : activeActNpcIdSet.has(candidate.id) || (candidate.characterId ? activeActNpcIdSet.has(candidate.characterId) : false), ); } function getAvailableActiveSceneActNpcs(state: GameState) { const recruitedNpcIds = getRecruitedNpcIds(state); const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({ profile: state.customWorldProfile, sceneId: state.currentScenePreset?.id ?? null, storyEngineMemory: state.storyEngineMemory, }); const activeActNpcIdSet = new Set(activeActNpcIds); if (activeActNpcIdSet.size === 0) { return []; } return (state.currentScenePreset?.npcs ?? []) .filter(candidate => { const candidateIds = [ candidate.id, candidate.characterId, candidate.name, candidate.title, ] .map((value) => resolveCustomWorldRoleIdReference(state.customWorldProfile, value), ) .filter(Boolean); return candidateIds.some(id => activeActNpcIdSet.has(id)); }) .filter(candidate => candidate.characterId !== state.playerCharacter?.id) .filter(candidate => !recruitedNpcIds.has(candidate.id)); } function getAvailableHostileSceneNpcs(state: GameState) { const recruitedNpcIds = getRecruitedNpcIds(state); return getSceneHostileNpcs(state.currentScenePreset) .filter(candidate => !recruitedNpcIds.has(candidate.id)) .filter((candidate): candidate is SceneNpc & { monsterPresetId: string } => Boolean(candidate.monsterPresetId)); } function pickEncounterHostileNpcs(hostileNpcs: Array) { const selectedMonsterIds = new Set( pickEncounterMonsterIds(hostileNpcs.map(npc => npc.monsterPresetId)), ); return hostileNpcs.filter(npc => selectedMonsterIds.has(npc.monsterPresetId)); } function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) { const focusNpcId = resolveActiveSceneActEncounterFocusNpcId({ profile: state.customWorldProfile, sceneId: state.currentScenePreset?.id ?? null, storyEngineMemory: state.storyEngineMemory, }); return ( npcs.find( (npc) => resolveCustomWorldRoleIdReference(state.customWorldProfile, npc.id) === focusNpcId || resolveCustomWorldRoleIdReference( state.customWorldProfile, npc.characterId, ) === focusNpcId || resolveCustomWorldRoleIdReference( state.customWorldProfile, npc.name, ) === focusNpcId || resolveCustomWorldRoleIdReference( state.customWorldProfile, npc.title, ) === focusNpcId, ) ?? pickRandomItem(npcs) ); } function hasActiveSceneActEncounterTarget(state: GameState) { return resolveActiveSceneActEncounterNpcIds({ profile: state.customWorldProfile, sceneId: state.currentScenePreset?.id ?? null, storyEngineMemory: state.storyEngineMemory, }).length > 0; } function buildEmptyEncounterPreview() { return { sceneHostileNpcs: [], currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } function buildActiveSceneActNpcEncounter( state: GameState, availableNpcs: SceneNpc[], xMeters: number, ) { const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs); return { sceneHostileNpcs: [], currentEncounter: npc ? buildFriendlyEncounter(npc, xMeters) : null, npcInteractionActive: false, inBattle: false, }; } function buildHostileEncounterGroup( state: GameState, entryX: number, animation: 'idle' | 'move', ) { if (!state.worldType || !state.currentScenePreset) return []; const selectedHostiles = pickEncounterHostileNpcs(getAvailableHostileSceneNpcs(state)); const hostileEncounters = selectedHostiles.map(npc => buildEncounterFromSceneNpc(npc)); const hostileMonsters = createSceneHostileNpcsFromEncounters( state.worldType, hostileEncounters, PLAYER_BASE_X_METERS, ); const anchorX = getMonsterGroupAnchorX(hostileMonsters); return hostileMonsters.map(monster => { const xMeters = Number((entryX + (monster.xMeters - anchorX)).toFixed(2)); return { ...monster, xMeters, facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS), animation, encounter: monster.encounter ? { ...monster.encounter, xMeters, } : monster.encounter, }; }); } function buildFriendlyEncounter(npc: SceneNpc, xMeters: number) { return { ...buildEncounterFromSceneNpc(npc, xMeters), xMeters, } satisfies Encounter; } function buildResolvedHostileBattleState(state: GameState, hostileEncounters: Encounter[]) { if (!state.worldType) return state; const resolvedMonsters = createSceneHostileNpcsFromEncounters( state.worldType, hostileEncounters, PLAYER_BASE_X_METERS, ).map(monster => ({ ...monster, animation: 'idle' as const, facing: getFacingTowardPlayer(monster.xMeters, PLAYER_BASE_X_METERS), encounter: monster.encounter ? { ...monster.encounter, xMeters: monster.xMeters, } : monster.encounter, })); return { ...state, sceneHostileNpcs: resolvedMonsters, currentEncounter: null, npcInteractionActive: false, playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: true, }; } export function createSceneEncounterPreview(state: GameState) { if (!state.worldType || !state.currentScenePreset) { return buildEmptyEncounterPreview(); } if (hasActiveSceneActEncounterTarget(state)) { return buildActiveSceneActNpcEncounter( state, getAvailableActiveSceneActNpcs(state), PREVIEW_ENTITY_X_METERS, ); } const availableNpcs = getAvailableFriendlySceneNpcs(state); const availableHostiles = getAvailableHostileSceneNpcs(state); const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = []; if (availableHostiles.length > 0) availableKinds.push('hostile'); if (availableNpcs.length > 0) availableKinds.push('npc'); if (TREASURE_ENCOUNTERS_ENABLED && (state.currentScenePreset.treasureHints?.length ?? 0) > 0) { availableKinds.push('treasure'); } const kind = pickRandomItem(availableKinds); if (!kind) { return buildEmptyEncounterPreview(); } if (kind === 'hostile') { return { sceneHostileNpcs: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'), currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } if (kind === 'npc') { const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs); return { sceneHostileNpcs: [], currentEncounter: npc ? buildFriendlyEncounter(npc, PREVIEW_ENTITY_X_METERS) : null, npcInteractionActive: false, inBattle: false, }; } const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []); return { sceneHostileNpcs: [], currentEncounter: treasureHint ? createTreasureEncounter(state, treasureHint) : null, npcInteractionActive: false, inBattle: false, }; } export function createSceneCallOutEncounter(state: GameState) { if (!state.worldType || !state.currentScenePreset) { return buildEmptyEncounterPreview(); } if (hasActiveSceneActEncounterTarget(state)) { return buildActiveSceneActNpcEncounter( state, getAvailableActiveSceneActNpcs(state), CALL_OUT_ENTRY_X_METERS, ); } const availableNpcs = getAvailableFriendlySceneNpcs(state); const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = []; const availableHostiles = getAvailableHostileSceneNpcs(state); if (availableHostiles.length > 0) availableKinds.push('hostile'); if (availableNpcs.length > 0) availableKinds.push('npc'); if (TREASURE_ENCOUNTERS_ENABLED && (state.currentScenePreset.treasureHints?.length ?? 0) > 0) { availableKinds.push('treasure'); } const kind = pickRandomItem(availableKinds); if (kind === 'hostile') { return { sceneHostileNpcs: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'), currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } if (kind === 'npc') { const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs); return { sceneHostileNpcs: [], currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null, npcInteractionActive: false, inBattle: false, }; } if (kind === 'treasure') { const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []); return { sceneHostileNpcs: [], currentEncounter: treasureHint ? { ...createTreasureEncounter(state, treasureHint), xMeters: CALL_OUT_ENTRY_X_METERS, } : null, npcInteractionActive: false, inBattle: false, }; } return { sceneHostileNpcs: [], currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } export function ensureSceneEncounterPreview(state: GameState): GameState { if ( state.inBattle || state.sceneHostileNpcs.length > 0 || state.currentEncounter || !state.currentScenePreset || !state.worldType ) { return state; } return { ...state, ...createSceneEncounterPreview(state), playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, }; } export function hasAutoBattleSceneEncounter(state: GameState) { if (!state.currentScenePreset || !state.worldType || state.inBattle) { return false; } if (state.sceneHostileNpcs.length > 0) { return state.sceneHostileNpcs.some(monster => Boolean(monster.encounter?.monsterPresetId)); } return state.currentEncounter?.kind === 'npc' ? shouldAutoStartBattleForEncounter(state, state.currentEncounter) : false; } export function resolveSceneEncounterPreview(state: GameState): GameState { if (!state.currentScenePreset || !state.worldType) { return state; } const previewState = state.sceneHostileNpcs.length > 0 || state.currentEncounter ? state : ensureSceneEncounterPreview(state); if (previewState.sceneHostileNpcs.length > 0) { const hostileEncounters = previewState.sceneHostileNpcs .map(monster => monster.encounter) .filter((encounter): encounter is Encounter => Boolean(encounter?.monsterPresetId)); if (hostileEncounters.length > 0) { return buildResolvedHostileBattleState(previewState, hostileEncounters); } const resolvedMonsters = createSceneHostileNpcsFromIds( previewState.worldType ?? WorldType.WUXIA, previewState.sceneHostileNpcs.map(monster => monster.id), PLAYER_BASE_X_METERS, ).map(monster => ({ ...monster, animation: 'idle' as const, facing: getFacingTowardPlayer(monster.xMeters, PLAYER_BASE_X_METERS), })); return { ...previewState, sceneHostileNpcs: resolvedMonsters, currentEncounter: null, npcInteractionActive: false, playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: true, }; } if ( previewState.currentEncounter?.kind === 'npc' && shouldAutoStartBattleForEncounter(previewState, previewState.currentEncounter) ) { return buildResolvedNpcBattleState(previewState, previewState.currentEncounter); } if (previewState.currentEncounter) { return { ...previewState, currentEncounter: { ...previewState.currentEncounter, xMeters: RESOLVED_ENTITY_X_METERS, }, npcInteractionActive: false, sceneHostileNpcs: [], playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: false, }; } return previewState; } export function getPreviewEntityX(state: GameState) { return state.sceneHostileNpcs.length > 0 ? getMonsterGroupAnchorX(state.sceneHostileNpcs) : state.currentEncounter?.xMeters ?? PREVIEW_ENTITY_X_METERS; }