Release Healer Man 0.1.29
This commit is contained in:
1 parent
8c9809d3e0
commit
544c1bee91
120 files changed
+4363
-1036
No files matched your search
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in new issue
Block a user