718 lines
21 KiB
TypeScript
718 lines
21 KiB
TypeScript
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<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;
|
||
}
|
||
|
||
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<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] ?? {
|
||
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<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) ||
|
||
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<SceneNpc & { monsterPresetId: string }>) {
|
||
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;
|
||
}
|