421
src/data/sceneEncounterPreviews.ts
Normal file
421
src/data/sceneEncounterPreviews.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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')],
|
||||
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,
|
||||
);
|
||||
|
||||
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<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 = 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;
|
||||
}
|
||||
Reference in New Issue
Block a user