617 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|