331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|