import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types'; import { getRecruitedNpcIds } from './companionRoster'; import { createSceneMonstersFromIds, createSceneNpcMonstersFromEncounters, 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; 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); return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0; } function buildResolvedNpcBattleState(state: GameState, encounter: Encounter) { const npcState = getResolvedNpcState(state, encounter); const battleNpcId = getNpcEncounterKey(encounter); return { ...state, sceneMonsters: [ createNpcBattleMonster(encounter, npcState, 'fight', { worldType: state.worldType, customWorldProfile: state.customWorldProfile, }), ], 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, sparReturnEncounter: null, 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, ); return getSceneFriendlyNpcs(state.currentScenePreset) .filter(candidate => !isCampScene || Boolean(candidate.characterId)) .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 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 = createSceneNpcMonstersFromEncounters( 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 = createSceneNpcMonstersFromEncounters( 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, sceneMonsters: 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 { sceneMonsters: [], currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } 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 { sceneMonsters: [], currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } if (kind === 'hostile') { return { sceneMonsters: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'), currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } if (kind === 'npc') { const npc = pickRandomItem(availableNpcs); return { sceneMonsters: [], currentEncounter: npc ? buildFriendlyEncounter(npc, PREVIEW_ENTITY_X_METERS) : null, npcInteractionActive: false, inBattle: false, }; } const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []); return { sceneMonsters: [], currentEncounter: treasureHint ? createTreasureEncounter(state, treasureHint) : null, npcInteractionActive: false, inBattle: false, }; } export function createSceneCallOutEncounter(state: GameState) { if (!state.worldType || !state.currentScenePreset) { return { sceneMonsters: [], currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = []; const availableHostiles = getAvailableHostileSceneNpcs(state); if (availableHostiles.length > 0) availableKinds.push('hostile'); const availableNpcs = getAvailableFriendlySceneNpcs(state); 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 { sceneMonsters: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'), currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } if (kind === 'npc') { const npc = pickRandomItem(availableNpcs); return { sceneMonsters: [], currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null, npcInteractionActive: false, inBattle: false, }; } if (kind === 'treasure') { const treasureHint = pickRandomItem(state.currentScenePreset.treasureHints ?? []); return { sceneMonsters: [], currentEncounter: treasureHint ? { ...createTreasureEncounter(state, treasureHint), xMeters: CALL_OUT_ENTRY_X_METERS, } : null, npcInteractionActive: false, inBattle: false, }; } return { sceneMonsters: [], currentEncounter: null, npcInteractionActive: false, inBattle: false, }; } export function ensureSceneEncounterPreview(state: GameState): GameState { if ( state.inBattle || state.sceneMonsters.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.sceneMonsters.length > 0) { return state.sceneMonsters.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.sceneMonsters.length > 0 || state.currentEncounter ? state : ensureSceneEncounterPreview(state); if (previewState.sceneMonsters.length > 0) { const hostileEncounters = previewState.sceneMonsters .map(monster => monster.encounter) .filter((encounter): encounter is Encounter => Boolean(encounter?.monsterPresetId)); if (hostileEncounters.length > 0) { return buildResolvedHostileBattleState(previewState, hostileEncounters); } const resolvedMonsters = createSceneMonstersFromIds( previewState.worldType ?? WorldType.WUXIA, previewState.sceneMonsters.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, sceneMonsters: 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, sceneMonsters: [], 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.sceneMonsters.length > 0 ? getMonsterGroupAnchorX(state.sceneMonsters) : state.currentEncounter?.xMeters ?? PREVIEW_ENTITY_X_METERS; }