Files
healer-man/src/scene/ActivityAssetWarmup.tsx
T
2026-09-12 15:14:56 -04:00

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>
</>
);
}