Compare commits

..
2 Commits
Author SHA1 Message Date
phenom eaddad4134 Release Healer Man 0.1.18 2026-09-05 22:15:48 -04:00
phenom fc8a7a722a Release Healer Man 0.1.17 2026-09-05 21:27:30 -04:00
21 changed files with 326 additions and 67 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.16", "version": "0.1.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.16", "version": "0.1.18",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.16", "version": "0.1.18",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.16", "versionName": "0.1.18",
"versionCode": 1016 "versionCode": 1018
} }
@@ -32,6 +32,10 @@ const sources = [
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns", logicalPrefix: "assets/creatures/wailing-caverns",
}, },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{ {
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns", logicalPrefix: "assets/game/dungeons/wailing-caverns",
@@ -33,6 +33,10 @@ const sources = [
directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "public", "assets", "creatures", "wailing-caverns"),
logicalPrefix: "assets/creatures/wailing-caverns", logicalPrefix: "assets/creatures/wailing-caverns",
}, },
{
directory: path.join(PROJECT_ROOT, "public", "assets", "game", "arenas"),
logicalPrefix: "assets/game/arenas",
},
{ {
directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"), directory: path.join(PROJECT_ROOT, "src", "assets", "game", "dungeons", "wailing-caverns"),
logicalPrefix: "assets/game/dungeons/wailing-caverns", logicalPrefix: "assets/game/dungeons/wailing-caverns",
+3
View File
@@ -20,6 +20,7 @@ export const BUNDLED_ANDROID_PACKS = Object.freeze([
"core:equipment", "core:equipment",
"core:character-basic", "core:character-basic",
"core:runtime", "core:runtime",
"core:arenas",
"dungeon:wailing-caverns", "dungeon:wailing-caverns",
]); ]);
@@ -79,6 +80,7 @@ function packLabel(packId) {
if (packId === "core:equipment") return "Starter Equipment"; if (packId === "core:equipment") return "Starter Equipment";
if (packId === "core:character-basic") return "Basic Human Character Model"; if (packId === "core:character-basic") return "Basic Human Character Model";
if (packId === "core:runtime") return "Game Runtime"; if (packId === "core:runtime") return "Game Runtime";
if (packId === "core:arenas") return "Rated Arenas";
if (packId === "core:combat-media") return "Original Spell Effects & Sounds"; if (packId === "core:combat-media") return "Original Spell Effects & Sounds";
return name; return name;
} }
@@ -105,6 +107,7 @@ export function contentPackForPath(logicalPath) {
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) { if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
return `dungeon:${segments[3]}`; return `dungeon:${segments[3]}`;
} }
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) { if (segments[1] === "game" && segments[2] === "manastorm" && segments[3]) {
return `manastorm:${segments[3]}`; return `manastorm:${segments[3]}`;
} }
+2
View File
@@ -20,6 +20,8 @@ test("assigns stable player-facing packs from logical asset paths", () => {
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_01.webp"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1"); assert.equal(contentPackForPath("assets/characters/wow335a-v1/orc/male/rig.glb"), "characters:wow335a-v1");
assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1"); assert.equal(contentPackForPath("assets/game/manastorm/stage-1/visual.glb"), "manastorm:stage-1");
assert.equal(contentPackForPath("assets/game/arenas/nagrand-arena/visual.glb"), "core:arenas");
assert.equal(BUNDLED_ANDROID_PACKS.includes("core:arenas"), true);
assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media"); assert.equal(contentPackForPath("assets/game/combat-effects/audio/smite.wav"), "core:combat-media");
assert.equal( assert.equal(
contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"), contentPackForPath("assets/game/combat-effects/audio/manastorm/Manastorm_BossKill_GenericPortalsReady.wav"),
+1
View File
@@ -39,6 +39,7 @@ export declare const ARENA_TICK_MS: 50;
export declare class ArenaSimulation { export declare class ArenaSimulation {
constructor(options: ArenaSimulationOptions); constructor(options: ArenaSimulationOptions);
start(): this; start(): this;
resetPreparationClock(startedAt?: number): boolean;
stop(): void; stop(): void;
step(): void; step(): void;
snapshot(): ArenaAuthoritativeSnapshot; snapshot(): ArenaAuthoritativeSnapshot;
+7
View File
@@ -156,6 +156,13 @@ export class ArenaSimulation {
return this; return this;
} }
resetPreparationClock(startedAt = Date.now()) {
if (this.interval || this.tickNumber !== 0 || this.phase !== "preparation" || this.finished) return false;
this.startedAt = Number(startedAt);
this.phaseEndsAt = this.startedAt + 10_000;
return true;
}
stop() { stop() {
if (this.interval) clearInterval(this.interval); if (this.interval) clearInterval(this.interval);
this.interval = null; this.interval = null;
+8
View File
@@ -26,6 +26,14 @@ test("authoritative simulation seals ten seconds, runs 20 Hz, and rejects duplic
assert.ok(simulation.actor("left-healer").position[0] > before); assert.ok(simulation.actor("left-healer").position[0] > before);
}); });
test("a deferred practice start receives a fresh preparation clock", () => {
const stage = ARENA_STAGES.find((entry) => entry.id === "warnorken-arena");
const simulation = new ArenaSimulation({ id: "deferred", stage, bracket: "2v2", mode: "practice", ruleset: "wow", participants: participants(), startedAt: 1_000 });
assert.equal(simulation.resetPreparationClock(20_000), true);
assert.equal(simulation.snapshot().serverTime, 20_000);
assert.equal(simulation.snapshot().phaseEndsAt, 30_000);
});
test("Dalaran and Ring of Valor mechanics synchronize collision state", () => { test("Dalaran and Ring of Valor mechanics synchronize collision state", () => {
const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers"); const dalaran = ARENA_STAGES.find((entry) => entry.id === "dalaran-sewers");
const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall"); const warning = arenaDynamicObjectState(dalaran, 27_000).find((entry) => entry.id === "waterfall");
+138 -40
View File
@@ -1,56 +1,104 @@
import { Clone } from "@react-three/drei"; import { Clone, useProgress } from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber"; import { Canvas, useFrame } from "@react-three/fiber";
import { Suspense, useEffect, useMemo, useRef, type RefObject } from "react"; import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { FrontSide, Group, Mesh, Vector3 } from "three"; import { Group, Vector3 } from "three";
import { selectedCharacter, useShellStore } from "./app/shellStore"; import { selectedCharacter, useShellStore } from "./app/shellStore";
import { CharacterModel } from "./avatar/CharacterModel"; import { CharacterModel } from "./avatar/CharacterModel";
import { ProceduralAvatar } from "./avatar/ProceduralAvatar"; import { ProceduralAvatar } from "./avatar/ProceduralAvatar";
import { classById } from "./app/characterCatalog"; import { classById } from "./app/characterCatalog";
import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment"; import { equipmentItemsForOwner, PLAYER_EQUIPMENT_OWNER_ID } from "./game/equipment";
import { useArenaStore } from "./game/arenaStore"; import { useArenaStore } from "./game/arenaStore";
import { arenaCameraPose } from "./game/arenaCamera";
import { arenaStageById } from "./game/arenaStages"; import { arenaStageById } from "./game/arenaStages";
import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes"; import type { ArenaActorSnapshot, ArenaDynamicObjectSnapshot, ArenaStageManifest } from "./game/arenaTypes";
import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF"; import { GameGltfLoaderLifecycle, useGameGLTF } from "./scene/useGameGLTF";
function StageEnvironment({ stage }: { stage: ArenaStageManifest }) { class ArenaStageErrorBoundary extends Component<{
const visual = stage.assets.find((asset) => asset.id === "visual")?.url; readonly children: ReactNode;
return visual ? <LoadedStage url={visual} /> : <ProceduralStage stage={stage} />; 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} floorOnly />
{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`); const gltf = useGameGLTF(`${url}?arena-runtime=1`);
useEffect(() => { useEffect(() => {
gltf.scene.traverse((object) => { onReady();
if (!(object instanceof Mesh)) return; }, [gltf.scene, onReady]);
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material.side = FrontSide;
material.needsUpdate = true;
}
});
}, [gltf.scene]);
return <Clone object={gltf.scene} />; return <Clone object={gltf.scene} />;
} }
function ProceduralStage({ stage }: { stage: ArenaStageManifest }) { function ProceduralStage({ stage, floorOnly = false }: { stage: ArenaStageManifest; floorOnly?: boolean }) {
const width = stage.bounds.max[0] - stage.bounds.min[0]; const width = stage.bounds.max[0] - stage.bounds.min[0];
const depth = stage.bounds.max[2] - stage.bounds.min[2]; const depth = stage.bounds.max[2] - stage.bounds.min[2];
const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2; const centerX = (stage.bounds.max[0] + stage.bounds.min[0]) / 2;
const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2; const centerZ = (stage.bounds.max[2] + stage.bounds.min[2]) / 2;
const floorY = Math.min(...stage.teamSpawns.map((spawn) => spawn[1])) - 0.175;
const wallHeight = 2.4;
return ( return (
<group> <group>
<mesh position={[centerX, stage.bounds.min[1], centerZ]} receiveShadow> <mesh position={[centerX, floorY, centerZ]} receiveShadow>
<boxGeometry args={[width, 0.5, depth]} /> <boxGeometry args={[width, 0.25, depth]} />
<meshStandardMaterial color="#252b27" roughness={0.9} /> <meshStandardMaterial color={floorOnly ? "#364038" : "#3d4d43"} roughness={0.92} />
</mesh> </mesh>
{stage.lineOfSight.map((object) => ( {!floorOnly && (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow> <>
{object.shape === "cylinder" <gridHelper args={[Math.max(width, depth), 24, "#c8aa5c", "#596c5f"]} position={[centerX, floorY + 0.14, centerZ]} />
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} /> {[
: <boxGeometry args={object.size as [number, number, number]} />} [centerX, floorY + wallHeight / 2, stage.bounds.min[2], width, wallHeight, 0.6],
<meshStandardMaterial color="#4e5146" roughness={0.78} /> [centerX, floorY + wallHeight / 2, stage.bounds.max[2], width, wallHeight, 0.6],
</mesh> [stage.bounds.min[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
))} [stage.bounds.max[0], floorY + wallHeight / 2, centerZ, 0.6, wallHeight, depth],
].map(([x, y, z, sizeX, sizeY, sizeZ], index) => (
<mesh key={`boundary-${index}`} position={[x, y, z]} receiveShadow>
<boxGeometry args={[sizeX, sizeY, sizeZ]} />
<meshStandardMaterial color="#26342d" roughness={0.88} />
</mesh>
))}
{stage.teamSpawns.map((spawn, index) => (
<mesh key={`spawn-${index}`} position={[spawn[0], floorY + 0.2, spawn[2]]} receiveShadow>
<cylinderGeometry args={[3.4, 3.4, 0.18, 32]} />
<meshStandardMaterial color={index === 0 ? "#338dcc" : "#bd4944"} emissive={index === 0 ? "#123d5b" : "#541c1a"} emissiveIntensity={0.7} />
</mesh>
))}
{stage.lineOfSight.map((object) => (
<mesh key={object.id} position={object.position as [number, number, number]} castShadow receiveShadow>
{object.shape === "cylinder"
? <cylinderGeometry args={[object.size[0] / 2, object.size[0] / 2, object.size[1], 16]} />
: <boxGeometry args={object.size as [number, number, number]} />}
<meshStandardMaterial color="#6a6f5e" roughness={0.78} />
</mesh>
))}
</>
)}
</group> </group>
); );
} }
@@ -141,31 +189,35 @@ function ArenaActor({ actor, local, character, movement, stage }: { actor: Arena
function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) { function ArenaCamera({ actor, stage }: { actor: ArenaActorSnapshot | null; stage: ArenaStageManifest }) {
const target = useMemo(() => new Vector3(), []); const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []); const desired = useMemo(() => new Vector3(), []);
const pose = useMemo(() => arenaCameraPose(stage, actor?.team ?? 0), [actor?.team, stage]);
useFrame(({ camera }, delta) => { useFrame(({ camera }, delta) => {
if (!actor) return; if (!actor) return;
const spawn = stage.teamSpawns[actor.team]; target.set(...pose.target);
const centerX = (stage.bounds.min[0] + stage.bounds.max[0]) / 2; desired.set(...pose.position);
const centerZ = (stage.bounds.min[2] + stage.bounds.max[2]) / 2;
const directionX = centerX - spawn[0];
const directionZ = centerZ - spawn[2];
const length = Math.max(0.001, Math.hypot(directionX, directionZ));
target.set(actor.position[0], actor.position[1] + 1.2, actor.position[2]);
desired.set(actor.position[0] - directionX / length * 5, actor.position[1] + 7.5, actor.position[2] - directionZ / length * 5);
camera.position.lerp(desired, Math.min(1, delta * 5)); camera.position.lerp(desired, Math.min(1, delta * 5));
camera.lookAt(target); camera.lookAt(target);
}); });
return null; 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, detailedAsset, onStageReady, onStageError }: { stage: ArenaStageManifest; actors: readonly ArenaActorSnapshot[]; dynamicObjects: readonly ArenaDynamicObjectSnapshot[]; localActor: ArenaActorSnapshot | null; character: ReturnType<typeof selectedCharacter>; movement: RefObject<[number, number]>; detailedAsset: boolean; onStageReady: () => void; onStageError: (error: unknown) => void }) {
return ( 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"]} /> <color attach="background" args={["#111815"]} />
<fog attach="fog" args={["#111815", 55, 240]} /> <fog attach="fog" args={["#111815", 55, 240]} />
<hemisphereLight args={["#c9d8cd", "#101713", 1.55]} /> <hemisphereLight args={["#c9d8cd", "#101713", 1.55]} />
<directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" /> <directionalLight castShadow position={[24, 42, 18]} intensity={2.2} color="#f1d6a2" />
<GameGltfLoaderLifecycle> <GameGltfLoaderLifecycle>
<Suspense fallback={null}><StageEnvironment stage={stage} /></Suspense> {detailedAsset ? (
<ArenaStageErrorBoundary key={stage.id} fallback={null} onError={onStageError}>
<Suspense fallback={<ProceduralStage stage={stage} />}><StageEnvironment stage={stage} onReady={onStageReady} /></Suspense>
</ArenaStageErrorBoundary>
) : <ProceduralStage stage={stage} />}
<DynamicStageObjects stage={stage} objects={dynamicObjects} /> <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} />)} {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} /> <ArenaCamera actor={localActor} stage={stage} />
@@ -174,6 +226,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 }) { function TeamFrames({ actors, team }: { actors: readonly ArenaActorSnapshot[]; team: 0 | 1 }) {
return ( return (
<div className={`arena-team-frames arena-team-frames--${team}`}> <div className={`arena-team-frames arena-team-frames--${team}`}>
@@ -194,11 +265,36 @@ export default function ArenaRuntime() {
const intent = useArenaStore((state) => state.intent); const intent = useArenaStore((state) => state.intent);
const leave = useArenaStore((state) => state.leave); const leave = useArenaStore((state) => state.leave);
const load = useArenaStore((state) => state.load); const load = useArenaStore((state) => state.load);
const startPractice = useArenaStore((state) => state.startPractice);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter); const character = useShellStore(selectedCharacter);
const stage = snapshot ? arenaStageById(snapshot.stageId) : null; const stage = snapshot ? arenaStageById(snapshot.stageId) : null;
const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null; const localActor = snapshot?.actors.find((actor) => actor.id === (snapshot.controlledActorId ?? "practice-player")) ?? null;
const movementRef = useRef<[number, number]>([0, 0]); 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(() => { useEffect(() => {
if (snapshot || !character) return; if (snapshot || !character) return;
@@ -252,9 +348,11 @@ export default function ArenaRuntime() {
if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>; if (!snapshot || !stage) return <div className="world-transition"><span>HM</span><strong>Entering arena</strong><small>Waiting for the authoritative match snapshot...</small></div>;
const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null; const seconds = snapshot.phaseEndsAt ? Math.max(0, Math.ceil((snapshot.phaseEndsAt - snapshot.serverTime) / 1000)) : null;
const detailedAsset = stageFailure?.stageId !== stage.id;
return ( return (
<main className="arena-runtime"> <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" key={`${stage.id}:${detailedAsset ? "detailed" : "fallback"}`}><ArenaWorld stage={stage} actors={snapshot.actors} dynamicObjects={snapshot.dynamicObjects} localActor={localActor} character={character} movement={movementRef} detailedAsset={detailedAsset} 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"> <header className="arena-match-header">
<span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em> <span>{stage.name}</span><strong>{snapshot.bracket} · {snapshot.ruleset.toUpperCase()}</strong><em>Dampening {Math.round(snapshot.dampening * 100)}%</em>
</header> </header>
+2
View File
@@ -16,6 +16,7 @@ const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:equipment", "core:equipment",
"core:character-basic", "core:character-basic",
"core:runtime", "core:runtime",
"core:arenas",
"dungeon:wailing-caverns", "dungeon:wailing-caverns",
]); ]);
const STATIC_BROWSER_BUNDLED_PACKS = new Set([ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
@@ -23,6 +24,7 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
"core:equipment", "core:equipment",
"core:character-basic", "core:character-basic",
"core:runtime", "core:runtime",
"core:arenas",
"dungeon:wailing-caverns", "dungeon:wailing-caverns",
]); ]);
+19
View File
@@ -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);
}
}
});
});
+16
View File
@@ -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],
};
}
+7 -1
View File
@@ -55,9 +55,15 @@ export function createArenaPractice(options: PracticeOptions) {
onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }), onSnapshot: (snapshot) => options.onSnapshot({ ...snapshot, controlledActorId: "practice-player" }),
onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }), onResult: (result) => options.onResult({ ...result, ratingUpdates: [] }),
}); });
simulation.start();
options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" }); options.onSnapshot({ ...simulation.snapshot(), controlledActorId: "practice-player" });
let started = false;
return { return {
start() {
if (started) return;
started = true;
simulation.resetPreparationClock();
simulation.start();
},
intent(intent: ArenaCombatIntent) { intent(intent: ArenaCombatIntent) {
return simulation.receiveIntent("practice-player", intent); return simulation.receiveIntent("practice-player", intent);
}, },
+3
View File
@@ -41,6 +41,7 @@ interface ArenaStoreState {
deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>; deleteLoadout: (session: PlayerSession, characterId: string, slot: number) => Promise<boolean>;
queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>; queueMatch: (session: PlayerSession | null, character: CharacterProfile) => Promise<boolean>;
ready: (session: PlayerSession) => Promise<void>; ready: (session: PlayerSession) => Promise<void>;
startPractice: () => void;
leave: (session: PlayerSession | null) => Promise<void>; leave: (session: PlayerSession | null) => Promise<void>;
intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void; intent: (intent: Omit<ArenaCombatIntent, "sequence">) => void;
resetResult: () => void; resetResult: () => void;
@@ -221,6 +222,8 @@ export const useArenaStore = create<ArenaStoreState>((set, get) => ({
catch (error) { set({ error: messageFor(error) }); } catch (error) { set({ error: messageFor(error) }); }
}, },
startPractice: () => get().practice?.start(),
leave: async (session) => { leave: async (session) => {
get().practice?.stop(); get().practice?.stop();
get().socket?.close(); get().socket?.close();
+27
View File
@@ -13,6 +13,7 @@ import {
MINIMUM_CORPSE_VISIBLE_MS, MINIMUM_CORPSE_VISIBLE_MS,
MOB_WOUND_ANIMATION_COOLDOWN_MS, MOB_WOUND_ANIMATION_COOLDOWN_MS,
PLAYER_AURA_ENTITY_ID, PLAYER_AURA_ENTITY_ID,
resolvedAbilityCosts,
selectedMob, selectedMob,
useCombatStore, useCombatStore,
} from "./combatStore"; } from "./combatStore";
@@ -97,6 +98,32 @@ describe("combat store", () => {
expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true); expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true);
}); });
it("uses the displayed primary mana and resolved percentage cost for affordability", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const initial = useCombatStore.getState();
const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt");
expect(frostbolt).toBeDefined();
const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial);
expect(cost).toEqual({ resource: "mana", amount: Math.round(initial.maxResource * 0.14) });
registerTarget("mana-target", [10, 0, 0]);
useCombatStore.setState({
resource: cost.amount,
resourcePools: { ...initial.resourcePools, mana: 0 },
});
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 1_000)).toMatchObject({ ok: true });
useCombatStore.getState().cancelCast();
useCombatStore.setState({
resource: cost.amount - 1,
resourcePools: { ...initial.resourcePools, mana: initial.maxResource },
});
expect(useCombatStore.getState().castAbility(frostbolt!.id, [0, 0, 0], 3_000)).toMatchObject({
ok: false,
reason: "not-enough-resource",
});
});
it("emits exactly one cast and release event and cancels presentation with the cast", () => { it("emits exactly one cast and release event and cancels presentation with the cast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 }); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
registerTarget("presentation-target", [10, 0, 0]); registerTarget("presentation-target", [10, 0, 0]);
+15 -7
View File
@@ -1504,16 +1504,19 @@ function initialResourcePools(
return { pools, maximums }; return { pools, maximums };
} }
function abilityCosts( export function resolvedAbilityCosts(
ability: AbilityDefinition, ability: AbilityDefinition,
modifiers: readonly TalentModifier[], modifiers: readonly TalentModifier[],
state: Pick<CombatState, "maxHealth" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>, state: Pick<CombatState, "classId" | "maxHealth" | "maxResource" | "maxResourcePools"> & Partial<Pick<CombatState, "auras">>,
): readonly { resource: ResourceType; amount: number }[] { ): readonly { resource: ResourceType; amount: number }[] {
const aggregated = new Map<ResourceType, number>(); const aggregated = new Map<ResourceType, number>();
const activeResourceType = resourceProfileForClass(state.classId).type;
for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) { for (const cost of ability.costs?.length ? ability.costs : [ability.cost]) {
const maximum = cost.resource === "health" const maximum = cost.resource === "health"
? state.maxHealth ? state.maxHealth
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource); : cost.resource === activeResourceType
? state.maxResource
: state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource);
const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount; const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount;
const rawAmount = Math.max(0, Math.round(applyTalentModifierValue( const rawAmount = Math.max(0, Math.round(applyTalentModifierValue(
baseAmount, baseAmount,
@@ -3866,7 +3869,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: ability?.target; : ability?.target;
const modifiers = state.talentModifiers; const modifiers = state.talentModifiers;
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0; const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability ? abilityCosts(ability, modifiers, state) : []; const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs; const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const targeting = ability ? targetingRulesForAbility(ability) : null; const targeting = ability ? targetingRulesForAbility(ability) : null;
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical"; const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
@@ -3895,7 +3898,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met"; else if (ability.id === "rom-mage-primary-56" && !hasRuneWakerState("rom-static-field-charge")) failure = "condition-not-met";
else if (costs.some((cost) => cost.resource === "health" else if (costs.some((cost) => cost.resource === "health"
? state.health <= cost.amount ? state.health <= cost.amount
: (state.resourcePools[cost.resource] ?? (cost.resource === resourceProfileForClass(state.classId).type ? state.resource : 0)) < cost.amount)) failure = "not-enough-resource"; : (cost.resource === resourceProfileForClass(state.classId).type
? state.resource
: state.resourcePools[cost.resource] ?? 0) < cost.amount)) failure = "not-enough-resource";
else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes"; else if (runeCost && !spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)) failure = "not-enough-runes";
const playerOrigin = currentOrigin(state, origin); const playerOrigin = currentOrigin(state, origin);
@@ -4060,7 +4065,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.classId, state.classId,
); );
const cooldownMs = talentAdjustedCooldown(ability, modifiers); const cooldownMs = talentAdjustedCooldown(ability, modifiers);
const spentPools = { ...state.resourcePools }; const activeResourceType = resourceProfileForClass(state.classId).type;
const spentPools: Partial<Record<ResourceType, number>> = {
...state.resourcePools,
[activeResourceType]: state.resource,
};
const lastResourceSpentAt = { ...state.lastResourceSpentAt }; const lastResourceSpentAt = { ...state.lastResourceSpentAt };
let healthAfterCosts = state.health; let healthAfterCosts = state.health;
for (const cost of costsToSpend) { for (const cost of costsToSpend) {
@@ -4073,7 +4082,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const spentRunes = runeCost && completingCast?.mode !== "channel" const spentRunes = runeCost && completingCast?.mode !== "channel"
? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now) ? spendDeathKnightRunes(state.deathKnightRunes, runeCost, now)
: state.deathKnightRunes; : state.deathKnightRunes;
const activeResourceType = resourceProfileForClass(state.classId).type;
const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost; const activeResourceAfterCosts = spentPools[activeResourceType] ?? state.resource - resourceCost;
if (!completingCast && castTimeMs > 0) { if (!completingCast && castTimeMs > 0) {
castSequence += 1; castSequence += 1;
+6 -9
View File
@@ -1,6 +1,6 @@
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useThree } from "@react-three/fiber"; 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 type { WebGLRenderer } from "three";
import { KTX2Loader, type GLTFLoader } from "three-stdlib"; import { KTX2Loader, type GLTFLoader } from "three-stdlib";
import { resolveContentUrl } from "../content/contentManager"; import { resolveContentUrl } from "../content/contentManager";
@@ -20,15 +20,12 @@ function ktx2LoaderFor(renderer: WebGLRenderer): KTX2Loader {
return loader; 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 }) { 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; return children;
} }
+45
View File
@@ -3327,6 +3327,51 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.arena-world, .arena-world,
.arena-world > div { position: absolute; inset: 0; } .arena-world > div { position: absolute; inset: 0; }
.arena-render-fallback {
display: grid;
place-content: center;
gap: .4rem;
padding: 2rem;
color: #e8eee4;
text-align: center;
background: radial-gradient(circle at center, #17251f, #07100d 68%);
}
.arena-render-fallback small { max-width: 32rem; color: #b9c4b6; }
.arena-asset-status {
position: absolute;
z-index: 3;
top: 50%;
left: 50%;
display: grid;
width: min(24rem, calc(100vw - 2rem));
gap: .45rem;
padding: .85rem 1rem;
border: 1px solid rgba(220, 190, 99, .5);
text-align: center;
background: rgba(6, 13, 10, .92);
transform: translate(-50%, -50%);
box-shadow: 0 1rem 4rem rgba(0, 0, 0, .45);
pointer-events: none;
}
.arena-asset-status > span {
overflow: hidden;
height: .32rem;
background: rgba(216, 185, 98, .16);
}
.arena-asset-status > span i {
display: block;
height: 100%;
background: #d8b962;
transition: width .15s ease-out;
}
.arena-asset-status small { color: #b9c4b6; }
.arena-asset-status--fallback { top: auto; bottom: 5.3rem; transform: translateX(-50%); }
.arena-match-header { .arena-match-header {
position: absolute; position: absolute;
top: .7rem; top: .7rem;
+14 -5
View File
@@ -4,7 +4,7 @@ import { classById, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog"; import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings"; import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore"; import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
import { respawnDefeatedPlayer } from "../game/deathRespawn"; import { respawnDefeatedPlayer } from "../game/deathRespawn";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resurrectManastormSession } from "../game/manastormSession"; import { resurrectManastormSession } from "../game/manastormSession";
@@ -137,9 +137,12 @@ export function Hud() {
const resource = useCombatStore((state) => state.resource); const resource = useCombatStore((state) => state.resource);
const maxResource = useCombatStore((state) => state.maxResource); const maxResource = useCombatStore((state) => state.maxResource);
const resourcePools = useCombatStore((state) => state.resourcePools); const resourcePools = useCombatStore((state) => state.resourcePools);
const maxResourcePools = useCombatStore((state) => state.maxResourcePools);
const resourceName = useCombatStore((state) => state.resourceName); const resourceName = useCombatStore((state) => state.resourceName);
const abilities = useCombatStore((state) => state.abilities); const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks); const talentRanks = useCombatStore((state) => state.talentRanks);
const talentModifiers = useCombatStore((state) => state.talentModifiers);
const auras = useCombatStore((state) => state.auras);
const actionBindings = useCombatStore((state) => state.actionBindings); const actionBindings = useCombatStore((state) => state.actionBindings);
const cooldowns = useCombatStore((state) => state.cooldowns); const cooldowns = useCombatStore((state) => state.cooldowns);
const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt); const globalCooldownEndsAt = useCombatStore((state) => state.globalCooldownEndsAt);
@@ -190,11 +193,17 @@ export function Hud() {
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock); const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock); const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
const remaining = Math.max(personalRemaining, globalRemaining); const remaining = Math.max(personalRemaining, globalRemaining);
const abilityCosts = ability.costs?.length ? ability.costs : [ability.cost]; const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
classId,
maxHealth,
maxResource,
maxResourcePools,
auras,
});
const cannotPay = abilityCosts.some((cost) => cost.resource === "health" const cannotPay = abilityCosts.some((cost) => cost.resource === "health"
? health <= cost.amount ? health <= cost.amount
: (resourcePools[cost.resource] ?? (cost.resource === resourceTone ? resource : 0)) < cost.amount); : (cost.resource === resourceTone ? resource : resourcePools[cost.resource] ?? 0) < cost.amount);
const displayCost = abilityCosts[0] ?? ability.cost; const displayCost = abilityCosts[0] ?? { resource: ability.cost.resource, amount: 0 };
const item: ActionBarItem = { const item: ActionBarItem = {
id: ability.id, id: ability.id,
name: ability.name, name: ability.name,
@@ -211,7 +220,7 @@ export function Hud() {
target: ability.target, target: ability.target,
}; };
return [control, item]; return [control, item];
})) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, classColor, clock, cooldowns, globalCooldownEndsAt, health, level, resource, resourceName, resourcePools, resourceTone, talentRanks]); })) as ActionBarItems, [abilities, actionBindings, actionLayer, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding( const castBinding = (control: (typeof ACTION_CONTROLS)[number]) => useCombatStore.getState().castActionBinding(
useGameStore.getState().actionLayer, useGameStore.getState().actionLayer,