import { Component, Suspense, useCallback, useEffect, useMemo, useState, type ErrorInfo, type ReactNode, } from "react"; import { useShellStore } from "../app/shellStore"; import type { ClassId } from "../app/characterCatalog"; import type { AvatarIdentity } from "../avatar/CharacterModel"; import { resolveAvatarAppearance } from "../avatar/appearanceResolver"; import { loadAvatarTextureSet } from "../avatar/appearanceTextureCache"; import { availableAvatarManifestUrls, getAvatarEntry, loadAvatarManifest, type AvatarManifestEntry, } from "../avatar/characterManifest"; import { resolveContentUrl } from "../content/contentManager"; import { useCombatStore } from "../game/combatStore"; import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment"; import { resolvedEquipmentItems } from "../game/heirloomEquipment"; import type { InventoryItem } from "../game/lootTypes"; import type { DungeonNavigationLink, DungeonTraversalRamp } from "../game/dungeonTypes"; import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache"; import { usePartyStore } from "../game/partyStore"; import { preloadTalentGameplayResources } from "../ui/talentGameplayResources"; import { useGameGLTFs } from "./useGameGLTF"; interface WarmupBoundaryProps { readonly children: ReactNode; readonly label: string; readonly onSettled: () => void; } class WarmupBoundary extends Component { state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } componentDidCatch(error: unknown, info: ErrorInfo): void { console.warn(`${this.props.label} could not be fully prepared before entering the expedition.`, error, info.componentStack); this.props.onSettled(); } render(): ReactNode { return this.state.failed ? null : this.props.children; } } function WarmupCommit({ onSettled }: { readonly onSettled: () => void }) { useEffect(() => { const frame = window.requestAnimationFrame(onSettled); return () => window.cancelAnimationFrame(frame); }, [onSettled]); return null; } function SceneAssetProbe({ navigationLinks, traversalRamps, navigationUrl, urls, onSettled, }: { readonly navigationLinks: readonly DungeonNavigationLink[]; readonly traversalRamps: readonly DungeonTraversalRamp[]; readonly navigationUrl: string | null; readonly urls: readonly string[]; readonly onSettled: () => void; }) { const gltfs = useGameGLTFs(urls); const navigationIndex = navigationUrl ? urls.indexOf(navigationUrl) : -1; useMemo(() => { if (navigationIndex < 0 || !navigationUrl) return null; return preparePartyNavigationGraph( navigationUrl, gltfs[navigationIndex].scene, navigationLinks, traversalRamps, ); }, [gltfs, navigationIndex, navigationLinks, navigationUrl, traversalRamps]); return ; } interface AvatarWarmupPlan { readonly identity: AvatarIdentity; readonly entry: AvatarManifestEntry; readonly equipment: readonly InventoryItem[]; } interface AvatarWarmupPayload { readonly modelUrls: readonly string[]; readonly textureLoad: Promise; } function AsyncWarmupCommit({ label, onSettled, promise, }: { readonly label: string; readonly onSettled: () => void; readonly promise: Promise; }) { useEffect(() => { let active = true; void promise.catch((error: unknown) => { console.warn(`${label} could not be fully prepared before entering the expedition.`, error); }).then(() => { if (active) onSettled(); }); return () => { active = false; }; }, [label, onSettled, promise]); return null; } function ResolvedAvatarAssetProbe({ modelUrls, onSettled, textureLoad, }: AvatarWarmupPayload & { readonly onSettled: () => void }) { useGameGLTFs(modelUrls); return ( ); } function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) { const activeCharacter = useShellStore((state) => state.activeCharacter); const members = usePartyStore((state) => state.members); const inventory = useCombatStore((state) => state.inventory); const equipment = useCombatStore((state) => state.equipment); const level = useCombatStore((state) => state.level); const specializationId = useCombatStore((state) => state.specializationId); const gearRole = useCombatStore((state) => state.gearRole); const [payload, setPayload] = useState(null); useEffect(() => { let active = true; setPayload(null); void loadAvatarManifest().then((manifest) => { const plans: AvatarWarmupPlan[] = []; if (activeCharacter) { const entry = getAvatarEntry(manifest, activeCharacter.raceId, activeCharacter.gender); if (entry) { plans.push({ identity: activeCharacter, entry, equipment: resolvedEquipmentItems(equipment, inventory, { ownerId: PLAYER_EQUIPMENT_OWNER_ID, ownerKind: "player", classId: activeCharacter.classId, specializationId, role: gearRole, level, }), }); } } for (const member of members) { const entry = getAvatarEntry(manifest, member.raceId, member.gender); if (!entry) continue; plans.push({ identity: member, entry, equipment: resolvedEquipmentItems(equipment, inventory, { ownerId: member.id, ownerKind: "party", classId: member.classId, specializationId: member.specializationId, role: member.role, level: member.level, }), }); } const resolvedPlans = plans.map((plan) => ({ ...plan, appearance: resolveAvatarAppearance( manifest, plan.entry, plan.identity.appearance, plan.identity.classId, plan.equipment, ), })); const modelUrls = [...new Set(resolvedPlans.flatMap(({ appearance, entry }) => [ entry.model, ...appearance.equipmentModels.map((model) => model.url), ]))].map(resolveContentUrl); const textureLoad = Promise.all(resolvedPlans.map((plan) => ( loadAvatarTextureSet(manifest, plan.entry, plan.appearance) ))); if (active) setPayload({ modelUrls, textureLoad }); }).catch((error: unknown) => { console.warn("Party avatar assets could not be fully prepared before entering the expedition.", error); if (active) onSettled(); }); return () => { active = false; }; }, [activeCharacter, equipment, gearRole, inventory, level, members, onSettled, specializationId]); if (!payload) return null; if (!payload.modelUrls.length) { return ( ); } return ; } function PartyDataProbe({ onSettled }: { readonly onSettled: () => void }) { const classIdsKey = usePartyStore((state) => [...new Set(state.members .map((member) => member.classId) .filter((classId) => !classId.startsWith("rom-")))] .sort() .join("|")); useEffect(() => { let active = true; const classIds = classIdsKey ? classIdsKey.split("|") as ClassId[] : []; void Promise.all(classIds.map((classId) => preloadTalentGameplayResources(classId))) .catch((error: unknown) => { console.warn("Party talent catalogs could not be fully prepared before entering the expedition.", error); }) .then(() => { if (active) onSettled(); }); return () => { active = false; }; }, [classIdsKey, onSettled]); return null; } export function ActivityAssetWarmup({ navigationLinks, traversalRamps, navigationUrl, sceneAssetUrls, onReady, }: { readonly navigationLinks: readonly DungeonNavigationLink[]; readonly traversalRamps: readonly DungeonTraversalRamp[]; readonly navigationUrl: string | null; readonly sceneAssetUrls: readonly string[]; readonly onReady: () => void; }) { const [sceneReady, setSceneReady] = useState(false); const [avatarsReady, setAvatarsReady] = useState(!availableAvatarManifestUrls().length); const [partyDataReady, setPartyDataReady] = useState(false); const markSceneReady = useCallback(() => setSceneReady(true), []); const markAvatarsReady = useCallback(() => setAvatarsReady(true), []); const markPartyDataReady = useCallback(() => setPartyDataReady(true), []); useEffect(() => { if (!sceneReady || !avatarsReady || !partyDataReady) return; onReady(); }, [avatarsReady, onReady, partyDataReady, sceneReady]); return ( <> {sceneAssetUrls.length ? ( ) : } {!avatarsReady ? ( ) : null} ); }