Files
Genarrative/src/data/sceneEncounterPreviews.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

794 lines
22 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);
}