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; verticalMotionRef?: MutableRefObject; attackPulseRef?: MutableRefObject; animationEventRef?: MutableRefObject; onReady?: () => void; preview?: boolean; turnDegrees?: number; equipment?: readonly InventoryItem[]; fallback: ReactNode; } const EMPTY_EQUIPMENT: readonly InventoryItem[] = Object.freeze([]); type CharacterLocomotionMotion = CharacterBaseMotion | Exclude; 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; verticalMotionRef?: MutableRefObject; attackPulseRef?: MutableRefObject; animationEventRef?: MutableRefObject; 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(null); const overrideAction = useRef(null); const overrideTerminal = useRef(false); const overrideLooping = useRef(false); const currentMotion = useRef(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(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(); const ownedSkeletons = new Set(); 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, [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 ( <> ); } 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 = ( {fallback} {identity ? ( ) : null} ); if (!identity || !manifest || !entry) return rotatedFallback; const resetKey = `${entry.raceId}:${entry.gender}:${entry.model}`; return ( ); }