Release Healer Man 0.1.4

This commit is contained in:
phenom
2026-08-18 12:06:04 -04:00
parent 5bb170039a
commit f4c9cf356c
63 changed files with 2694 additions and 81 deletions
+330
View File
@@ -0,0 +1,330 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
Color,
DoubleSide,
Group,
MeshBasicMaterial,
PointsMaterial,
Vector3,
} from "three";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import {
combatEffectStyle,
preloadCombatEffectManifest,
subscribeCombatPresentation,
type CombatPresentationEvent,
type CombatVfxQuality,
} from "../game/combatPresentation";
import {
configureCombatAudio,
playCombatAudio,
resetCombatAudio,
setCombatAudioListener,
stopCombatAudioGroup,
} from "../game/combatAudio";
interface ActiveCombatEffect {
readonly key: string;
readonly event: CombatPresentationEvent;
}
export interface CombatEffectsProps {
readonly active: boolean;
readonly safeGraphics?: boolean;
}
const QUALITY_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, number>> = {
off: 0,
low: 24,
high: 64,
};
const QUALITY_PARTICLE_COUNT: Readonly<Record<Exclude<CombatVfxQuality, "off">, number>> = {
low: 48,
high: 128,
};
function effectDurationMs(event: CombatPresentationEvent): number {
if (event.phase === "cast-start") return Math.max(280, Math.min(4_500, event.durationMs ?? 900));
if (event.phase === "tick") return 320;
if (event.phase === "impact") return 420;
if (event.delivery === "melee") return 280;
if (event.delivery === "area") return 850;
if (event.delivery === "aura") return 950;
if (event.delivery === "channel") return 620;
if (event.delivery === "projectile" && event.targetPosition) {
const dx = event.targetPosition[0] - event.origin[0];
const dy = event.targetPosition[1] - event.origin[1];
const dz = event.targetPosition[2] - event.origin[2];
return Math.max(180, Math.min(550, Math.hypot(dx, dy, dz) / 28 * 1_000));
}
return 460;
}
function deterministicParticles(seed: number, count: number): Float32Array {
const result = new Float32Array(count * 3);
for (let index = 0; index < count; index += 1) {
const angle = index * 2.399963229728653 + seed * 0.17;
const normalized = (index + 0.5) / count;
const radius = 0.12 + Math.sqrt(normalized) * 0.72;
result[index * 3] = Math.cos(angle) * radius;
result[index * 3 + 1] = (normalized - 0.45) * 1.4 + Math.sin(seed + index * 1.73) * 0.16;
result[index * 3 + 2] = Math.sin(angle) * radius;
}
return result;
}
function CombatEffectPrimitive({
event,
quality,
active,
onDone,
}: {
readonly event: CombatPresentationEvent;
readonly quality: Exclude<CombatVfxQuality, "off">;
readonly active: boolean;
readonly onDone: () => void;
}) {
const groupRef = useRef<Group>(null);
const pointsMaterialRef = useRef<PointsMaterial>(null);
const primaryMaterialRef = useRef<MeshBasicMaterial>(null);
const secondaryMaterialRef = useRef<MeshBasicMaterial>(null);
const elapsedRef = useRef(0);
const doneRef = useRef(false);
const style = useMemo(() => combatEffectStyle(event), [event]);
const particleCount = QUALITY_PARTICLE_COUNT[quality];
const positions = useMemo(
() => deterministicParticles(event.id, particleCount),
[event.id, particleCount],
);
const origin = useMemo(() => new Vector3(...event.origin), [event.origin]);
const target = useMemo(
() => new Vector3(...(event.targetPosition ?? event.origin)),
[event.origin, event.targetPosition],
);
const durationMs = effectDurationMs(event);
const projectile = event.phase === "release"
&& event.delivery === "projectile"
&& event.targetPosition !== undefined;
const effectPosition = projectile
? origin
: event.phase === "cast-start"
? origin
: target;
const isGround = event.delivery === "area";
const initialY = effectPosition.y + (isGround ? 0.08 : event.delivery === "melee" ? 1 : 1.15);
useFrame((_state, delta) => {
if (!active || doneRef.current || !groupRef.current) return;
elapsedRef.current += Math.min(0.05, delta) * 1_000;
const progress = Math.min(1, elapsedRef.current / durationMs);
const eased = 1 - (1 - progress) ** 3;
if (projectile) {
groupRef.current.position.lerpVectors(origin, target, eased);
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
} else {
groupRef.current.rotation.y += delta * (event.school === "physical" ? 4 : 1.8);
const scale = event.phase === "cast-start"
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
groupRef.current.scale.setScalar(scale);
if (!isGround && event.delivery !== "melee") groupRef.current.position.y = initialY + progress * 0.55;
}
const opacity = event.phase === "cast-start"
? Math.min(1, (1 - progress) * 1.8)
: Math.max(0, 1 - progress);
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
if (progress >= 1) {
doneRef.current = true;
onDone();
}
});
return (
<group
ref={groupRef}
position={[effectPosition.x, initialY, effectPosition.z]}
rotation={isGround ? [-Math.PI / 2, 0, 0] : [0, 0, event.delivery === "melee" ? -0.55 : 0]}
>
<points>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial
ref={pointsMaterialRef}
color={style.primary}
size={quality === "high" ? 0.15 : 0.19}
sizeAttenuation
transparent
opacity={0.9}
depthWrite={false}
blending={AdditiveBlending}
/>
</points>
{event.delivery === "melee" ? (
<mesh rotation={[0, 0.2, Math.PI / 2]}>
<torusGeometry args={[0.7, 0.075, 8, 32, Math.PI * 1.35]} />
<meshBasicMaterial
ref={primaryMaterialRef}
color={style.primary}
transparent
opacity={0.75}
depthWrite={false}
side={DoubleSide}
blending={AdditiveBlending}
/>
</mesh>
) : event.delivery === "area" ? (
<>
<mesh>
<ringGeometry args={[0.72, 1, 48]} />
<meshBasicMaterial
ref={primaryMaterialRef}
color={style.primary}
transparent
opacity={0.62}
depthWrite={false}
side={DoubleSide}
blending={AdditiveBlending}
/>
</mesh>
<mesh scale={0.68}>
<ringGeometry args={[0.82, 1, 32]} />
<meshBasicMaterial
ref={secondaryMaterialRef}
color={style.secondary}
transparent
opacity={0.5}
depthWrite={false}
side={DoubleSide}
blending={AdditiveBlending}
/>
</mesh>
</>
) : (
<>
<mesh>
<sphereGeometry args={[projectile ? 0.28 : 0.44, 12, 8]} />
<meshBasicMaterial
ref={primaryMaterialRef}
color={style.primary}
transparent
opacity={0.7}
depthWrite={false}
blending={AdditiveBlending}
/>
</mesh>
<mesh scale={projectile ? 0.52 : 1.18}>
<sphereGeometry args={[0.32, 10, 7]} />
<meshBasicMaterial
ref={secondaryMaterialRef}
color={style.secondary}
transparent
opacity={0.45}
wireframe={!projectile}
depthWrite={false}
blending={AdditiveBlending}
/>
</mesh>
</>
)}
{quality === "high" && event.school !== "physical" ? (
<pointLight color={new Color(style.primary)} intensity={style.emissive * 1.5} distance={5} decay={2} />
) : null}
</group>
);
}
export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) {
const settings = useShellStore((state) => state.combatPresentationSettings);
const masterVolume = useCombatStore((state) => state.settings.masterVolume);
const [effects, setEffects] = useState<readonly ActiveCombatEffect[]>([]);
const settingsRef = useRef(settings);
const activeRef = useRef(active);
const effectiveQuality: CombatVfxQuality = safeGraphics && settings.vfxQuality === "high"
? "low"
: settings.vfxQuality;
const qualityRef = useRef(effectiveQuality);
const camera = useThree((state) => state.camera);
const forward = useMemo(() => new Vector3(), []);
useEffect(() => {
settingsRef.current = settings;
qualityRef.current = safeGraphics && settings.vfxQuality === "high" ? "low" : settings.vfxQuality;
configureCombatAudio(settings, masterVolume);
if (
qualityRef.current !== "off"
|| Object.values(settings.audioEnabled).some(Boolean)
) void preloadCombatEffectManifest();
setEffects((current) => current.filter(({ event }) => (
qualityRef.current !== "off" && settings.vfxEnabled[event.actorGroup]
)));
for (const group of ["player", "party", "enemy"] as const) {
if (!settings.audioEnabled[group] || settings.audioVolume[group] <= 0) stopCombatAudioGroup(group);
}
}, [masterVolume, safeGraphics, settings]);
useEffect(() => {
activeRef.current = active;
}, [active]);
useEffect(() => subscribeCombatPresentation((message) => {
if (message.kind === "reset") {
setEffects([]);
resetCombatAudio();
return;
}
const event = message.event;
if (activeRef.current) playCombatAudio(event);
setEffects((current) => {
const withoutCast = event.phase === "release" || event.phase === "cast-cancel"
? current.filter((effect) => !(
effect.event.phase === "cast-start"
&& effect.event.sourceActorId === event.sourceActorId
&& effect.event.abilityId === event.abilityId
))
: current;
const presentationSettings = settingsRef.current;
const quality = qualityRef.current;
if (
event.phase === "cast-cancel"
|| !activeRef.current
|| quality === "off"
|| !presentationSettings.vfxEnabled[event.actorGroup]
) return withoutCast;
const limit = QUALITY_SYSTEM_LIMIT[quality];
return [...withoutCast, { key: `${event.sessionId}:${event.id}`, event }].slice(-limit);
});
}), []);
useEffect(() => () => resetCombatAudio(), []);
useFrame(() => {
camera.getWorldDirection(forward);
setCombatAudioListener(
[camera.position.x, camera.position.y, camera.position.z],
[forward.x, forward.y, forward.z],
);
});
if (effectiveQuality === "off") return null;
return (
<group name="combat-effects">
{effects.map((effect) => (
<CombatEffectPrimitive
key={effect.key}
event={effect.event}
quality={effectiveQuality}
active={active}
onDone={() => setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))}
/>
))}
</group>
);
}