Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -0,0 +1,300 @@
|
||||
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 } 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,
|
||||
navigationUrl,
|
||||
urls,
|
||||
onSettled,
|
||||
}: {
|
||||
readonly navigationLinks: readonly DungeonNavigationLink[];
|
||||
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,
|
||||
);
|
||||
}, [gltfs, navigationIndex, navigationLinks, navigationUrl]);
|
||||
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,
|
||||
navigationUrl,
|
||||
sceneAssetUrls,
|
||||
onReady,
|
||||
}: {
|
||||
readonly navigationLinks: readonly DungeonNavigationLink[];
|
||||
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}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,12 @@ function AssetObject({ definition }: { readonly definition: DungeonWorldObjectDe
|
||||
|
||||
function ProceduralObject({ definition }: { readonly definition: DungeonWorldObjectDefinition }) {
|
||||
const [x, y, z] = definition.halfExtents;
|
||||
const round = definition.kind === "trap" || definition.kind === "ritual" || definition.kind === "trigger";
|
||||
return (
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[x * 2, y * 2, z * 2]} />
|
||||
{round
|
||||
? <cylinderGeometry args={[Math.max(x, z), Math.max(x, z), y * 2, 16]} />
|
||||
: <boxGeometry args={[x * 2, y * 2, z * 2]} />}
|
||||
<meshStandardMaterial
|
||||
color={definition.visual?.color ?? (definition.kind === "switch" ? "#dca24a" : "#65584b")}
|
||||
emissive={definition.visual?.emissive ?? "#000000"}
|
||||
@@ -45,6 +48,8 @@ function WorldObject({
|
||||
}) {
|
||||
const state = dungeonWorldObjectState(definition, stateId);
|
||||
if (!state.visible) return null;
|
||||
const showVisual = definition.visual?.hidden !== true;
|
||||
if (!showVisual && !state.blocksMovement && !state.blocksSight) return null;
|
||||
return (
|
||||
<RigidBody
|
||||
key={`${definition.id}:${state.id}`}
|
||||
@@ -67,9 +72,9 @@ function WorldObject({
|
||||
sensor
|
||||
/>
|
||||
)}
|
||||
{definition.visual?.assetUrl
|
||||
{showVisual && (definition.visual?.assetUrl
|
||||
? <AssetObject definition={definition} />
|
||||
: <ProceduralObject definition={definition} />}
|
||||
: <ProceduralObject definition={definition} />)}
|
||||
</RigidBody>
|
||||
);
|
||||
}
|
||||
|
||||
+83
-8
@@ -54,12 +54,20 @@ import { useDocumentVisibility } from "../platform/useDocumentVisibility";
|
||||
import { CappedFrameLoop } from "./CappedFrameLoop";
|
||||
import { selectDungeonSessionPopulation } from "../game/dungeonSpawnSelection";
|
||||
import { DungeonWorldObjects } from "./DungeonWorldObjects";
|
||||
import { ActivityAssetWarmup } from "./ActivityAssetWarmup";
|
||||
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { shouldLoadPartyNavigation } from "../game/partyNavigationPolicy";
|
||||
import {
|
||||
activeDungeonCompletedObjectiveIds,
|
||||
activeDungeonEncounterRevision,
|
||||
activeDungeonEncounterSpawnGroupIds,
|
||||
subscribeDungeonEncounterSnapshots,
|
||||
} from "../game/dungeonEncounterRuntime";
|
||||
import { activeEncounterSpawnGroupSpawns } from "../game/dungeonEncounterSpawns";
|
||||
import {
|
||||
activeEncounterSpawnGroupSpawns,
|
||||
suppressCompletedDungeonPopulation,
|
||||
} from "../game/dungeonEncounterSpawns";
|
||||
|
||||
export function GameScene() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
@@ -74,6 +82,9 @@ export function GameScene() {
|
||||
activeDungeonEncounterRevision,
|
||||
);
|
||||
const dungeon = requireDungeonDefinition(activeDungeonId);
|
||||
const completedObjectiveIds = gameMode === "dungeon"
|
||||
? activeDungeonCompletedObjectiveIds(activeDungeonId)
|
||||
: [];
|
||||
const cameraFar = Math.max(
|
||||
950,
|
||||
Math.min(
|
||||
@@ -100,6 +111,26 @@ export function GameScene() {
|
||||
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
|
||||
[dungeon, gameMode, manastormEncounter],
|
||||
);
|
||||
const manastormAssetPackage = gameMode === "manastorm"
|
||||
? activeManastormStageAssetPackage()
|
||||
: null;
|
||||
const activityAssets = useMemo(
|
||||
() => manastormAssetPackage
|
||||
? resolveManastormStageAssets(manastormAssetPackage)
|
||||
: resolveDungeonAssets(dungeon),
|
||||
[dungeon, manastormAssetPackage, sessionRevision],
|
||||
);
|
||||
const navigationUrl = activityAssets.navigation
|
||||
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormAssetPackage)
|
||||
? activityAssets.navigation
|
||||
: null;
|
||||
const navigationLinks = manastormAssetPackage ? [] : dungeon.navigationLinks;
|
||||
const activitySceneAssetUrls = useMemo(() => [...new Set([
|
||||
...Object.values(stagePopulation.entities).flatMap((entity) => (
|
||||
entity.visual.model?.url ? [entity.visual.model.url] : []
|
||||
)),
|
||||
...(navigationUrl ? [navigationUrl] : []),
|
||||
])], [navigationUrl, stagePopulation.entities]);
|
||||
const difficultyRoamingPacks = manastormStageRoamingPacks(
|
||||
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
|
||||
gameMode === "manastorm" ? manastormEncounter : null,
|
||||
@@ -107,14 +138,19 @@ export function GameScene() {
|
||||
const difficultySpawns = stagePopulation.staticSpawns.filter(
|
||||
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
|
||||
);
|
||||
const persistentPopulation = suppressCompletedDungeonPopulation(
|
||||
difficultySpawns,
|
||||
difficultyRoamingPacks,
|
||||
completedObjectiveIds,
|
||||
);
|
||||
const phasedStaticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon"
|
||||
? wailingStaticSpawnsForPhase(difficultySpawns, wailingPhase)
|
||||
: difficultySpawns;
|
||||
? wailingStaticSpawnsForPhase(persistentPopulation.staticSpawns, wailingPhase)
|
||||
: persistentPopulation.staticSpawns;
|
||||
const sessionPopulation = selectDungeonSessionPopulation(
|
||||
activeDungeonId,
|
||||
sessionRevision,
|
||||
phasedStaticSpawns,
|
||||
difficultyRoamingPacks,
|
||||
persistentPopulation.roamingPacks,
|
||||
);
|
||||
const activeSpawnGroupIds = activeDungeonEncounterSpawnGroupIds();
|
||||
const encounterSpawns = activeEncounterSpawnGroupSpawns(
|
||||
@@ -122,7 +158,7 @@ export function GameScene() {
|
||||
activeSpawnGroupIds,
|
||||
difficultyMask,
|
||||
sessionPopulation.staticSpawns,
|
||||
);
|
||||
).filter((spawn) => !completedObjectiveIds.includes(spawn.id));
|
||||
const staticSpawns = [
|
||||
...sessionPopulation.staticSpawns,
|
||||
...encounterSpawns,
|
||||
@@ -136,7 +172,10 @@ export function GameScene() {
|
||||
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
|
||||
const documentVisible = useDocumentVisibility();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [environmentReadySessionRevision, setEnvironmentReadySessionRevision] = useState<number | null>(null);
|
||||
const [activityAssetsReadySessionRevision, setActivityAssetsReadySessionRevision] = useState<number | null>(null);
|
||||
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
|
||||
const proxyAssetStateRef = useRef<{ readonly revision: number; readonly message: string | null } | null>(null);
|
||||
const [safeGraphics, setSafeGraphics] = useState(false);
|
||||
const [adaptiveDpr, setAdaptiveDpr] = useState(1.5);
|
||||
const [canvasRevision, setCanvasRevision] = useState(0);
|
||||
@@ -164,15 +203,42 @@ export function GameScene() {
|
||||
[gameMode, manastormEncounter, manastormPhase],
|
||||
);
|
||||
|
||||
const markWorldReady = useCallback(() => {
|
||||
setReadySessionRevision(sessionRevision);
|
||||
const markEnvironmentReady = useCallback(() => {
|
||||
const game = useGameStore.getState();
|
||||
proxyAssetStateRef.current = game.assetStatus === "proxy"
|
||||
? { revision: sessionRevision, message: game.assetMessage }
|
||||
: null;
|
||||
game.setAssetStatus("loading", "Preparing party, creatures, and navigation.");
|
||||
setEnvironmentReadySessionRevision(sessionRevision);
|
||||
setGraphicsRecovery((state) => state === "recovering" ? null : state);
|
||||
}, [sessionRevision]);
|
||||
|
||||
const markActivityAssetsReady = useCallback(() => {
|
||||
setActivityAssetsReadySessionRevision(sessionRevision);
|
||||
}, [sessionRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
environmentReadySessionRevision !== sessionRevision
|
||||
|| activityAssetsReadySessionRevision !== sessionRevision
|
||||
) return;
|
||||
setReadySessionRevision(sessionRevision);
|
||||
}, [activityAssetsReadySessionRevision, environmentReadySessionRevision, sessionRevision]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!worldReady) return;
|
||||
const proxy = proxyAssetStateRef.current?.revision === sessionRevision
|
||||
? proxyAssetStateRef.current
|
||||
: null;
|
||||
useGameStore.getState().setAssetStatus(proxy ? "proxy" : "ready", proxy?.message ?? null);
|
||||
}, [sessionRevision, worldReady]);
|
||||
|
||||
const restartRenderer = useCallback(() => {
|
||||
releasePointerLock();
|
||||
useGameStore.getState().resetAtActiveSpawn();
|
||||
setSafeGraphics(true);
|
||||
setEnvironmentReadySessionRevision(null);
|
||||
setActivityAssetsReadySessionRevision(null);
|
||||
setReadySessionRevision(null);
|
||||
setGraphicsRecovery("recovering");
|
||||
setCanvasRevision((revision) => revision + 1);
|
||||
@@ -273,6 +339,15 @@ export function GameScene() {
|
||||
/>
|
||||
)}
|
||||
<GameGltfLoaderLifecycle>
|
||||
{!worldReady && (
|
||||
<ActivityAssetWarmup
|
||||
key={`activity-warmup:${sessionRevision}`}
|
||||
navigationLinks={navigationLinks}
|
||||
navigationUrl={navigationUrl}
|
||||
sceneAssetUrls={activitySceneAssetUrls}
|
||||
onReady={markActivityAssetsReady}
|
||||
/>
|
||||
)}
|
||||
{!safeGraphics && worldReady && (
|
||||
<AdaptiveGraphicsQuality
|
||||
frameRateLimit={gameplayFrameRate}
|
||||
@@ -306,7 +381,7 @@ export function GameScene() {
|
||||
paused={simulationBlocked}
|
||||
timeStep={1 / 60}
|
||||
>
|
||||
<DungeonEnvironment onReady={markWorldReady} />
|
||||
<DungeonEnvironment onReady={markEnvironmentReady} />
|
||||
{worldReady && (
|
||||
<>
|
||||
<PlayerRig />
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { BufferGeometry, Quaternion, type Group, type Mesh } from "three";
|
||||
import { Quaternion, type Group } from "three";
|
||||
import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog";
|
||||
import { CharacterModel } from "../avatar/CharacterModel";
|
||||
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
|
||||
@@ -49,12 +49,11 @@ import {
|
||||
type PartyMovementProfile,
|
||||
} from "../game/partyMovement";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
findNearestPartyNavigationSurface,
|
||||
type PartyNavigationGraph,
|
||||
} from "../game/partyNavigation";
|
||||
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
|
||||
import {
|
||||
MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
partyObjectivesForNavigation,
|
||||
shouldLoadPartyNavigation,
|
||||
} from "../game/partyNavigationPolicy";
|
||||
@@ -198,24 +197,6 @@ class NavigationAssetBoundary extends Component<NavigationAssetBoundaryProps, Na
|
||||
}
|
||||
}
|
||||
|
||||
function navigationGeometry(scene: Group): BufferGeometry {
|
||||
scene.updateMatrixWorld(true);
|
||||
let sourceMesh: Mesh | null = null;
|
||||
let largestVertexCount = 0;
|
||||
scene.traverse((node) => {
|
||||
const mesh = node as Mesh;
|
||||
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
|
||||
if (count > largestVertexCount) {
|
||||
sourceMesh = mesh;
|
||||
largestVertexCount = count;
|
||||
}
|
||||
});
|
||||
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
|
||||
const geometry = (sourceMesh as Mesh).geometry.clone();
|
||||
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function PartyNavigationLoader({
|
||||
url,
|
||||
offMeshLinks,
|
||||
@@ -226,17 +207,10 @@ function PartyNavigationLoader({
|
||||
readonly onReady: (graph: PartyNavigationGraph) => void;
|
||||
}) {
|
||||
const gltf = useGLTF(resolveContentUrl(url));
|
||||
const graph = useMemo(() => {
|
||||
const geometry = navigationGeometry(gltf.scene);
|
||||
try {
|
||||
return buildPartyNavigationGraph(geometry, {
|
||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
offMeshLinks,
|
||||
});
|
||||
} finally {
|
||||
geometry.dispose();
|
||||
}
|
||||
}, [gltf.scene, offMeshLinks]);
|
||||
const graph = useMemo(
|
||||
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks),
|
||||
[gltf.scene, offMeshLinks, url],
|
||||
);
|
||||
|
||||
useEffect(() => onReady(graph), [graph, onReady]);
|
||||
return null;
|
||||
|
||||
@@ -261,7 +261,6 @@ export function ProductionDungeon({
|
||||
if (signalledFirstFrame.current) return;
|
||||
if (preparedCollisions.current.size < assets.collision.length) return;
|
||||
signalledFirstFrame.current = true;
|
||||
useGameStore.getState().setAssetStatus("ready");
|
||||
onReady?.();
|
||||
}, [assets.collision.length, onReady]);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const gameSceneSource = readFileSync(new URL("./GameScene.tsx", import.meta.url), "utf8");
|
||||
const productionDungeonSource = readFileSync(new URL("./ProductionDungeon.tsx", import.meta.url), "utf8");
|
||||
const warmupSource = readFileSync(new URL("./ActivityAssetWarmup.tsx", import.meta.url), "utf8");
|
||||
const manastormBridgeSource = readFileSync(new URL("../game/ManastormBridge.tsx", import.meta.url), "utf8");
|
||||
|
||||
describe("activity asset warmup integration", () => {
|
||||
it("keeps the world blocked until activity-specific assets finish", () => {
|
||||
expect(gameSceneSource).toContain("<ActivityAssetWarmup");
|
||||
expect(gameSceneSource).toContain("environmentReadySessionRevision !== sessionRevision");
|
||||
expect(gameSceneSource).toContain("activityAssetsReadySessionRevision !== sessionRevision");
|
||||
expect(gameSceneSource).toContain('setAssetStatus(proxy ? "proxy" : "ready"');
|
||||
expect(productionDungeonSource).not.toContain('setAssetStatus("ready")');
|
||||
});
|
||||
|
||||
it("warms scene, avatar, navigation, and party data while the loading screen is active", () => {
|
||||
expect(warmupSource).toContain("useGameGLTFs(urls)");
|
||||
expect(warmupSource).toContain("preparePartyNavigationGraph(");
|
||||
expect(warmupSource).toContain("loadAvatarTextureSet(");
|
||||
expect(warmupSource).toContain("preloadTalentGameplayResources(classId)");
|
||||
});
|
||||
|
||||
it("defers next-stage Manastorm traffic until combat has ended", () => {
|
||||
expect(manastormBridgeSource).toContain('status !== "portal-ready" && status !== "transitioning"');
|
||||
});
|
||||
});
|
||||
@@ -43,3 +43,13 @@ export function useGameGLTF(url: string) {
|
||||
|
||||
return useGLTF(resolvedUrl, false, true, extendLoader);
|
||||
}
|
||||
|
||||
export function useGameGLTFs(urls: readonly string[]) {
|
||||
const resolvedUrls = urls.map(resolveContentUrl);
|
||||
const renderer = useThree((state) => state.gl);
|
||||
const extendLoader = useCallback((loader: GLTFLoader) => {
|
||||
loader.setKTX2Loader(ktx2LoaderFor(renderer));
|
||||
}, [renderer]);
|
||||
|
||||
return useGLTF(resolvedUrls, false, true, extendLoader);
|
||||
}
|
||||
Reference in new issue
Block a user