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> = { off: 0, low: 24, high: 64, }; const QUALITY_PARTICLE_COUNT: Readonly, 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; readonly active: boolean; readonly onDone: () => void; }) { const groupRef = useRef(null); const pointsMaterialRef = useRef(null); const primaryMaterialRef = useRef(null); const secondaryMaterialRef = useRef(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 ( {event.delivery === "melee" ? ( ) : event.delivery === "area" ? ( <> ) : ( <> )} {quality === "high" && event.school !== "physical" ? ( ) : null} ); } export function CombatEffects({ active, safeGraphics = false }: CombatEffectsProps) { const settings = useShellStore((state) => state.combatPresentationSettings); const masterVolume = useCombatStore((state) => state.settings.masterVolume); const [effects, setEffects] = useState([]); 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 ( {effects.map((effect) => ( setEffects((current) => current.filter((candidate) => candidate.key !== effect.key))} /> ))} ); }