Files
Genarrative/src/data/sceneEncounterPreviews.ts

718 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}