Release Healer Man 0.1.30

This commit is contained in:
phenom committed 2026-09-11 18:49:33 -04:00
1 parent 544c1bee91
commit 7d7fd567f0
845 files changed
+2040 -87

No files matched your search

+187 -46
View File
@@ -7,6 +7,7 @@ import {
DoubleSide,
DynamicDrawUsage,
Group,
Matrix4,
MeshBasicMaterial,
MultiplyBlending,
NormalBlending,
@@ -22,6 +23,7 @@ import {
preloadCombatEffectManifest,
type CombatEffectPhaseAsset,
type CombatPresentationEvent,
type CombatPresentationPhase,
type CombatVfxQuality,
} from "../game/combatPresentation";
import { fetchCombatMediaAsset } from "../game/combatMediaPack";
@@ -48,17 +50,30 @@ import {
type OriginalCombatMeshGraph,
} from "./originalCombatMeshRuntime";
import { updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import { OriginalWowModel } from "./OriginalWowModel";
import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
import {
parseOriginalWowModelGraph, updateWowBoneMatrices, wowModelFrame, wowModelDurationMs, wowModelTextureUrls, wowParticleChild,
type OriginalWowModelGraph,
} from "./originalWowModelRuntime";
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph;
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph | OriginalWowModelGraph;
function sourceParticles(graph: OriginalSourceEffectGraph): OriginalCombatEffectGraph | null {
return graph.kind === "wow-m2-model" ? graph.particleGraph
: graph.kind === "wow-m2-particle-effect" ? graph : null;
}
interface LoadedOriginalGraph {
readonly asset: CombatEffectPhaseAsset;
readonly graph: OriginalSourceEffectGraph;
readonly textures: ReadonlyMap<number, Texture>;
readonly sourceTextures: ReadonlyMap<string, Texture>;
}
interface OriginalCombatEffectProps {
readonly event: CombatPresentationEvent;
readonly alternateOriginalPhase?: CombatPresentationPhase;
readonly quality: Exclude<CombatVfxQuality, "off">;
readonly active: boolean;
readonly onDone: () => void;
@@ -210,7 +225,8 @@ async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
? response.json()
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
.then((value) => (
value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
value?.kind === "wow-m2-model" ? parseOriginalWowModelGraph(value)
: value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
? parseOriginalCombatMeshGraph(value)
: parseOriginalCombatEffectGraph(value)
));
@@ -243,7 +259,13 @@ async function loadTexture(url: string): Promise<Texture> {
async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<LoadedOriginalGraph> {
if (!asset.effectGraphUrl) throw new Error("Original effect phase has no graph URL.");
const graph = await loadGraph(asset.effectGraphUrl);
const textureIndices = graph.kind === "wow-m2-static-mesh"
const sourceTextures = new Map<string, Texture>();
if (graph.kind === "wow-m2-model") {
await Promise.all(wowModelTextureUrls(graph).map(async (url) => sourceTextures.set(url, await loadTexture(url))));
}
const textureIndices = graph.kind === "wow-m2-model"
? graph.textures.flatMap((texture, index) => texture.url ? [index] : [])
: graph.kind === "wow-m2-static-mesh"
? [...new Set(graph.layers.map((layer) => layer.textureIndex))]
: [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => (
graph.particles[particleIndex]?.textureIndex
@@ -253,7 +275,7 @@ async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<Lo
if (!url) throw new Error(`Original effect texture ${textureIndex} has no packaged URL.`);
return [textureIndex, await loadTexture(url)] as const;
}));
return { asset, graph, textures: new Map(loadedTextures) };
return { asset, graph, textures: new Map(loadedTextures), sourceTextures };
}
function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial {
@@ -278,7 +300,7 @@ function materialForParticle(particle: OriginalCombatEffectParticle, texture: Te
});
}
const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
graph,
particle,
texture,
@@ -287,6 +309,9 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
eventId,
emitterIndex,
loopTimeline,
modelGraph,
sourceTextures,
timelineRef,
}: {
readonly graph: OriginalCombatEffectGraph;
readonly particle: OriginalCombatEffectParticle;
@@ -296,8 +321,17 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
readonly eventId: number;
readonly emitterIndex: number;
readonly loopTimeline: boolean;
readonly modelGraph?: OriginalWowModelGraph;
readonly sourceTextures?: ReadonlyMap<string, Texture>;
readonly timelineRef?: MutableRefObject<number>;
}) {
const elapsedRef = useRef(0);
const sourcePose = useMemo(() => ({
bones: modelGraph?.bones.map(() => new Matrix4()) ?? [],
births: new Float64Array(budget).fill(Number.NaN),
birthMatrices: Array.from({ length: modelGraph ? budget : 0 }, () => new Matrix4()),
position: new Vector3(), velocity: new Vector3(), origin: new Vector3(),
}), [modelGraph, budget]);
const geometry = useMemo(() => {
const next = new BufferGeometry();
const position = new BufferAttribute(new Float32Array(budget * 3), 3);
@@ -315,6 +349,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
next.setAttribute("particleAlpha", alpha);
next.setAttribute("particleSize", size);
next.setAttribute("particleFrame", frame);
next.setAttribute("particleAge", new BufferAttribute(new Float32Array(budget), 1));
next.setDrawRange(0, budget);
return next;
}, [budget]);
@@ -330,7 +365,8 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
useFrame((state, delta) => {
if (!active || budget === 0) return;
elapsedRef.current += Math.min(0.1, Math.max(0, delta));
if (timelineRef) elapsedRef.current = timelineRef.current;
else elapsedRef.current += Math.min(0.1, Math.max(0, delta));
material.uniforms.viewportHeight.value = state.size.height * state.viewport.dpr;
const elapsed = elapsedRef.current;
const positions = geometry.getAttribute("position") as BufferAttribute;
@@ -338,6 +374,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
const alphas = geometry.getAttribute("particleAlpha") as BufferAttribute;
const sizes = geometry.getAttribute("particleSize") as BufferAttribute;
const frames = geometry.getAttribute("particleFrame") as BufferAttribute;
const ages = geometry.getAttribute("particleAge") as BufferAttribute;
const cycleLength = Math.max(0.02, peakLifespan);
const emitterSeed = eventId * 1_009
+ particle.id * 17
@@ -349,6 +386,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
const completedCycles = Math.floor((elapsed - slotOffset) / cycleLength);
const birthTime = slotOffset + Math.max(0, completedCycles) * cycleLength;
const age = elapsed - birthTime;
ages.setX(index, Math.max(0, age));
const cycleSeed = emitterSeed + index * 97 + Math.max(0, completedCycles) * 271;
const rawTimelineMs = birthTime * 1_000;
const timelineMs = loopTimeline ? rawTimelineMs % durationMs : rawTimelineMs;
@@ -370,7 +408,7 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
const accepted = elapsed >= slotOffset
&& age >= 0
&& age < lifespan
&& scalarAt("enabled") > 0.5
&& (particle.samples.enabled.length === 0 || scalarAt("enabled") > 0.5)
&& sourceRandom(cycleSeed + 2) <= Math.min(1, emissionRate / Math.max(peakRate, 0.001));
if (!accepted) {
alphas.setX(index, 0);
@@ -408,12 +446,22 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
localZ += sphereZ * Math.max(areaWidth, areaLength);
}
// WoW M2 is Z-up; the game scene is Y-up.
positions.setXYZ(
index,
particle.position[0] + localX + velocityX * age,
particle.position[2] + localZ + velocityZ * age - gravity * age * age * 0.5,
-particle.position[1] - localY - velocityY * age,
);
sourcePose.position.set(particle.position[0] + localX, particle.position[1] + localY, particle.position[2] + localZ);
sourcePose.velocity.set(velocityX * age, velocityY * age, velocityZ * age);
if (modelGraph && sourcePose.bones[particle.parentBone]) {
if (sourcePose.births[index] !== rawTimelineMs) {
updateWowBoneMatrices(modelGraph, wowModelFrame(modelGraph, rawTimelineMs, loopTimeline), sourcePose.bones);
sourcePose.birthMatrices[index].copy(sourcePose.bones[particle.parentBone]);
sourcePose.births[index] = rawTimelineMs;
}
const bone = sourcePose.birthMatrices[index];
sourcePose.position.applyMatrix4(bone);
sourcePose.origin.set(0, 0, 0).applyMatrix4(bone);
sourcePose.velocity.applyMatrix4(bone).sub(sourcePose.origin);
}
sourcePose.position.add(sourcePose.velocity);
positions.setXYZ(index, sourcePose.position.x,
sourcePose.position.z - gravity * age * age * 0.5, -sourcePose.position.y);
const color = interpolateOriginalLifetime(particle.samples.color, lifeProgress) ?? [255, 255, 255];
const alpha = interpolateOriginalLifetime(particle.samples.alpha, lifeProgress) ?? 0;
const scale = interpolateOriginalLifetime(particle.samples.scale, lifeProgress) ?? [0, 0];
@@ -435,11 +483,52 @@ const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
frames.needsUpdate = true;
});
const geometryChild = wowParticleChild(modelGraph, particle.geometryModel);
const recursiveChild = wowParticleChild(modelGraph, particle.recursionModel);
return budget > 0 ? (
<points geometry={geometry} material={material} frustumCulled={false} />
<group>
{geometryChild && sourceTextures ? <OriginalParticleGeometry graph={geometryChild} source={geometry}
textures={sourceTextures} budget={budget} active={active} /> : !recursiveChild
? <points geometry={geometry} material={material} frustumCulled={false} /> : null}
{recursiveChild && sourceTextures && <OriginalRecursiveParticles graph={recursiveChild} source={geometry}
textures={sourceTextures} budget={budget} active={active} eventId={eventId} />}
</group>
) : null;
});
const OriginalRecursiveParticles = memo(function OriginalRecursiveParticles({ graph, source, textures, budget, active, eventId }: {
readonly graph: OriginalWowModelGraph; readonly source: BufferGeometry; readonly textures: ReadonlyMap<string, Texture>;
readonly budget: number; readonly active: boolean; readonly eventId: number;
}) {
// Recursive effects share the parent's particle pool. Bound the secondary
// emitters so large dispels cannot multiply the entire effect budget.
const slots = useMemo(() => Array.from({ length: Math.min(8, budget) }, () => ({
group: new Group(), age: { current: 0 },
})), [budget]);
useFrame(() => {
if (!active) return;
const positions = source.getAttribute("position"), alpha = source.getAttribute("particleAlpha");
const sizes = source.getAttribute("particleSize"), ages = source.getAttribute("particleAge");
slots.forEach((slot, index) => {
slot.group.position.fromBufferAttribute(positions, index);
slot.group.visible = alpha.getX(index) > 0;
slot.group.scale.setScalar(sizes.getX(index)); slot.age.current = ages.getX(index);
});
});
const particles = graph.particleGraph;
return <group name="original-wow-recursive-particles">{slots.map((slot, slotIndex) =>
<primitive key={slotIndex} object={slot.group}>
{particles?.runtime.readyParticleIndices.map((index) => {
const particle = particles.particles[index];
const texture = textures.get(graph.textures[particle.textureIndex].url!);
return texture && <OriginalParticleEmitter key={index} graph={particles} modelGraph={graph} particle={particle}
texture={texture} budget={Math.min(16, originalEmitterParticleBudget(particle, "high"))} active={active}
eventId={eventId} emitterIndex={slotIndex * 32 + index} loopTimeline timelineRef={slot.age} sourceTextures={textures} />;
})}
</primitive>,
)}</group>;
});
function meshBlendMode(blendMode: number) {
if (blendMode === 3 || blendMode === 4 || blendMode === 7) return AdditiveBlending;
if (blendMode === 5 || blendMode === 6) return MultiplyBlending;
@@ -569,8 +658,8 @@ function LoadedOriginalCombatEffect({
const staticMeshPlaybackRef = useRef({ elapsedMs: 0, decaying: false });
const particleBudgets = useMemo(() => {
const emitters = loaded.flatMap((item, loadedIndex) => {
const graph = item.graph;
return graph.kind === "wow-m2-static-mesh"
const graph = sourceParticles(item.graph);
return !graph
? []
: graph.runtime.readyParticleIndices.map((particleIndex) => ({
key: `${loadedIndex}:${particleIndex}`,
@@ -595,7 +684,9 @@ function LoadedOriginalCombatEffect({
const longestGraph = loaded.reduce((maximum, item) => (
Math.max(
maximum,
item.graph.kind === "wow-m2-static-mesh"
item.graph.kind === "wow-m2-model" ? wowModelDurationMs(item.graph)
+ (item.graph.particleGraph ? graphTailMs(item.graph.particleGraph) : 0)
: item.graph.kind === "wow-m2-static-mesh"
? 6_000
: graphDurationMs(item.graph) + graphTailMs(item.graph),
)
@@ -604,7 +695,7 @@ function LoadedOriginalCombatEffect({
return Math.max(120, Math.min(6_000, longestGraph));
}, [event.durationMs, event.phase, loaded]);
const staticMeshDecayDurationMs = useMemo(() => loaded.reduce((maximum, item) => (
item.graph.kind === "wow-m2-static-mesh"
item.graph.kind === "wow-m2-static-mesh" || item.graph.kind === "wow-m2-model"
? Math.max(
maximum,
item.graph.animations.find((animation) => animation.id === 159)?.durationMs ?? 0,
@@ -695,6 +786,8 @@ function LoadedOriginalCombatEffect({
position={[0, effectPlacementHeight(item.asset, graph, event), 0]}
scale={scale}
>
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} />}
{graph.kind === "wow-m2-static-mesh" ? (
<OriginalStaticMesh
graph={graph}
@@ -702,19 +795,22 @@ function LoadedOriginalCombatEffect({
active={active}
playbackRef={staticMeshPlaybackRef}
/>
) : graph.runtime.readyParticleIndices.map((particleIndex) => {
const particle = graph.particles[particleIndex];
) : sourceParticles(graph)?.runtime.readyParticleIndices.map((particleIndex) => {
const particles = sourceParticles(graph)!;
const particle = particles.particles[particleIndex];
const texture = item.textures.get(particle?.textureIndex ?? -1);
return particle && texture ? (
<OriginalParticleEmitter
key={`${particleIndex}:${particle.id}:${particle.textureIndex}`}
graph={graph}
particle={particle}
texture={texture}
budget={particleBudgets.get(`${loadedIndex}:${particleIndex}`) ?? 0}
active={active}
eventId={event.id}
emitterIndex={loadedIndex * 4_096 + particleIndex}
graph={particles}
modelGraph={graph.kind === "wow-m2-model" ? graph : undefined}
sourceTextures={item.sourceTextures}
particle={particle}
texture={texture}
budget={particleBudgets.get(`${loadedIndex}:${particleIndex}`) ?? 0}
active={active}
eventId={event.id}
emitterIndex={loadedIndex * 4_096 + particleIndex}
loopTimeline={loopTimeline}
/>
) : null;
@@ -728,47 +824,91 @@ function LoadedOriginalCombatEffect({
export const OriginalCombatEffect = memo(function OriginalCombatEffect({
event,
alternateOriginalPhase,
quality,
active,
onDone,
fallback,
}: OriginalCombatEffectProps & { readonly fallback: ReactNode }) {
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
const [renderEvent, setRenderEvent] = useState(event);
const completedMissingOriginalRef = useRef(false);
useEffect(() => {
let cancelled = false;
setLoaded(null);
setRenderEvent(event);
completedMissingOriginalRef.current = false;
void preloadCombatEffectManifest().then(async () => {
const assets = combatOriginalPhaseAssets(event);
const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset));
const loadEvent = async (candidate: CombatPresentationEvent) => {
const assets = combatOriginalPhaseAssets(candidate);
const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset));
return {
event: candidate,
assets,
settled,
ready: settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []),
};
};
const primary = await loadEvent(event);
const attempts = [primary];
let selected = primary;
if (!cancelled && selected.ready.length === 0 && alternateOriginalPhase) {
const alternate = await loadEvent({
...event,
id: -event.id,
phase: alternateOriginalPhase,
});
attempts.push(alternate);
if (alternate.ready.length > 0) selected = alternate;
}
if (cancelled) return;
const ready = settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
if (import.meta.env.DEV && assets.length > ready.length) {
const failures = settled.flatMap((result) => (
result.status === "rejected"
? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
: []
));
console.warn(
`Original spell effect failed to load for ${event.name} (${event.phase}).`,
failures,
);
if (import.meta.env.DEV) {
for (const attempt of attempts) {
if (attempt.assets.length <= attempt.ready.length) continue;
const failures = attempt.settled.flatMap((result) => (
result.status === "rejected"
? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
: []
));
console.warn(
`Original spell effect failed to load for ${attempt.event.name} (${attempt.event.phase}).`,
failures,
);
}
}
// Keep any usable original layers; only substitute when none can render.
setLoaded(ready);
setRenderEvent(selected.event);
setLoaded(selected.ready);
}).catch(() => {
if (!cancelled) setLoaded([]);
if (!cancelled) {
setRenderEvent(event);
setLoaded([]);
}
});
return () => {
cancelled = true;
};
}, [event]);
}, [alternateOriginalPhase, event]);
if (loaded === null) return null;
const selection = selectCombatEffectRender("original", loaded.map((item) => item.asset));
const selection = loaded === null
? null
: selectCombatEffectRender("original", loaded.map((item) => item.asset));
useEffect(() => {
if (
loaded === null
|| selection?.kind === "original"
|| fallback !== null
|| completedMissingOriginalRef.current
) return;
completedMissingOriginalRef.current = true;
onDone();
}, [fallback, loaded, onDone, selection?.kind]);
if (loaded === null || selection === null) return null;
return selection.kind === "original" ? (
<LoadedOriginalCombatEffect
event={event}
event={renderEvent}
quality={quality}
active={active}
loaded={loaded}
@@ -777,6 +917,7 @@ export const OriginalCombatEffect = memo(function OriginalCombatEffect({
) : fallback;
}, (previous, next) => (
previous.event === next.event
&& previous.alternateOriginalPhase === next.alternateOriginalPhase
&& previous.quality === next.quality
&& previous.active === next.active
));