Release Healer Man 0.1.29

This commit is contained in:
phenom committed 2026-09-11 14:26:15 -04:00
1 parent 8c9809d3e0
commit 544c1bee91
120 files changed
+4363 -1036

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useMemo, useRef, type RefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { Html } from "@react-three/drei";
import { Group, Plane, Raycaster, Vector2, Vector3 } from "three";
import { CombatEffects } from "./CombatEffects";
import { emitCombatPresentation } from "../game/combatPresentation";
import { useArenaStore } from "../game/arenaStore";
import type { ArenaAuthoritativeSnapshot, Vec3Tuple } from "../game/arenaTypes";
export function ArenaCombatEffects({ snapshot, actorId }: { snapshot: ArenaAuthoritativeSnapshot; actorId?: string }) {
const seen = useRef(new Set<number>());
useEffect(() => {
const team = snapshot.actors.find((actor) => actor.id === actorId)?.team;
for (const event of snapshot.combatPresentation ?? []) {
if (seen.current.has(event.id)) continue;
seen.current.add(event.id);
const source = snapshot.actors.find((actor) => actor.id === event.sourceActorId);
emitCombatPresentation({ ...event, occurredAt: Date.now(),
actorGroup: event.sourceActorId === actorId ? "player" : source?.team === team ? "party" : "enemy" });
}
if (seen.current.size > 500) seen.current = new Set((snapshot.combatPresentation ?? []).map((event) => event.id));
}, [snapshot, actorId]);
return <>
<CombatEffects active />
{snapshot.actors.flatMap((actor) => (actor.combat?.summons ?? []).map((summon) => <group key={summon.id} name={`arena-summon:${summon.id}`} position={summon.position}
onPointerDown={(event) => { if (event.button === 0) { event.stopPropagation(); useArenaStore.getState().intent({ type: "target", targetActorId: summon.id }); } }}>
<mesh position={[0, .55, 0]} castShadow>{summon.kind === "totem" ? <cylinderGeometry args={[.22, .38, 1.1, 8]} /> : <capsuleGeometry args={[.32, .55, 5, 10]} />}
<meshStandardMaterial color={actor.team === snapshot.actors.find((entry) => entry.id === actorId)?.team ? "#89bbbd" : "#c36966"} emissive={summon.kind === "totem" ? "#1f4939" : "#000000"} /></mesh>
<Html center position={[0, 1.3, 0]} distanceFactor={12}><span className="arena-summon-name">{summon.name}</span></Html>
</group>))}
{snapshot.actors.flatMap((actor) => (actor.combat?.areaEffects ?? []).map((area) => <mesh key={area.id} position={[area.position[0], area.position[1] + .04, area.position[2]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[Math.max(.1, area.radius - .12), area.radius, 48]} /><meshBasicMaterial color={actor.team === snapshot.actors.find((entry) => entry.id === actorId)?.team ? "#79d7e8" : "#ed685c"} transparent opacity={.6} depthWrite={false} />
</mesh>))}
</>;
}
export function ArenaGroundTarget({ environment, position, radius, origin, controller }: {
environment: RefObject<Group | null>; position: RefObject<Vec3Tuple>; radius: number; origin: Vec3Tuple; controller: boolean;
}) {
const ring = useRef<Group>(null);
const ray = useMemo(() => new Raycaster(), []);
const plane = useMemo(() => new Plane(new Vector3(0, 1, 0)), []);
const point = useMemo(() => new Vector3(), []);
const center = useMemo(() => new Vector2(), []);
useFrame(({ camera, pointer }) => {
ray.setFromCamera(controller ? center : pointer, camera);
const hit = environment.current ? ray.intersectObject(environment.current, true).find((entry) => Math.abs(entry.point.y - origin[1]) < 6) : null;
plane.constant = -origin[1];
if (hit) point.copy(hit.point);
else if (!ray.ray.intersectPlane(plane, point)) return;
position.current = [point.x, point.y, point.z];
ring.current?.position.set(point.x, point.y + .06, point.z);
});
return <group ref={ring}><mesh rotation={[-Math.PI / 2, 0, 0]}><ringGeometry args={[Math.max(.1, radius - .12), radius, 48]} /><meshBasicMaterial color="#81edbd" transparent opacity={.9} depthWrite={false} /></mesh></group>;
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useMemo } from "react";
import { BufferGeometry, Float32BufferAttribute, DoubleSide } from "three";
import geometry from "../../server/generated/arena-geometry.json" with { type: "json" };
/** The fallback draws the same surfaces used by the authoritative simulation. */
export default function ArenaFallbackStage({ stageId }: { stageId: string }) {
const meshes = useMemo(() => {
const data = (geometry as Record<string, { navigation: number[]; collision: number[] }>)[stageId];
return ["navigation", "collision"].map((kind) => {
const mesh = new BufferGeometry();
mesh.setAttribute("position", new Float32BufferAttribute(data?.[kind as "navigation" | "collision"] ?? [], 3));
mesh.computeVertexNormals();
return mesh;
});
}, [stageId]);
useEffect(() => () => meshes.forEach((mesh) => mesh.dispose()), [meshes]);
return <group>
<mesh geometry={meshes[0]} position={[0, -.025, 0]} receiveShadow><meshStandardMaterial color="#536452" side={DoubleSide} roughness={.9} /></mesh>
<mesh geometry={meshes[1]} receiveShadow castShadow><meshStandardMaterial color="#66705e" side={DoubleSide} roughness={.85} /></mesh>
</group>;
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useMemo } from "react";
import { BufferGeometry, Float32BufferAttribute, DoubleSide } from "three";
import navigation from "../../server/generated/arena-navigation.json";
export function ArenaNavigationSurface({ stageId }: { stageId: string }) {
const geometry = useMemo(() => {
const mesh = new BufferGeometry();
mesh.setAttribute("position", new Float32BufferAttribute((navigation as Record<string, number[]>)[stageId] ?? [], 3));
mesh.computeVertexNormals();
return mesh;
}, [stageId]);
useEffect(() => () => geometry.dispose(), [geometry]);
return <mesh geometry={geometry} position={[0, -.12, 0]} receiveShadow>
<meshStandardMaterial color="#465142" side={DoubleSide} roughness={.92} />
</mesh>;
}
+59
View File
@@ -0,0 +1,59 @@
import { useMemo, useRef, type RefObject } from "react";
import { useFrame } from "@react-three/fiber";
import { Group, Raycaster, Vector3 } from "three";
import { arenaCameraClearDistance, arenaCameraPose, arenaSpawnYaw, ARENA_CAMERA_DISTANCE } from "../game/arenaCamera";
import { cameraRelativeMovement, cameraYawAfterHorizontalLook, clampCameraPitch, DEFAULT_CAMERA_PITCH } from "../game/inputMath";
import { consumeMouseLook, consumeJumpRequest, isMouseLookDragActive, readInputSnapshot } from "../game/inputManager";
import { useArenaStore } from "../game/arenaStore";
import type { ArenaActorSnapshot, ArenaStageManifest } from "../game/arenaTypes";
export function ArenaPlayerCamera({ actor, stage, player, environment, movement, blocked }: {
actor: ArenaActorSnapshot | null;
stage: ArenaStageManifest;
player: RefObject<Group | null>;
environment: RefObject<Group | null>;
movement: RefObject<[number, number]>;
blocked: boolean;
}) {
const orbit = useRef({ yaw: arenaSpawnYaw(stage, actor?.team ?? 0), pitch: DEFAULT_CAMERA_PITCH });
const target = useMemo(() => new Vector3(), []);
const desired = useMemo(() => new Vector3(), []);
const ray = useMemo(() => new Raycaster(), []);
const distance = useRef(ARENA_CAMERA_DISTANCE);
const sent = useRef({ x: NaN, z: NaN, time: 0 });
useFrame(({ camera, clock }, rawDelta) => {
if (!actor) return;
const delta = Math.min(0.05, rawDelta);
const input = readInputSnapshot();
const [mouseX, mouseY] = consumeMouseLook();
if (consumeJumpRequest() !== null && !blocked && actor.alive) useArenaStore.getState().intent({ type: "jump" });
if (!blocked) {
const dragging = isMouseLookDragActive();
orbit.current.yaw = cameraYawAfterHorizontalLook(orbit.current.yaw, input.lookX * delta * 2.25 + (dragging ? mouseX * 0.0021 : 0));
orbit.current.pitch = clampCameraPitch(orbit.current.pitch + input.lookY * delta * 1.55 + (dragging ? mouseY * 0.0017 : 0));
}
const [x, z] = blocked || !actor.alive ? [0, 0] : cameraRelativeMovement(input.moveX, input.moveForward, orbit.current.yaw);
movement.current = [x, z];
// Publish immediately on input changes, and refresh held input through preparation/reconnect.
if (Math.abs(x - sent.current.x) > 0.002 || Math.abs(z - sent.current.z) > 0.002 || clock.elapsedTime - sent.current.time > 0.1 || !Number.isFinite(sent.current.x)) {
useArenaStore.getState().intent({ type: "movement", movement: { x, z } });
if (isMouseLookDragActive() || Math.abs(input.lookX) > .01) useArenaStore.getState().intent({ type: "facing", facing: orbit.current.yaw });
sent.current = { x, z, time: clock.elapsedTime };
}
const foot = player.current?.position.toArray() ?? actor.position;
const pose = arenaCameraPose(foot as [number, number, number], orbit.current.yaw, orbit.current.pitch);
target.set(...pose.target);
desired.set(...pose.position);
const clear = environment.current ? arenaCameraClearDistance(target, desired, environment.current, ray) : ARENA_CAMERA_DISTANCE;
distance.current = clear < distance.current ? clear : Math.min(clear, distance.current + delta * 8);
// A tight starting room can require a first-person distance. Keep the
// character's own head/shoulders out of that view, then restore the body
// automatically as the camera backs out into the arena.
if (player.current) player.current.visible = actor.alive && distance.current > 0.9;
desired.sub(target).normalize().multiplyScalar(distance.current).add(target);
// Never interpolate from a spectator/world-origin anchor through the arena walls.
camera.position.copy(desired);
camera.lookAt(target);
});
return null;
}
+8
View File
@@ -340,6 +340,14 @@ export function CombatEffects({ active, safeGraphics = false }: CombatEffectsPro
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
fallback={(
<CombatEffectPrimitive
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
)}
/>
) : (
<CombatEffectPrimitive
+12 -16
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
@@ -35,6 +35,7 @@ import {
originalCombatAnchorActorId,
originalAuraMatchesPresentationEvent,
originalEmitterParticleBudget,
selectCombatEffectRender,
parseOriginalCombatEffectGraph,
type OriginalCombatEffectAnimatedTrackName,
type OriginalCombatEffectGraph,
@@ -730,15 +731,12 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
quality,
active,
onDone,
}: OriginalCombatEffectProps) {
fallback,
}: OriginalCombatEffectProps & { readonly fallback: ReactNode }) {
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
const onDoneRef = useRef(onDone);
const terminalRef = useRef(false);
onDoneRef.current = onDone;
useEffect(() => {
let cancelled = false;
terminalRef.current = false;
setLoaded(null);
void preloadCombatEffectManifest().then(async () => {
const assets = combatOriginalPhaseAssets(event);
@@ -756,21 +754,19 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
failures,
);
}
if (ready.length) {
setLoaded(ready);
return;
}
if (!terminalRef.current) {
terminalRef.current = true;
onDoneRef.current();
}
// Keep any usable original layers; only substitute when none can render.
setLoaded(ready);
}).catch(() => {
if (!cancelled) setLoaded([]);
});
return () => {
cancelled = true;
};
}, [event]);
return loaded ? (
if (loaded === null) return null;
const selection = selectCombatEffectRender("original", loaded.map((item) => item.asset));
return selection.kind === "original" ? (
<LoadedOriginalCombatEffect
event={event}
quality={quality}
@@ -778,7 +774,7 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
loaded={loaded}
onDone={onDone}
/>
) : null;
) : fallback;
}, (previous, next) => (
previous.event === next.event
&& previous.quality === next.quality
@@ -293,7 +293,7 @@ describe("original combat-effect graph contract", () => {
expect(originalEmitterParticleBudget(inactive, "high")).toBe(0);
});
it("never substitutes generated rendering when Original has no graph asset", () => {
it("prefers original rendering and falls back to generated when no original graph is available", () => {
expect(selectCombatEffectRender("generated", undefined)).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [
{},
@@ -302,7 +302,8 @@ describe("original combat-effect graph contract", () => {
kind: "original",
effectGraphUrl: "/assets/game/combat-effects/original/wow335/priest/graphs/smite.json",
});
expect(selectCombatEffectRender("original", undefined)).toBeNull();
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toBeNull();
expect(selectCombatEffectRender("original", undefined)).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [])).toEqual({ kind: "generated" });
expect(selectCombatEffectRender("original", [{ effectGraphUrl: " " }])).toEqual({ kind: "generated" });
});
});
+2 -2
View File
@@ -682,12 +682,12 @@ export type OriginalCombatEffectRenderSelection =
export function selectCombatEffectRender(
mode: CombatVfxMode,
phaseAssets: readonly OriginalCombatEffectPhaseAsset[] | null | undefined,
): OriginalCombatEffectRenderSelection | null {
): OriginalCombatEffectRenderSelection {
if (mode === "generated") return { kind: "generated" };
const authentic = phaseAssets?.find((asset) => (
typeof asset.effectGraphUrl === "string" && asset.effectGraphUrl.trim().length > 0
));
return authentic?.effectGraphUrl
? { kind: "original", effectGraphUrl: authentic.effectGraphUrl }
: null;
: { kind: "generated" };
}