414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
|
|
import {
|
|
buildCustomWorldRuntimeCharacters,
|
|
createCharacterSkillCooldowns,
|
|
getCharacterMaxHp,
|
|
getCharacterMaxMana,
|
|
setRuntimeCharacterOverrides,
|
|
} from '../../data/characterPresets';
|
|
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
|
import { getInitialPlayerCurrency } from '../../data/economy';
|
|
import {
|
|
applyEquipmentLoadoutToState,
|
|
buildInitialEquipmentLoadout,
|
|
createEmptyEquipmentLoadout,
|
|
} from '../../data/equipmentEffects';
|
|
import {
|
|
buildInitialNpcState,
|
|
buildInitialPlayerInventory,
|
|
} from '../../data/npcInteractions';
|
|
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
|
|
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
|
|
import {
|
|
ensureSceneEncounterPreview,
|
|
RESOLVED_ENTITY_X_METERS,
|
|
} from '../../data/sceneEncounterPreviews';
|
|
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
|
|
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
|
import {
|
|
AnimationState,
|
|
Character,
|
|
CustomWorldProfile,
|
|
Encounter,
|
|
EquipmentLoadout,
|
|
GameState,
|
|
InventoryItem,
|
|
SceneNpc,
|
|
WorldType,
|
|
} from '../../types';
|
|
import type { BottomTab } from './rpgSessionTypes';
|
|
|
|
const PLAYER_BASE_MAX_HP = 180;
|
|
|
|
function mergeStarterInventoryItems<
|
|
T extends { category: string; name: string },
|
|
>(explicitItems: T[], fallbackItems: T[]) {
|
|
const merged = new Map<string, T>();
|
|
|
|
[...explicitItems, ...fallbackItems].forEach((item) => {
|
|
merged.set(`${item.category}:${item.name}`, item);
|
|
});
|
|
|
|
return [...merged.values()];
|
|
}
|
|
|
|
function normalizeExplicitStarterCategory(category: string) {
|
|
const normalized = category.trim();
|
|
return normalized === '专属物' ? '专属物品' : normalized;
|
|
}
|
|
|
|
function inferExplicitStarterSlot(category: string) {
|
|
const normalized = normalizeExplicitStarterCategory(category);
|
|
if (normalized === '武器') return 'weapon' as const;
|
|
if (normalized === '护甲') return 'armor' as const;
|
|
if (
|
|
normalized === '饰品' ||
|
|
normalized === '稀有品' ||
|
|
normalized === '专属物品'
|
|
) {
|
|
return 'relic' as const;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildExplicitCustomWorldRoleStarterState(
|
|
profile: CustomWorldProfile,
|
|
character: Character,
|
|
) {
|
|
const role =
|
|
profile.playableNpcs.find((entry) => entry.id === character.id) ??
|
|
profile.storyNpcs.find((entry) => entry.id === character.id) ??
|
|
profile.playableNpcs.find((entry) => entry.name === character.name) ??
|
|
profile.storyNpcs.find((entry) => entry.name === character.name) ??
|
|
null;
|
|
|
|
const inventory = role
|
|
? role.initialItems.map((item, index) => {
|
|
const category = normalizeExplicitStarterCategory(item.category);
|
|
return {
|
|
id: `custom-role-item:${role.id}:${index + 1}`,
|
|
category,
|
|
name: item.name,
|
|
quantity: Math.max(1, item.quantity),
|
|
rarity: item.rarity,
|
|
tags: [...item.tags],
|
|
description: item.description,
|
|
equipmentSlotId: inferExplicitStarterSlot(category),
|
|
runtimeMetadata: {
|
|
origin: 'ai_compiled' as const,
|
|
generationChannel: 'discovery' as const,
|
|
seedKey: `${role.id}:${index + 1}`,
|
|
relationAnchor: {
|
|
type: 'npc' as const,
|
|
npcId: role.id,
|
|
npcName: role.name,
|
|
roleText: role.role,
|
|
},
|
|
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
|
|
},
|
|
} satisfies InventoryItem;
|
|
})
|
|
: [];
|
|
|
|
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
|
|
inventory.forEach((item) => {
|
|
const slot = item.equipmentSlotId;
|
|
if (!slot || equipment[slot]) {
|
|
return;
|
|
}
|
|
equipment[slot] = item;
|
|
});
|
|
|
|
return {
|
|
inventory,
|
|
equipment,
|
|
};
|
|
}
|
|
|
|
function createInitialCampEncounter(
|
|
worldType: WorldType | null,
|
|
playerCharacter: Character,
|
|
): Encounter | null {
|
|
if (!worldType) return null;
|
|
|
|
const campScenePreset =
|
|
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
|
|
const npcCandidates = (campScenePreset?.npcs ?? [])
|
|
.filter((npc: SceneNpc) => Boolean(npc.characterId))
|
|
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
|
|
if (npcCandidates.length === 0) return null;
|
|
|
|
const npc =
|
|
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
|
|
if (!npc) return null;
|
|
|
|
return {
|
|
id: npc.id,
|
|
kind: 'npc',
|
|
characterId: npc.characterId,
|
|
npcName: npc.name,
|
|
npcDescription: npc.description,
|
|
npcAvatar: npc.avatar,
|
|
context: npc.role,
|
|
gender: npc.gender,
|
|
xMeters: RESOLVED_ENTITY_X_METERS,
|
|
};
|
|
}
|
|
|
|
function createInitialGameState(): GameState {
|
|
return {
|
|
worldType: null,
|
|
customWorldProfile: null,
|
|
playerCharacter: null,
|
|
runtimeStats: createInitialGameRuntimeStats(),
|
|
playerProgression: createInitialPlayerProgressionState(),
|
|
currentScene: 'Selection',
|
|
storyHistory: [],
|
|
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
|
chapterState: null,
|
|
campaignState: null,
|
|
activeScenarioPackId: null,
|
|
activeCampaignPackId: null,
|
|
characterChats: {},
|
|
lastObserveSignsSceneId: null,
|
|
lastObserveSignsReport: null,
|
|
animationState: AnimationState.IDLE,
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
currentScenePreset: null,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
playerHp: PLAYER_BASE_MAX_HP,
|
|
playerMaxHp: PLAYER_BASE_MAX_HP,
|
|
playerMana: 0,
|
|
playerMaxMana: 0,
|
|
playerSkillCooldowns: {},
|
|
activeBuildBuffs: [],
|
|
activeCombatEffects: [],
|
|
playerCurrency: 0,
|
|
playerInventory: [],
|
|
playerEquipment: createEmptyEquipmentLoadout(),
|
|
npcStates: {},
|
|
quests: [],
|
|
roster: [],
|
|
companions: [],
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* RPG session bootstrap 主实现。
|
|
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
|
*/
|
|
export function useRpgSessionBootstrap() {
|
|
const [gameState, setGameState] = useState<GameState>(() =>
|
|
createInitialGameState(),
|
|
);
|
|
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
|
|
const [isMapOpen, setIsMapOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
|
|
setRuntimeCharacterOverrides(
|
|
gameState.customWorldProfile
|
|
? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile)
|
|
: null,
|
|
);
|
|
}, [gameState.customWorldProfile]);
|
|
|
|
const resetGame = () => {
|
|
setBottomTab('adventure');
|
|
setIsMapOpen(false);
|
|
setGameState(createInitialGameState());
|
|
};
|
|
|
|
const handleCustomWorldSelect = (customWorldProfile: CustomWorldProfile) => {
|
|
const resolvedWorldType = WorldType.CUSTOM;
|
|
setRuntimeCustomWorldProfile(customWorldProfile);
|
|
setRuntimeCharacterOverrides(
|
|
buildCustomWorldRuntimeCharacters(customWorldProfile),
|
|
);
|
|
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
|
setIsMapOpen(false);
|
|
setGameState((prev) =>
|
|
ensureSceneEncounterPreview({
|
|
...prev,
|
|
worldType: resolvedWorldType,
|
|
customWorldProfile,
|
|
currentScenePreset: initialScenePreset,
|
|
sceneHostileNpcs: [],
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
playerProgression: createInitialPlayerProgressionState(),
|
|
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
|
chapterState: null,
|
|
campaignState: null,
|
|
activeScenarioPackId: customWorldProfile?.scenarioPackId ?? null,
|
|
activeCampaignPackId: customWorldProfile?.campaignPackId ?? null,
|
|
lastObserveSignsSceneId: null,
|
|
lastObserveSignsReport: null,
|
|
playerActionMode: 'idle',
|
|
activeCombatEffects: [],
|
|
activeBuildBuffs: [],
|
|
inBattle: false,
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleBackToWorldSelect = () => {
|
|
setBottomTab('adventure');
|
|
setIsMapOpen(false);
|
|
setGameState(createInitialGameState());
|
|
};
|
|
|
|
const handleCharacterSelect = (character: Character) => {
|
|
setBottomTab('adventure');
|
|
setIsMapOpen(false);
|
|
|
|
setGameState((prev) => {
|
|
const resolvedWorldType = prev.worldType;
|
|
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
|
const initialScenePreset = resolvedWorldType
|
|
? (getWorldCampScenePreset(resolvedWorldType) ??
|
|
getScenePreset(resolvedWorldType, 0))
|
|
: null;
|
|
const initialEncounter = createInitialCampEncounter(
|
|
resolvedWorldType,
|
|
character,
|
|
);
|
|
const initialNpcState = initialEncounter
|
|
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
|
: null;
|
|
const initialEquipment = buildInitialEquipmentLoadout(
|
|
character,
|
|
resolvedCustomWorldProfile,
|
|
);
|
|
const explicitStarterItems =
|
|
resolvedWorldType === WorldType.CUSTOM
|
|
? buildExplicitCustomWorldRoleStarterState(
|
|
resolvedCustomWorldProfile!,
|
|
character,
|
|
)
|
|
: null;
|
|
const mergedStarterEquipment = {
|
|
weapon:
|
|
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
|
|
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
|
|
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
|
|
};
|
|
const playerMaxHp = getCharacterMaxHp(
|
|
character,
|
|
resolvedWorldType,
|
|
resolvedCustomWorldProfile,
|
|
);
|
|
|
|
return ensureSceneEncounterPreview(
|
|
applyEquipmentLoadoutToState(
|
|
{
|
|
...prev,
|
|
playerCharacter: character,
|
|
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
|
playerProgression: createInitialPlayerProgressionState(),
|
|
currentScene: 'Story',
|
|
storyHistory: [],
|
|
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
|
chapterState: null,
|
|
campaignState: null,
|
|
activeScenarioPackId:
|
|
prev.customWorldProfile?.scenarioPackId ?? null,
|
|
activeCampaignPackId:
|
|
prev.customWorldProfile?.campaignPackId ?? null,
|
|
characterChats: {},
|
|
currentEncounter: initialEncounter,
|
|
npcInteractionActive: false,
|
|
currentScenePreset: initialScenePreset,
|
|
lastObserveSignsSceneId: null,
|
|
lastObserveSignsReport: null,
|
|
animationState: AnimationState.IDLE,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
playerHp: playerMaxHp,
|
|
playerMaxHp: playerMaxHp,
|
|
playerMana: getCharacterMaxMana(character),
|
|
playerMaxMana: getCharacterMaxMana(character),
|
|
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
|
activeBuildBuffs: [],
|
|
activeCombatEffects: [],
|
|
playerCurrency: getInitialPlayerCurrency(
|
|
resolvedWorldType,
|
|
resolvedCustomWorldProfile,
|
|
),
|
|
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
|
explicitStarterItems?.inventory ?? [],
|
|
buildInitialPlayerInventory(
|
|
character,
|
|
resolvedWorldType,
|
|
resolvedCustomWorldProfile,
|
|
),
|
|
),
|
|
playerEquipment: createEmptyEquipmentLoadout(),
|
|
npcStates:
|
|
initialEncounter && initialNpcState
|
|
? {
|
|
[initialEncounter.id!]: initialNpcState,
|
|
}
|
|
: {},
|
|
quests: [],
|
|
roster: [],
|
|
companions: [],
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
},
|
|
mergedStarterEquipment,
|
|
),
|
|
);
|
|
});
|
|
};
|
|
|
|
return {
|
|
gameState,
|
|
setGameState,
|
|
bottomTab,
|
|
setBottomTab,
|
|
isMapOpen,
|
|
setIsMapOpen,
|
|
resetGame,
|
|
handleCustomWorldSelect,
|
|
handleBackToWorldSelect,
|
|
handleCharacterSelect,
|
|
};
|
|
}
|
|
|
|
export type RpgSessionBootstrapResult = ReturnType<
|
|
typeof useRpgSessionBootstrap
|
|
>;
|