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;
|
|
}
|