forked from phenom/healer-man
307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
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<WarmupBoundaryProps, { readonly failed: boolean }> {
|
|
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 <WarmupCommit onSettled={onSettled} />;
|
|
}
|
|
|
|
interface AvatarWarmupPlan {
|
|
readonly identity: AvatarIdentity;
|
|
readonly entry: AvatarManifestEntry;
|
|
readonly equipment: readonly InventoryItem[];
|
|
}
|
|
|
|
interface AvatarWarmupPayload {
|
|
readonly modelUrls: readonly string[];
|
|
readonly textureLoad: Promise<unknown>;
|
|
}
|
|
|
|
function AsyncWarmupCommit({
|
|
label,
|
|
onSettled,
|
|
promise,
|
|
}: {
|
|
readonly label: string;
|
|
readonly onSettled: () => void;
|
|
readonly promise: Promise<unknown>;
|
|
}) {
|
|
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 (
|
|
<AsyncWarmupCommit
|
|
label="Party avatar textures"
|
|
promise={textureLoad}
|
|
onSettled={onSettled}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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<AvatarWarmupPayload | null>(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 (
|
|
<AsyncWarmupCommit
|
|
label="Party avatar textures"
|
|
promise={payload.textureLoad}
|
|
onSettled={onSettled}
|
|
/>
|
|
);
|
|
}
|
|
return <ResolvedAvatarAssetProbe {...payload} onSettled={onSettled} />;
|
|
}
|
|
|
|
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 ? (
|
|
<WarmupBoundary label="Expedition models and navigation" onSettled={markSceneReady}>
|
|
<Suspense fallback={null}>
|
|
<SceneAssetProbe
|
|
navigationLinks={navigationLinks}
|
|
traversalRamps={traversalRamps}
|
|
navigationUrl={navigationUrl}
|
|
urls={sceneAssetUrls}
|
|
onSettled={markSceneReady}
|
|
/>
|
|
</Suspense>
|
|
</WarmupBoundary>
|
|
) : <WarmupCommit onSettled={markSceneReady} />}
|
|
{!avatarsReady ? (
|
|
<WarmupBoundary label="Party avatar assets" onSettled={markAvatarsReady}>
|
|
<Suspense fallback={null}>
|
|
<AvatarAssetProbe onSettled={markAvatarsReady} />
|
|
</Suspense>
|
|
</WarmupBoundary>
|
|
) : null}
|
|
<WarmupBoundary label="Party talent catalogs" onSettled={markPartyDataReady}>
|
|
<Suspense fallback={null}>
|
|
<PartyDataProbe onSettled={markPartyDataReady} />
|
|
</Suspense>
|
|
</WarmupBoundary>
|
|
</>
|
|
);
|
|
}
|