Files
healer-man/src/avatar/CharacterModel.tsx
T
2026-10-04 13:13:35 -04:00

617 lines
22 KiB
TypeScript

import { useGLTF } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import {
Suspense,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type ReactNode,
} from "react";
import {
AdditiveBlending,
AnimationMixer,
Color,
DoubleSide,
Euler,
LoopOnce,
LoopRepeat,
NormalBlending,
Quaternion,
type AnimationAction,
type AnimationClip,
type Material,
type Mesh,
type Object3D,
type Skeleton,
type SkinnedMesh,
type Texture,
} from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { shareClonedSkeletons } from "./sharedSkeletons";
import type {
CharacterAppearance,
ClassId,
GenderId,
RaceId,
} from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import { resolveContentUrl } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import {
selectCharacterBaseClip,
selectCharacterEventClip,
selectCharacterVerticalClip,
type CharacterCombatAnimationEvent,
type CharacterBaseMotion,
} from "../game/combatAnimation";
import type { CharacterVerticalMotion } from "../game/playerJump";
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
import {
ATTACK_PRESENTATION_DURATION_SECONDS,
attackSwingAt,
} from "./attackPresentation";
import { resolveAvatarAppearance, isAvatarGeosetVisible } from "./appearanceResolver";
import { loadAvatarTextureSet, type AvatarTextureSet } from "./appearanceTextureCache";
import {
avatarManifestKey,
getAvatarEntry,
loadAvatarManifest,
type AvatarManifest,
type AvatarManifestEntry,
} from "./characterManifest";
import {
EquipmentAttachments,
equipmentAttachmentHand,
resolveEquipmentAttachmentTransforms,
} from "./equipmentAttachments";
import {
ApproximateStarterEquipment,
StarterWeaponAttachments,
characterWeaponStance,
estimateAvatarWeaponScale,
type AvatarHand,
} from "./starterWeapons";
export interface AvatarIdentity {
raceId: RaceId;
gender: GenderId;
classId: ClassId;
appearance: CharacterAppearance;
}
export interface CharacterModelProps {
identity: AvatarIdentity | null | undefined;
active?: boolean;
movingRef?: MutableRefObject<boolean>;
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
attackPulseRef?: MutableRefObject<number>;
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
onReady?: () => void;
preview?: boolean;
turnDegrees?: number;
equipment?: readonly InventoryItem[];
fallback: ReactNode;
}
const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]);
type CharacterLocomotionMotion = CharacterBaseMotion | Exclude<CharacterVerticalMotion, "grounded">;
type MappedMaterial = Material & { map?: Texture | null; color?: Color };
function configureMaterial(material: Material): void {
material.side = DoubleSide;
if (/_B1$/i.test(material.name)) {
material.transparent = false;
material.opacity = 1;
material.alphaTest = 0.5;
material.depthWrite = true;
material.blending = NormalBlending;
} else if (/_B4$/i.test(material.name)) {
material.transparent = true;
material.depthWrite = false;
material.blending = AdditiveBlending;
} else {
material.transparent = false;
material.opacity = 1;
material.alphaTest = 0;
material.depthWrite = true;
material.blending = NormalBlending;
}
material.needsUpdate = true;
}
function cloneAvatarScene(root: Object3D): Object3D {
const scene = cloneSkeleton(root);
shareClonedSkeletons(scene);
scene.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const sources = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const clones = sources.map((source) => {
const clone = source.clone() as MappedMaterial;
clone.userData.avatarDefaultMap = clone.map ?? null;
clone.userData.avatarDefaultColor = clone.color?.clone() ?? null;
configureMaterial(clone);
return clone;
});
mesh.material = Array.isArray(mesh.material) ? clones : clones[0];
mesh.castShadow = false;
mesh.receiveShadow = false;
});
return scene;
}
function useManifestEntry(identity: AvatarIdentity | null | undefined): {
manifest: AvatarManifest | null;
entry: AvatarManifestEntry | null;
} {
const contentRevision = useContentManagerSnapshot().installedRevision;
const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null);
const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : "";
const raceId = identity?.raceId;
const gender = identity?.gender;
useEffect(() => {
let cancelled = false;
if (!raceId || !gender) {
setState(null);
return () => { cancelled = true; };
}
setState(null);
void loadAvatarManifest().then((manifest) => {
const entry = getAvatarEntry(manifest, raceId, gender);
if (!cancelled && entry) setState({ manifest, entry });
}).catch((error) => {
if (!cancelled) console.warn("Playable-character manifest could not be loaded", error);
});
return () => { cancelled = true; };
}, [contentRevision, gender, key, raceId]);
return { manifest: state?.manifest ?? null, entry: state?.entry ?? null };
}
function LoadedCharacterModel({
active,
entry,
identity,
manifest,
movingRef,
verticalMotionRef,
attackPulseRef,
animationEventRef,
onReady,
preview,
turnDegrees,
equipment,
}: {
active: boolean;
entry: AvatarManifestEntry;
identity: AvatarIdentity;
manifest: AvatarManifest;
movingRef?: MutableRefObject<boolean>;
verticalMotionRef?: MutableRefObject<CharacterVerticalMotion>;
attackPulseRef?: MutableRefObject<number>;
animationEventRef?: MutableRefObject<CharacterCombatAnimationEvent | null>;
onReady?: () => void;
preview: boolean;
turnDegrees: number;
equipment: readonly InventoryItem[];
}) {
const gltf = useGLTF(resolveContentUrl(entry.model));
const scene = useMemo(() => cloneAvatarScene(gltf.scene), [gltf.scene]);
const equipmentAttachmentTransforms = useMemo(
() => resolveEquipmentAttachmentTransforms(scene, entry),
[entry, scene],
);
const fallbackEquipmentScale = useMemo(
() => estimateAvatarWeaponScale(scene),
[scene],
);
const mixer = useMemo(() => new AnimationMixer(scene), [scene]);
const currentAction = useRef<AnimationAction | null>(null);
const overrideAction = useRef<AnimationAction | null>(null);
const overrideTerminal = useRef(false);
const overrideLooping = useRef(false);
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
const attackElapsed = useRef(ATTACK_PRESENTATION_DURATION_SECONDS);
const attackJoint = useMemo(
() => scene.getObjectByName("bone_ShoulderR") ?? null,
[scene],
);
const attackEuler = useMemo(() => new Euler(), []);
const attackQuaternion = useMemo(() => new Quaternion(), []);
const resolved = useMemo(() => resolveAvatarAppearance(
manifest,
entry,
identity.appearance,
identity.classId,
equipment,
), [
entry,
identity.appearance.face,
identity.appearance.feature,
identity.appearance.hairColor,
identity.appearance.hairStyle,
identity.appearance.skinColor,
identity.classId,
equipment,
manifest,
]);
const [textures, setTextures] = useState<AvatarTextureSet | null>(null);
const occupiedEquipmentHands = useMemo(
() => [...new Set(resolved.equipmentModels.flatMap((itemModel) => {
const hand = equipmentAttachmentHand(itemModel.attachment);
return hand ? [hand] : [];
}))] as AvatarHand[],
[resolved.equipmentModels],
);
const weaponStance = useMemo(() => characterWeaponStance(
identity.classId,
equipment,
resolved.equipmentModels.flatMap((itemModel) => (
itemModel.attachment === "handLeft" || itemModel.attachment === "handRight"
? [itemModel.itemId]
: []
)),
), [equipment, identity.classId, resolved.equipmentModels]);
useEffect(() => {
let cancelled = false;
void loadAvatarTextureSet(manifest, entry, resolved).then((next) => {
if (!cancelled) setTextures(next);
}).catch((error) => {
if (!cancelled) console.warn("Selected avatar textures could not be composed", error);
});
return () => { cancelled = true; };
}, [entry, manifest, resolved]);
useEffect(() => () => {
const ownedMaterials = new Set<Material>();
const ownedSkeletons = new Set<Skeleton>();
scene.traverse((node) => {
const skinned = node as SkinnedMesh;
if (skinned.isSkinnedMesh) ownedSkeletons.add(skinned.skeleton);
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach((material) => ownedMaterials.add(material));
});
ownedMaterials.forEach((material) => material.dispose());
ownedSkeletons.forEach((skeleton) => skeleton.dispose());
}, [scene]);
useLayoutEffect(() => {
scene.traverse((node) => {
node.visible = isAvatarGeosetVisible(node.name, resolved);
});
}, [resolved, scene]);
useLayoutEffect(() => {
const strategy = entry.rendererStrategy;
if (strategy?.kind !== "runewaker-modular") return;
const skin = new Color(strategy.skinPalette[
identity.appearance.skinColor % strategy.skinPalette.length
] ?? "#ffffff");
skin.offsetHSL((identity.appearance.face - 3.5) * 0.004, 0, (identity.appearance.feature - 3.5) * 0.006);
const hair = new Color(strategy.hairPalette[
identity.appearance.hairColor % strategy.hairPalette.length
] ?? "#ffffff");
scene.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const lowerName = node.name.toLowerCase();
const materials = (Array.isArray(mesh.material) ? mesh.material : [mesh.material]) as MappedMaterial[];
for (const material of materials) {
const defaultColor = material.userData.avatarDefaultColor as Color | null;
if (!material.color || !defaultColor) continue;
material.color.copy(defaultColor);
if (lowerName.includes("_hair_")) material.color.multiply(hair);
else if (lowerName.includes("_head_")) material.color.multiply(skin);
material.needsUpdate = true;
}
if (lowerName.includes("_hair_")) {
const defaultScale = (node.userData.avatarDefaultScale as { x: number; y: number; z: number } | undefined)
?? node.scale.clone();
node.userData.avatarDefaultScale = defaultScale;
const styleScale = 0.94 + (identity.appearance.hairStyle % 8) * 0.017;
node.scale.set(
defaultScale.x * styleScale,
defaultScale.y * (0.97 + (identity.appearance.feature % 8) * 0.008),
defaultScale.z * styleScale,
);
}
});
}, [
entry.rendererStrategy,
identity.appearance.face,
identity.appearance.feature,
identity.appearance.hairColor,
identity.appearance.hairStyle,
identity.appearance.skinColor,
scene,
]);
useLayoutEffect(() => {
onReady?.();
}, [onReady, scene]);
useLayoutEffect(() => {
scene.traverse((node) => {
const mesh = node as Mesh;
if (!mesh.isMesh) return;
const materials = (Array.isArray(mesh.material) ? mesh.material : [mesh.material]) as MappedMaterial[];
for (const material of materials) {
const defaultMap = material.userData.avatarDefaultMap as Texture | null;
if (/^rig_Cloak[0-9]+$/.test(node.name) && textures?.cloak) {
material.map = textures.cloak;
material.needsUpdate = true;
} else if (entry.materialTokens.body && material.name.startsWith(entry.materialTokens.body)) {
material.map = textures?.body ?? defaultMap;
material.needsUpdate = true;
} else if (entry.materialTokens.hair && material.name.startsWith(entry.materialTokens.hair)) {
material.map = textures?.hair ?? defaultMap;
material.needsUpdate = true;
}
}
});
}, [entry.materialTokens.body, entry.materialTokens.hair, scene, textures]);
const namedClip = (name: string | undefined): AnimationClip | undefined => {
if (!name) return undefined;
const normalized = name.toLowerCase();
return gltf.animations.find((candidate) => candidate.name.toLowerCase() === normalized)
?? gltf.animations.find((candidate) => candidate.name.toLowerCase().includes(normalized));
};
// The GLTF clip library and weapon stance change far less often than frames.
// Resolve locomotion once so five party members do not repeatedly parse and
// sort the same hundreds of animation names while standing or running.
const baseClips = useMemo(() => Object.fromEntries(
(["idle", "walk", "run"] as const).map((motion) => [
motion,
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
?? namedClip(entry.animations[motion])
?? namedClip(entry.animations.idle)
?? gltf.animations[0],
]),
) as Record<CharacterBaseMotion, AnimationClip | undefined>, [gltf.animations, entry.animations, weaponStance]);
const verticalClips = useMemo(() => ({
rising: [selectCharacterVerticalClip(gltf.animations, "rising", false), selectCharacterVerticalClip(gltf.animations, "rising", true)],
falling: [selectCharacterVerticalClip(gltf.animations, "falling", false), selectCharacterVerticalClip(gltf.animations, "falling", true)],
landing: [selectCharacterVerticalClip(gltf.animations, "landing", false), selectCharacterVerticalClip(gltf.animations, "landing", true)],
}), [gltf.animations]);
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
if (overrideAction.current) return;
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
? verticalClips[motion][moving ? 1 : 0]
: null;
const fallbackMotion: CharacterBaseMotion = moving ? "run" : "idle";
const clip = verticalSelection?.clip ?? baseClips[
motion === "idle" || motion === "walk" || motion === "run" ? motion : fallbackMotion
];
if (!clip) return;
if (currentMotion.current === motion && currentAction.current?.getClip() === clip) return;
const loop = verticalSelection?.loop ?? true;
const next = mixer.clipAction(clip, scene);
next.enabled = true;
next.clampWhenFinished = !loop;
next.setLoop(loop ? LoopRepeat : LoopOnce, loop ? Number.POSITIVE_INFINITY : 1)
.reset()
.fadeIn(0.16)
.play();
if (currentAction.current !== next) currentAction.current?.fadeOut(0.16);
currentAction.current = next;
currentMotion.current = motion;
};
const clearOverride = () => {
const previous = overrideAction.current;
if (previous) previous.fadeOut(0.12);
overrideAction.current = null;
overrideTerminal.current = false;
overrideLooping.current = false;
currentMotion.current = null;
};
const playAnimationEvent = (event: CharacterCombatAnimationEvent) => {
if (event.kind === "ability-cancel") {
clearOverride();
return;
}
// Damage does not visually interrupt an active WoW casting/channel pose.
if (event.kind === "wound" && overrideLooping.current) return;
const selection = selectCharacterEventClip(gltf.animations, identity.classId, event);
if (!selection) {
const nativeName = event.kind === "attack"
? entry.animations.attack
: event.kind === "wound"
? entry.animations.wound
: event.kind === "death"
? entry.animations.death
: event.kind === "ability-start" || event.kind === "ability-release"
? entry.animations.cast
: undefined;
const nativeClip = namedClip(nativeName);
if (nativeClip) {
const looping = event.kind === "ability-start";
const terminal = event.kind === "death";
const next = mixer.clipAction(nativeClip, scene);
overrideAction.current?.fadeOut(0.1);
currentAction.current?.fadeOut(0.1);
next.enabled = true;
next.clampWhenFinished = terminal;
next.setLoop(looping ? LoopRepeat : LoopOnce, looping ? Number.POSITIVE_INFINITY : 1);
next.reset().fadeIn(0.1).play();
overrideAction.current = next;
overrideTerminal.current = terminal;
overrideLooping.current = looping;
return;
}
if (event.kind === "attack" || event.kind === "ability-release") {
attackElapsed.current = 0;
}
return;
}
const next = mixer.clipAction(selection.clip, scene);
overrideAction.current?.fadeOut(0.1);
currentAction.current?.fadeOut(0.1);
next.enabled = true;
next.clampWhenFinished = selection.terminal;
next.setLoop(selection.loop ? LoopRepeat : LoopOnce, selection.loop ? Number.POSITIVE_INFINITY : 1);
next.reset().fadeIn(0.1).play();
overrideAction.current = next;
overrideTerminal.current = selection.terminal;
overrideLooping.current = selection.loop;
};
useEffect(() => {
switchMotion("idle", false);
return () => {
mixer.stopAllAction();
mixer.uncacheRoot(scene);
currentAction.current = null;
overrideAction.current = null;
currentMotion.current = null;
};
// The mixer and scene are stable for the lifetime of a loaded race/gender rig.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mixer, scene]);
useEffect(() => {
const handleFinished = (event: { action: AnimationAction }) => {
if (event.action !== overrideAction.current || overrideTerminal.current) return;
clearOverride();
};
mixer.addEventListener("finished", handleFinished);
return () => mixer.removeEventListener("finished", handleFinished);
});
useFrame((_, delta) => {
if (!active) return;
const moving = !preview && Boolean(movingRef?.current);
const verticalMotion = preview ? "grounded" : verticalMotionRef?.current ?? "grounded";
const locomotionMotion: CharacterLocomotionMotion = verticalMotion === "grounded"
? moving ? "run" : "idle"
: verticalMotion;
const nextAnimationEvent = animationEventRef?.current;
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
observedAnimationRevision.current = nextAnimationEvent.revision;
playAnimationEvent(nextAnimationEvent);
}
const nextAttackPulse = attackPulseRef?.current ?? 0;
if (nextAttackPulse !== observedAttackPulse.current) {
observedAttackPulse.current = nextAttackPulse;
if (nextAttackPulse > 0) {
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
}
}
switchMotion(locomotionMotion, moving);
const safeDelta = Math.min(delta, 0.05);
mixer.update(safeDelta);
// Mixer "finished" events normally clear one-shot combat overrides. Also
// poll the action state so a missed event cannot strand a character in the
// final (often crouched) attack pose and suppress locomotion indefinitely.
if (
overrideAction.current
&& !overrideTerminal.current
&& !overrideLooping.current
&& !overrideAction.current.isRunning()
) clearOverride();
switchMotion(locomotionMotion, moving);
const swing = attackSwingAt(attackElapsed.current);
attackElapsed.current = Math.min(
ATTACK_PRESENTATION_DURATION_SECONDS,
attackElapsed.current + safeDelta,
);
if (!overrideAction.current && attackJoint && swing !== 0) {
// The mixer establishes the locomotion pose first. This additive arm
// rotation then creates one readable strike without rocking the pelvis.
attackEuler.set(swing * 0.16, Math.abs(swing) * 0.1, swing * 0.82);
attackQuaternion.setFromEuler(attackEuler);
attackJoint.quaternion.multiply(attackQuaternion);
}
});
return (
<>
<group rotation={[0, turnDegrees * Math.PI / 180, 0]}>
<primitive
object={scene}
position={[0, entry.groundOffset, 0]}
rotation={[0, entry.rotationY, 0]}
scale={entry.scale}
/>
</group>
<StarterWeaponAttachments
classId={identity.classId}
entry={entry}
occupiedHands={occupiedEquipmentHands}
root={scene}
/>
<EquipmentAttachments
entry={entry}
fallbackScale={fallbackEquipmentScale}
models={resolved.equipmentModels}
transforms={equipmentAttachmentTransforms}
onReady={preview ? onReady : undefined}
/>
</>
);
}
export function CharacterModel({
identity,
active = true,
movingRef,
verticalMotionRef,
attackPulseRef,
animationEventRef,
onReady,
preview = false,
turnDegrees = 0,
equipment = EMPTY_EQUIPMENT,
fallback,
}: CharacterModelProps) {
const { manifest, entry } = useManifestEntry(identity);
const rotatedFallback = (
<group rotation={[0, turnDegrees * Math.PI / 180, 0]}>
{fallback}
{identity ? (
<ApproximateStarterEquipment
attackPulseRef={attackPulseRef}
classId={identity.classId}
/>
) : null}
</group>
);
if (!identity || !manifest || !entry) return rotatedFallback;
const resetKey = `${entry.raceId}:${entry.gender}:${entry.model}`;
return (
<AvatarErrorBoundary fallback={rotatedFallback} resetKey={resetKey}>
<Suspense fallback={rotatedFallback}>
<LoadedCharacterModel
key={resetKey}
active={active}
entry={entry}
identity={identity}
manifest={manifest}
movingRef={movingRef}
verticalMotionRef={verticalMotionRef}
attackPulseRef={attackPulseRef}
animationEventRef={animationEventRef}
onReady={onReady}
preview={preview}
turnDegrees={turnDegrees}
equipment={equipment}
/>
</Suspense>
</AvatarErrorBoundary>
);
}