Release Healer Man 0.1.17

This commit is contained in:
phenom committed 2026-09-05 21:27:30 -04:00
1 parent b57d2b2839
commit fc8a7a722a
13 files changed
+235 -41

No files matched your search

+103 -23
View File
@@ -1,7 +1,7 @@
import { Clone } from "@react-three/drei";
import { Clone, useProgress } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react";
import { FrontSide, Group, Mesh, Vector3 } from "three";
import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Group, Vector3 } from "three";
import { selectedCharacter, useShellStore } from "./app/shellStore";
import { CharacterModel } from "./avatar/CharacterModel";
import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
@@ -12,23 +12,46 @@ import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) {
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />;
class ArenaStageErrorBoundary extends Component<{
readonly children: ReactNode;
readonly fallback: ReactNode;
readonly onError: (error: unknown) => void;
}, { readonly failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
this.props.onError(error);
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
function LoadedStage({ url }: { url: string }) {
function StageEnvironment({ stage, onReady }: { stage: ArenaStageManifest; onReady: () => void }) {
const visual = stage.assets.find((asset) => asset.id === "visual")?.url;
return (
<>
<ProceduralStage stage={stage} />
{visual ? <LoadedStage url={visual} onReady={onReady} /> : <StageReady onReady={onReady} />}
</>
);
}
function StageReady({ onReady }: { readonly onReady: () => void }) {
useEffect(onReady, [onReady]);
return null;
}
function LoadedStage({ url, onReady }: { url: string; onReady: () => void }) {
const gltf = useGameGLTF(`${url}?arena-runtime=1`);
useEffect(() => {
gltf.scene.traverse((object) => {
if (!(object instanceof Mesh)) return;
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material.side = FrontSide;
material.needsUpdate = true;
}
});
}, [gltf.scene]);
onReady();
}, [gltf.scene, onReady]);
return <Clone object={gltf.scene} />;
}
@@ -37,11 +60,12 @@ function ProceduralStage({ stage }: { stage: ArenaStageManifest }) {
const depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
return (
<group>
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow>
<boxGeometry args={[width, 0.5, depth]} />
<meshStandardMaterial color="#252b27" roughness={0.9} />
<mesh position={[centerX, floorY, centerZ]} receiveShadow>
<boxGeometry args={[width, 0.25, depth]} />
<meshStandardMaterial color="#364038" roughness={0.92} />
</mesh>
{stage.lineOfSight.map((object) => (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
@@ -157,15 +181,26 @@ function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage
return null;
}
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]> }) {
function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, movement, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; onStageReady: () => void; onStageError: (error: unknown) => void }) {
return (
<Canvas shadows dpr={[1, 1.35]} camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}>
<Canvas
shadows
dpr={[1, 1.35]}
camera={{ fov: 52, near: 0.1, far: 1200, position: [0, 16, 24] }}
fallback={<div className="arena-render-fallback"><strong>3D arena unavailable</strong><small>This device could not start WebGL. Return to Arena Command and try another renderer.</small></div>}
>
<color attach="background" args={["#111815"]} />
<fog attach="fog" args={["#111815", 55, 240]} />
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle>
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense>
<ArenaStageErrorBoundary
key={stage.id}
fallback={<ProceduralStage stage={stage} />}
onError={onStageError}
>
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
</ArenaStageErrorBoundary>
<DynamicStageObjects stage={stage} objects={dynamicObjects} />
{actors.map((actor) => <ArenaActor key={actor.id} actor={actor} local={actor.id === localActor?.id} character={character} movement={movement} stage={stage} />)}
<ArenaCamera actor={localActor} stage={stage} />
@@ -174,6 +209,25 @@ function ArenaWorld({ stage, actors, dynamicObjects, localActor, character, move
);
}
function ArenaAssetStatus({ stage, ready, fallbackReason }: { readonly stage: ArenaStageManifest; readonly ready: boolean; readonly fallbackReason: string | null }) {
const { active, progress } = useProgress();
if (ready && !active && !fallbackReason) return null;
const value = Math.max(3, Math.min(100, Math.round(progress)));
return (
<section className={`arena-asset-status${fallbackReason ? " arena-asset-status--fallback" : ""}`} role="status" aria-live="polite">
<strong>{fallbackReason ? "Arena ready with safe geometry" : `Loading ${stage.name}`}</strong>
{fallbackReason ? (
<small>The detailed arena asset could not be decoded, so a collision-matched fallback is active.</small>
) : (
<>
<span><i style={{ width: `${value}%` }} /></span>
<small>Streaming arena geometry · {value}%</small>
</>
)}
</section>
);
}
function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
return (
<div className={`arena-team-frames arena-team-frames--${team}`}>
@@ -194,11 +248,36 @@ export default function ArenaRuntime() {
const intent = useArenaStore((state) => state.intent);
const leave = useArenaStore((state) => state.leave);
const load = useArenaStore((state) => state.load);
const startPractice = useArenaStore((state) => state.startPractice);
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const movementRef = useRef<[number, number]>([0, 0]);
const [loadedStageId, setLoadedStageId] = useState<string | null>(null);
const [stageFailure, setStageFailure] = useState<{ readonly stageId: string; readonly reason: string } | null>(null);
const handleStageReady = useCallback(() => {
if (!stage) return;
setLoadedStageId(stage.id);
setStageFailure((current) => current?.stageId === stage.id ? null : current);
startPractice();
}, [stage, startPractice]);
const handleStageError = useCallback((error: unknown) => {
if (!stage) return;
setLoadedStageId(stage.id);
setStageFailure({ stageId: stage.id, reason: error instanceof Error ? error.message : "Arena asset failed to load" });
startPractice();
}, [stage, startPractice]);
useEffect(() => {
if (!stage || loadedStageId === stage.id) return;
const timeout = window.setTimeout(() => {
setLoadedStageId(stage.id);
setStageFailure({ stageId: stage.id, reason: "Arena asset loading timed out" });
startPractice();
}, 30_000);
return () => window.clearTimeout(timeout);
}, [loadedStageId, stage, startPractice]);
useEffect(() => {
if (snapshot || !character) return;
@@ -254,7 +333,8 @@ export default function ArenaRuntime() {
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
return (
<main className="arena-runtime">
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} /></div>
<div className="arena-world"><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} onStageReady={handleStageReady} onStageError={handleStageError} /></div>
<ArenaAssetStatus stage={stage} ready={loadedStageId === stage.id} fallbackReason={stageFailure?.stageId === stage.id ? stageFailure.reason : null} />
<header className="arena-match-header">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
</header>