- }
- onError={onStageError}
- >
- }>
-
+ {detailedAsset ? (
+
+ }>
+
+ ) : }
{actors.map((actor) => )}
@@ -331,9 +348,10 @@ export default function ArenaRuntime() {
if (!snapshot || !stage) return HMEntering arenaWaiting for the authoritative match snapshot...
;
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
+ const detailedAsset = stageFailure?.stageId !== stage.id;
return (
-
+
{stage.name}{snapshot.bracket} ยท {snapshot.ruleset.toUpperCase()}Dampening {Math.round(snapshot.dampening * 100)}%
diff --git a/src/content/contentManager.ts b/src/content/contentManager.ts
index cba5fa99..c42ea493 100644
--- a/src/content/contentManager.ts
+++ b/src/content/contentManager.ts
@@ -16,6 +16,7 @@ const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:equipment",
"core:character-basic",
"core:runtime",
+ "core:arenas",
"dungeon:wailing-caverns",
]);
const STATIC_BROWSER_BUNDLED_PACKS = new Set([
@@ -23,6 +24,7 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
"core:equipment",
"core:character-basic",
"core:runtime",
+ "core:arenas",
"dungeon:wailing-caverns",
]);
diff --git a/src/game/arenaCamera.test.ts b/src/game/arenaCamera.test.ts
new file mode 100644
index 00000000..5b8337c3
--- /dev/null
+++ b/src/game/arenaCamera.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { arenaCameraPose } from "./arenaCamera";
+import { ARENA_STAGES } from "./arenaStages";
+
+describe("arena camera placement", () => {
+ it("uses a finite overview anchor aimed at the playable center of every arena", () => {
+ for (const stage of ARENA_STAGES) {
+ for (const team of [0, 1] as const) {
+ const { position, target } = arenaCameraPose(stage, team);
+ expect(position).toEqual(stage.spectatorPositions[team]);
+ expect(position[0], `${stage.id} team ${team} x`).toBeGreaterThan(stage.bounds.min[0]);
+ expect(position[0], `${stage.id} team ${team} x`).toBeLessThan(stage.bounds.max[0]);
+ expect(position[2], `${stage.id} team ${team} z`).toBeGreaterThan(stage.bounds.min[2]);
+ expect(position[2], `${stage.id} team ${team} z`).toBeLessThan(stage.bounds.max[2]);
+ expect(target.every(Number.isFinite)).toBe(true);
+ }
+ }
+ });
+});
diff --git a/src/game/arenaCamera.ts b/src/game/arenaCamera.ts
new file mode 100644
index 00000000..258f3b1c
--- /dev/null
+++ b/src/game/arenaCamera.ts
@@ -0,0 +1,16 @@
+import type { ArenaStageManifest } from "./arenaTypes";
+
+export interface ArenaCameraPose {
+ readonly position: [number, number, number];
+ readonly target: [number, number, number];
+}
+
+export function arenaCameraPose(stage: ArenaStageManifest, team: 0 | 1): ArenaCameraPose {
+ const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2;
+ const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
+ const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1]));
+ return {
+ position: [...stage.spectatorPositions[team]],
+ target: [centerX, floorY + 1.2, centerZ],
+ };
+}
diff --git a/src/scene/useGameGLTF.ts b/src/scene/useGameGLTF.ts
index e6dbdac2..db6930da 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, useEffect, type ReactNode } from "react";
+import { useCallback, type ReactNode } from "react";
import type { WebGLRenderer } from "three";
import { KTX2Loader, type GLTFLoader } from "three-stdlib";
import { resolveContentUrl } from "../content/contentManager";
@@ -20,15 +20,12 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader {
return loader;
}
-/** Owns the decoder worker pool for one Canvas and releases it on renderer teardown. */
+/**
+ * Marks the part of a Canvas that shares its GLTF decoder. KTX2Loader instances
+ * intentionally live with their weakly referenced renderer: disposing one from
+ * React cleanup can abort a GLB that is still decoding or cached by useGLTF.
+ */
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;
}