Release Healer Man 0.1.4
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user