Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+300
View File
@@ -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>
</>
);
}
+8 -3
View File
@@ -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
View File
@@ -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 />
+6 -32
View File
@@ -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;
-1
View File
@@ -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"');
});
});
+10
View File
@@ -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);
}