diff --git a/src/game/dungeonSession.ts b/src/game/dungeonSession.ts index 7fe6f905..e1491b16 100644 --- a/src/game/dungeonSession.ts +++ b/src/game/dungeonSession.ts @@ -77,3 +77,16 @@ export function activateDungeonSession( useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition); return true; } + +/** + * Rebuilds the current encounter after an asset/render failure without + * discarding the selected character, party composition, or run progression. + */ +export function restartActiveDungeonSession(): boolean { + const game = useGameStore.getState(); + return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, { + gameMode: game.gameMode, + spawn: game.activeSpawn, + preserveCharacter: true, + }); +} diff --git a/src/game/dungeonSessionAssetCache.test.ts b/src/game/dungeonSessionAssetCache.test.ts index 3e60e68b..1e2d03e6 100644 --- a/src/game/dungeonSessionAssetCache.test.ts +++ b/src/game/dungeonSessionAssetCache.test.ts @@ -2,7 +2,8 @@ import { useGLTF } from "@react-three/drei"; import { afterEach, describe, expect, it, vi } from "vitest"; import { dungeonGltfUrls } from "./dungeonAssets"; import { dungeonDefinitionById } from "./dungeonRegistry"; -import { activateDungeonSession } from "./dungeonSession"; +import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession"; +import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; describe("dungeon session asset eviction", () => { @@ -28,4 +29,19 @@ describe("dungeon session asset eviction", () => { expect(activateDungeonSession("not-a-dungeon")).toBe(false); expect(clear).not.toHaveBeenCalled(); }); + + it("restarts the active dungeon in place while preserving its party", () => { + const active = dungeonDefinitionById("deadmines")!; + useGameStore.getState().activateDungeon(active.id); + usePartyStore.getState().initializeParty("retry-party", 20, 600, "healer"); + const members = usePartyStore.getState().members.map((member) => member.id); + const revision = useGameStore.getState().sessionRevision; + const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined); + + expect(restartActiveDungeonSession()).toBe(true); + + expect(clear.mock.calls.map(([url]) => url)).toEqual(dungeonGltfUrls(active)); + expect(useGameStore.getState().sessionRevision).toBe(revision + 1); + expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members); + }); }); diff --git a/src/scene/GameScene.tsx b/src/scene/GameScene.tsx index 37c25f94..b8fc7756 100644 --- a/src/scene/GameScene.tsx +++ b/src/scene/GameScene.tsx @@ -36,6 +36,7 @@ import { wailingStaticSpawnsForPhase, } from "../game/wailingCavernsEncounter"; import { WailingNaralexEvent } from "./WailingNaralexEvent"; +import { GameGltfLoaderLifecycle } from "./useGameGLTF"; export function GameScene() { const activeDungeonId = useGameStore((state) => state.activeDungeonId); @@ -198,45 +199,51 @@ export function GameScene() { powerPreference: safeGraphics ? "default" : "high-performance", }} > - - - - - - - - - - + + + + + - - - + + + {worldReady && ( + <> + + + + + + + )} + + {graphicsRecovery && (
{ + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.warn("Creature model fell back to procedural rendering", error, info.componentStack); + } + + componentDidUpdate(previous: Readonly): void { + if (previous.resetKey !== this.props.resetKey && this.state.failed) { + this.setState({ failed: false }); + } + } + + render(): ReactNode { + return this.state.failed ? this.props.fallback : this.props.children; + } +} + function prepareCreatureModel(root: Object3D): Object3D { const cachedTemplate = preparedCreatureTemplates.get(root); if (cachedTemplate) return cachedTemplate; @@ -902,18 +933,20 @@ function ProxyMob({ forceFullFidelity={forceFullFidelity} > {model ? ( - - - + + + + + ) : ( proxyBody )} diff --git a/src/scene/ProductionDungeon.tsx b/src/scene/ProductionDungeon.tsx index d1240164..8b35f2fe 100644 --- a/src/scene/ProductionDungeon.tsx +++ b/src/scene/ProductionDungeon.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { RigidBody, TrimeshCollider } from "@react-three/rapier"; import { AdditiveBlending, @@ -15,6 +15,11 @@ import { useGameStore } from "../game/store"; import { collisionTrimeshes, prepareCollision } from "./productionDungeonCollision"; import { disposeObject3DResources } from "./threeResourceDisposal"; import { useGameGLTF } from "./useGameGLTF"; +import { + DUNGEON_COLLISION_LOAD_BATCH_SIZE, + DUNGEON_VISUAL_LOAD_BATCH_SIZE, + advanceDungeonAssetCount, +} from "./dungeonLoadSequencing"; function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean { const lower = name.toLowerCase(); @@ -90,6 +95,40 @@ function VisualChunk({ url, definition }: { readonly url: string; readonly defin return ; } +function AssetProbe({ url }: { readonly url: string }) { + useGameGLTF(url); + return null; +} + +function CommitLoadedBatch({ onReady }: { readonly onReady: () => void }) { + useEffect(() => { + const frame = window.requestAnimationFrame(onReady); + return () => window.cancelAnimationFrame(frame); + }, [onReady]); + return null; +} + +/** + * Suspends only the small batch currently being fetched. Previously prepared + * visuals and colliders stay mounted while the next batch enters the GLTF + * cache, avoiding one cold-start burst across the entire dungeon package. + */ +function AssetBatchLoader({ + urls, + onReady, +}: { + readonly urls: readonly string[]; + readonly onReady: () => void; +}) { + if (!urls.length) return null; + return ( + + {urls.map((url) => )} + + + ); +} + function CollisionChunk({ url, definition, @@ -146,8 +185,53 @@ export function ProductionDungeon({ if (!assets.visual.length || !assets.collision.length) { throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`); } + const [loadedVisualCount, setLoadedVisualCount] = useState(0); + const [loadedCollisionCount, setLoadedCollisionCount] = useState(0); const preparedCollisions = useRef(new Set()); const signalledFirstFrame = useRef(false); + const pendingVisuals = assets.visual.slice( + loadedVisualCount, + advanceDungeonAssetCount( + loadedVisualCount, + assets.visual.length, + DUNGEON_VISUAL_LOAD_BATCH_SIZE, + ), + ); + const visualsLoaded = loadedVisualCount >= assets.visual.length; + const pendingCollisions = visualsLoaded + ? assets.collision.slice( + loadedCollisionCount, + advanceDungeonAssetCount( + loadedCollisionCount, + assets.collision.length, + DUNGEON_COLLISION_LOAD_BATCH_SIZE, + ), + ) + : []; + const pendingUrls = pendingVisuals.length ? pendingVisuals : pendingCollisions; + const commitLoadedBatch = useCallback(() => { + if (!visualsLoaded) { + setLoadedVisualCount((current) => advanceDungeonAssetCount( + current, + assets.visual.length, + DUNGEON_VISUAL_LOAD_BATCH_SIZE, + )); + return; + } + setLoadedCollisionCount((current) => advanceDungeonAssetCount( + current, + assets.collision.length, + DUNGEON_COLLISION_LOAD_BATCH_SIZE, + )); + }, [assets.collision.length, assets.visual.length, visualsLoaded]); + + useEffect(() => { + useGameStore.getState().setAssetStatus( + "loading", + `Preparing ${loadedVisualCount}/${assets.visual.length} visual and ${loadedCollisionCount}/${assets.collision.length} collision chunks.`, + ); + }, [assets.collision.length, assets.visual.length, loadedCollisionCount, loadedVisualCount]); + const registerPrepared = useCallback((url: string, ready: boolean) => { if (!ready) { preparedCollisions.current.delete(url); @@ -163,8 +247,10 @@ export function ProductionDungeon({ return ( - {assets.visual.map((url) => )} - {assets.collision.map((url) => ( + {assets.visual.slice(0, loadedVisualCount).map((url) => ( + + ))} + {assets.collision.slice(0, loadedCollisionCount).map((url) => ( ))} + ); } diff --git a/src/scene/dungeonLoadSequencing.test.ts b/src/scene/dungeonLoadSequencing.test.ts new file mode 100644 index 00000000..d4b35597 --- /dev/null +++ b/src/scene/dungeonLoadSequencing.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + DUNGEON_COLLISION_LOAD_BATCH_SIZE, + DUNGEON_VISUAL_LOAD_BATCH_SIZE, + advanceDungeonAssetCount, +} from "./dungeonLoadSequencing"; + +describe("dungeon asset load sequencing", () => { + it("loads visuals individually and collision meshes in bounded batches", () => { + expect(DUNGEON_VISUAL_LOAD_BATCH_SIZE).toBe(1); + expect(DUNGEON_COLLISION_LOAD_BATCH_SIZE).toBe(4); + expect(advanceDungeonAssetCount(0, 48, DUNGEON_COLLISION_LOAD_BATCH_SIZE)).toBe(4); + expect(advanceDungeonAssetCount(44, 48, DUNGEON_COLLISION_LOAD_BATCH_SIZE)).toBe(48); + }); + + it("clamps malformed counters instead of creating an unbounded window", () => { + expect(advanceDungeonAssetCount(-3, 2, 0)).toBe(1); + expect(advanceDungeonAssetCount(8, 2, 4)).toBe(2); + }); +}); diff --git a/src/scene/dungeonLoadSequencing.ts b/src/scene/dungeonLoadSequencing.ts new file mode 100644 index 00000000..24ad7e20 --- /dev/null +++ b/src/scene/dungeonLoadSequencing.ts @@ -0,0 +1,14 @@ +export const DUNGEON_VISUAL_LOAD_BATCH_SIZE = 1; +export const DUNGEON_COLLISION_LOAD_BATCH_SIZE = 4; + +/** Advances a staged asset window without overshooting the available files. */ +export function advanceDungeonAssetCount( + loaded: number, + total: number, + batchSize: number, +): number { + const safeLoaded = Math.max(0, Math.trunc(loaded)); + const safeTotal = Math.max(0, Math.trunc(total)); + const safeBatchSize = Math.max(1, Math.trunc(batchSize)); + return Math.min(safeTotal, safeLoaded + safeBatchSize); +} diff --git a/src/scene/useGameGLTF.ts b/src/scene/useGameGLTF.ts index 9b0a52f2..e6dbdac2 100644 --- a/src/scene/useGameGLTF.ts +++ b/src/scene/useGameGLTF.ts @@ -1,6 +1,6 @@ import { useGLTF } from "@react-three/drei"; import { useThree } from "@react-three/fiber"; -import { useCallback } from "react"; +import { useCallback, useEffect, type ReactNode } from "react"; import type { WebGLRenderer } from "three"; import { KTX2Loader, type GLTFLoader } from "three-stdlib"; import { resolveContentUrl } from "../content/contentManager"; @@ -20,6 +20,18 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader { return loader; } +/** Owns the decoder worker pool for one Canvas and releases it on renderer teardown. */ +export function GameGltfLoaderLifecycle({ children }: { readonly children: ReactNode }) { + const renderer = useThree((state) => state.gl); + useEffect(() => () => { + const loader = ktx2Loaders.get(renderer); + if (!loader) return; + loader.dispose(); + ktx2Loaders.delete(renderer); + }, [renderer]); + return children; +} + /** * Loads shipped world and creature GLBs with Meshopt and KTX2 support. * Keeping one KTX2Loader per renderer also keeps Three from creating a worker diff --git a/src/ui/Hud.tsx b/src/ui/Hud.tsx index 95fdf498..4c4bd774 100644 --- a/src/ui/Hud.tsx +++ b/src/ui/Hud.tsx @@ -29,12 +29,7 @@ function LoadingOverlay() { const encounter = useManastormStore((state) => state.currentEncounter); const dungeon = requireDungeonDefinition(activeDungeonId); const assetStatus = useGameStore((state) => state.assetStatus); - const setAssetStatus = useGameStore((state) => state.setAssetStatus); - const { active, progress, item, errors } = useProgress(); - - useEffect(() => { - if (errors.length) setAssetStatus("error", `Could not load ${errors[0]}`); - }, [errors, setAssetStatus]); + const { active, progress, item } = useProgress(); if (assetStatus !== "checking" && assetStatus !== "loading") return null; const networkComplete = !active && progress >= 100; diff --git a/src/ui/SceneErrorBoundary.test.ts b/src/ui/SceneErrorBoundary.test.ts new file mode 100644 index 00000000..076b5c4c --- /dev/null +++ b/src/ui/SceneErrorBoundary.test.ts @@ -0,0 +1,55 @@ +import type { ErrorInfo } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { restartActiveDungeonSession } from "../game/dungeonSession"; +import { SceneErrorBoundary } from "./SceneErrorBoundary"; + +vi.mock("../game/dungeonSession", () => ({ + restartActiveDungeonSession: vi.fn(() => true), +})); + +describe("scene error recovery", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("automatically rebuilds the active dungeon without reloading the page", () => { + vi.useFakeTimers(); + vi.stubGlobal("window", { + setTimeout, + clearTimeout, + location: { reload: vi.fn() }, + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const boundary = new SceneErrorBoundary({ children: null }); + boundary.state = { + error: new Error("transient GLB failure"), + retryCount: 0, + retryScheduled: false, + }; + boundary.setState = vi.fn((patch) => { + const next = typeof patch === "function" + ? patch(boundary.state, boundary.props) + : patch; + if (next) boundary.state = { ...boundary.state, ...next }; + }) as typeof boundary.setState; + + boundary.componentDidCatch( + boundary.state.error!, + { componentStack: "\n at DungeonEnvironment" } as ErrorInfo, + ); + expect(boundary.state.retryScheduled).toBe(true); + + vi.advanceTimersByTime(500); + + expect(restartActiveDungeonSession).toHaveBeenCalledOnce(); + expect(window.location.reload).not.toHaveBeenCalled(); + expect(boundary.state).toMatchObject({ + error: null, + retryCount: 1, + retryScheduled: false, + }); + boundary.componentWillUnmount(); + }); +}); diff --git a/src/ui/SceneErrorBoundary.tsx b/src/ui/SceneErrorBoundary.tsx index 85f82ab2..be9bd95a 100644 --- a/src/ui/SceneErrorBoundary.tsx +++ b/src/ui/SceneErrorBoundary.tsx @@ -1,19 +1,69 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { restartActiveDungeonSession } from "../game/dungeonSession"; import { useGameStore } from "../game/store"; interface Props { children: ReactNode } -interface State { error: Error | null } +interface State { + error: Error | null; + retryCount: number; + retryScheduled: boolean; +} + +const MAX_AUTOMATIC_RETRIES = 1; +const AUTOMATIC_RETRY_DELAY_MS = 500; +const RETRY_BUDGET_RESET_MS = 60_000; export class SceneErrorBoundary extends Component { - state: State = { error: null }; + state: State = { error: null, retryCount: 0, retryScheduled: false }; + private retryTimer: number | null = null; + private retryBudgetTimer: number | null = null; - static getDerivedStateFromError(error: Error): State { + static getDerivedStateFromError(error: Error): Partial { return { error }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error("Healer Man scene failed", error, info.componentStack); useGameStore.getState().setAssetStatus("error", error.message); + if (this.retryBudgetTimer !== null) { + window.clearTimeout(this.retryBudgetTimer); + this.retryBudgetTimer = null; + } + if (this.state.retryCount < MAX_AUTOMATIC_RETRIES) this.scheduleRetry(); + } + + componentWillUnmount(): void { + if (this.retryTimer !== null) window.clearTimeout(this.retryTimer); + if (this.retryBudgetTimer !== null) window.clearTimeout(this.retryBudgetTimer); + } + + private scheduleRetry(): void { + if (this.retryTimer !== null) return; + this.setState({ retryScheduled: true }); + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null; + this.retryScene(); + }, AUTOMATIC_RETRY_DELAY_MS); + } + + private retryScene = (): void => { + const retryCount = this.state.retryCount + 1; + try { + if (!restartActiveDungeonSession()) { + this.setState({ retryScheduled: false }); + return; + } + this.setState({ error: null, retryCount, retryScheduled: false }); + this.retryBudgetTimer = window.setTimeout(() => { + this.retryBudgetTimer = null; + if (!this.state.error) this.setState({ retryCount: 0 }); + }, RETRY_BUDGET_RESET_MS); + } catch (error) { + const nextError = error instanceof Error ? error : new Error("The dungeon retry failed."); + console.error("Healer Man scene retry failed", nextError); + useGameStore.getState().setAssetStatus("error", nextError.message); + this.setState({ error: nextError, retryCount, retryScheduled: false }); + } } render() { @@ -21,9 +71,17 @@ export class SceneErrorBoundary extends Component { return (

Dungeon load interrupted

-

The cavern could not be assembled.

-

{this.state.error.message}

- +

{this.state.retryScheduled ? "Recovering the expedition..." : "The cavern could not be assembled."}

+

+ {this.state.retryScheduled + ? "Clearing the failed asset cache and rebuilding the dungeon in smaller batches." + : this.state.error.message} +

+ {!this.state.retryScheduled && ( + + )}
); }