179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
import { createPortal } from "@react-three/fiber";
|
|
import { useTexture } from "@react-three/drei";
|
|
import { useEffect, useMemo } from "react";
|
|
import * as THREE from "three";
|
|
import {
|
|
CHARACTER_PART_CATALOG,
|
|
characterAppearancePartIds,
|
|
type CharacterAppearanceV1,
|
|
type CharacterMaterialVariant,
|
|
type CharacterPartId,
|
|
} from "../game/characterAppearance";
|
|
import type { MemberId } from "../game/types";
|
|
import { useGameGLTF } from "./GameAssetProvider";
|
|
|
|
interface BoundCharacterPart {
|
|
group: THREE.Group;
|
|
materials: THREE.Material[];
|
|
skeletons: THREE.Skeleton[];
|
|
}
|
|
|
|
const CHARACTER_MATERIAL_VARIANT_URLS: Record<CharacterMaterialVariant, string> = {
|
|
"priest-vestments": new URL("../assets/game/textures/claudecraft/chars/players/priest-vestments.webp", import.meta.url).href,
|
|
};
|
|
|
|
function rigBonesByName(rigScene: THREE.Object3D) {
|
|
const bones = new Map<string, THREE.Bone>();
|
|
rigScene.traverse((object) => {
|
|
if (object instanceof THREE.Bone) bones.set(object.name, object);
|
|
});
|
|
return bones;
|
|
}
|
|
|
|
function createBoundCharacterPart(
|
|
sourceScene: THREE.Object3D,
|
|
rigScene: THREE.Object3D,
|
|
partId: CharacterPartId,
|
|
materialTexture?: THREE.Texture,
|
|
): BoundCharacterPart {
|
|
const definition = CHARACTER_PART_CATALOG[partId];
|
|
const rigBones = rigBonesByName(rigScene);
|
|
const group = new THREE.Group();
|
|
group.name = `character-part:${partId}`;
|
|
const materialClones = new Map<THREE.Material, THREE.Material>();
|
|
const skeletons: THREE.Skeleton[] = [];
|
|
|
|
sourceScene.updateMatrixWorld(true);
|
|
for (const nodeName of definition.nodeNames) {
|
|
const sourceNode = sourceScene.getObjectByName(nodeName);
|
|
if (!sourceNode) throw new Error(`Character part ${partId} is missing node ${nodeName}.`);
|
|
|
|
const partNode = sourceNode.clone(true);
|
|
partNode.matrix.copy(sourceNode.matrixWorld);
|
|
partNode.matrix.decompose(partNode.position, partNode.quaternion, partNode.scale);
|
|
partNode.traverse((object) => {
|
|
if (!(object instanceof THREE.SkinnedMesh)) return;
|
|
if (materialTexture) {
|
|
const cloneMaterial = (source: THREE.Material) => {
|
|
const existing = materialClones.get(source);
|
|
if (existing) return existing;
|
|
const clone = source.clone();
|
|
if ("map" in clone) clone.map = materialTexture;
|
|
clone.needsUpdate = true;
|
|
materialClones.set(source, clone);
|
|
return clone;
|
|
};
|
|
object.material = Array.isArray(object.material)
|
|
? object.material.map(cloneMaterial)
|
|
: cloneMaterial(object.material);
|
|
}
|
|
const mappedBones = object.skeleton.bones.map((sourceBone) => {
|
|
const rigBone = rigBones.get(sourceBone.name);
|
|
if (!rigBone) throw new Error(`Character part ${partId} cannot resolve rig bone ${sourceBone.name}.`);
|
|
return rigBone;
|
|
});
|
|
const skeleton = new THREE.Skeleton(
|
|
mappedBones,
|
|
object.skeleton.boneInverses.map((inverse) => inverse.clone()),
|
|
);
|
|
const bindMatrix = object.bindMatrix.clone();
|
|
object.bind(skeleton, bindMatrix);
|
|
object.castShadow = true;
|
|
object.receiveShadow = true;
|
|
object.frustumCulled = false;
|
|
skeletons.push(skeleton);
|
|
});
|
|
group.add(partNode);
|
|
}
|
|
|
|
return { group, materials: [...materialClones.values()], skeletons };
|
|
}
|
|
|
|
function BoundCharacterPart({
|
|
actorScene,
|
|
materialTexture,
|
|
modelUrls,
|
|
partId,
|
|
}: {
|
|
actorScene: THREE.Object3D;
|
|
materialTexture?: THREE.Texture;
|
|
modelUrls: Record<MemberId, string>;
|
|
partId: CharacterPartId;
|
|
}) {
|
|
const definition = CHARACTER_PART_CATALOG[partId];
|
|
const gltf = useGameGLTF(modelUrls[definition.sourceMemberId]);
|
|
const boundPart = useMemo(
|
|
() => createBoundCharacterPart(gltf.scene, actorScene, partId, materialTexture),
|
|
[actorScene, gltf.scene, materialTexture, partId],
|
|
);
|
|
|
|
useEffect(() => () => {
|
|
for (const skeleton of boundPart.skeletons) skeleton.dispose();
|
|
for (const material of boundPart.materials) material.dispose();
|
|
}, [boundPart]);
|
|
|
|
const rigRoot = actorScene.getObjectByName("Rig_Medium") ?? actorScene;
|
|
return createPortal(<primitive object={boundPart.group} />, rigRoot);
|
|
}
|
|
|
|
function MaterialVariantCharacterPart({
|
|
actorScene,
|
|
materialVariant,
|
|
modelUrls,
|
|
partId,
|
|
}: {
|
|
actorScene: THREE.Object3D;
|
|
materialVariant: CharacterMaterialVariant;
|
|
modelUrls: Record<MemberId, string>;
|
|
partId: CharacterPartId;
|
|
}) {
|
|
const sourceTexture = useTexture(CHARACTER_MATERIAL_VARIANT_URLS[materialVariant]);
|
|
const materialTexture = useMemo(() => {
|
|
sourceTexture.flipY = false;
|
|
sourceTexture.colorSpace = THREE.SRGBColorSpace;
|
|
sourceTexture.magFilter = THREE.NearestFilter;
|
|
sourceTexture.needsUpdate = true;
|
|
return sourceTexture;
|
|
}, [sourceTexture]);
|
|
return <BoundCharacterPart actorScene={actorScene} materialTexture={materialTexture} modelUrls={modelUrls} partId={partId} />;
|
|
}
|
|
|
|
function ModularCharacterPart({
|
|
actorScene,
|
|
modelUrls,
|
|
partId,
|
|
}: {
|
|
actorScene: THREE.Object3D;
|
|
modelUrls: Record<MemberId, string>;
|
|
partId: CharacterPartId;
|
|
}) {
|
|
const definition = CHARACTER_PART_CATALOG[partId];
|
|
const materialVariant = "materialVariant" in definition ? definition.materialVariant : undefined;
|
|
return materialVariant
|
|
? <MaterialVariantCharacterPart actorScene={actorScene} materialVariant={materialVariant} modelUrls={modelUrls} partId={partId} />
|
|
: <BoundCharacterPart actorScene={actorScene} modelUrls={modelUrls} partId={partId} />;
|
|
}
|
|
|
|
export function ModularCharacterBody({
|
|
actorScene,
|
|
appearance,
|
|
modelUrls,
|
|
}: {
|
|
actorScene: THREE.Object3D;
|
|
appearance: CharacterAppearanceV1;
|
|
modelUrls: Record<MemberId, string>;
|
|
}) {
|
|
return (
|
|
<>
|
|
{characterAppearancePartIds(appearance).map((partId) => (
|
|
<ModularCharacterPart
|
|
key={`${partId}:${appearance.version}`}
|
|
actorScene={actorScene}
|
|
modelUrls={modelUrls}
|
|
partId={partId}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|