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 = { "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(); 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(); 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; 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(, rigRoot); } function MaterialVariantCharacterPart({ actorScene, materialVariant, modelUrls, partId, }: { actorScene: THREE.Object3D; materialVariant: CharacterMaterialVariant; modelUrls: Record; 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 ; } function ModularCharacterPart({ actorScene, modelUrls, partId, }: { actorScene: THREE.Object3D; modelUrls: Record; partId: CharacterPartId; }) { const definition = CHARACTER_PART_CATALOG[partId]; const materialVariant = "materialVariant" in definition ? definition.materialVariant : undefined; return materialVariant ? : ; } export function ModularCharacterBody({ actorScene, appearance, modelUrls, }: { actorScene: THREE.Object3D; appearance: CharacterAppearanceV1; modelUrls: Record; }) { return ( <> {characterAppearancePartIds(appearance).map((partId) => ( ))} ); }