455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
import { AnimationState, Encounter, GameState, 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';
|
|
import {
|
|
canUseLimitedPrimaryNpcChat,
|
|
resolveActiveSceneActEncounterNpcIds,
|
|
} from '../services/customWorldSceneActRuntime';
|
|
|
|
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);
|
|
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 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,
|
|
}),
|
|
],
|
|
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<T>(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))
|
|
.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 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<SceneNpc & { monsterPresetId: string }>) {
|
|
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 = 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 {
|
|
sceneHostileNpcs: [],
|
|
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 {
|
|
sceneHostileNpcs: [],
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
inBattle: false,
|
|
};
|
|
}
|
|
|
|
if (kind === 'hostile') {
|
|
return {
|
|
sceneHostileNpcs: buildHostileEncounterGroup(state, PREVIEW_ENTITY_X_METERS, 'idle'),
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
inBattle: false,
|
|
};
|
|
}
|
|
|
|
if (kind === 'npc') {
|
|
const npc = pickRandomItem(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 {
|
|
sceneHostileNpcs: [],
|
|
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 {
|
|
sceneHostileNpcs: buildHostileEncounterGroup(state, CALL_OUT_ENTRY_X_METERS, 'move'),
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
inBattle: false,
|
|
};
|
|
}
|
|
|
|
if (kind === 'npc') {
|
|
const npc = pickRandomItem(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;
|
|
}
|