Release Healer Man 0.1.9

This commit is contained in:
phenom committed 2026-08-30 14:16:56 -04:00
1 parent c245a199b6
commit d060296ade
74 files changed
+5901 -334

No files matched your search

+136 -73
View File
@@ -5,31 +5,47 @@ import {
Suspense,
useEffect,
useMemo,
type MutableRefObject,
type ReactNode,
} from "react";
import {
DoubleSide,
Mesh,
Object3D,
SkinnedMesh,
Vector3,
type Material,
} from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import type { ClassId } from "../app/characterCatalog";
import type { AvatarManifestEntry } from "./characterManifest";
import type {
EquipmentAttachmentName,
EquipmentModelVisual,
} from "./equipmentAppearance";
import {
resolveAvatarAttachmentPoint,
type AvatarAttachmentTransform,
} from "./attachmentPoints";
import { AvatarErrorBoundary } from "./AvatarErrorBoundary";
import {
avatarWeaponSocketRotation,
findAvatarHandBone,
StarterWeaponAttachments,
findAvatarShieldBone,
StarterWeaponVisual,
type AvatarHand,
type StarterWeaponRotation,
} from "./starterWeapons";
const EQUIPMENT_ATTACHMENT_NAMES: readonly EquipmentAttachmentName[] = Object.freeze([
"handLeft",
"handRight",
"shield",
"shoulderLeft",
"shoulderRight",
]);
const NO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
export type EquipmentAttachmentTransforms = Readonly<
Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>>
>;
export function equipmentAttachmentHand(
attachment: EquipmentAttachmentName,
): AvatarHand | null {
@@ -58,23 +74,14 @@ function cloneEquipmentScene(source: Object3D, name: string): Object3D {
return scene;
}
function findSkinBone(root: Object3D, boneIndex: number): Object3D | null {
let bone: Object3D | null = null;
root.traverse((node) => {
if (bone) return;
const skinnedMesh = node as SkinnedMesh;
if (!skinnedMesh.isSkinnedMesh) return;
bone = skinnedMesh.skeleton.bones[boneIndex] ?? null;
});
return bone;
}
function fallbackTarget(
root: Object3D,
attachment: EquipmentAttachmentName,
): Object3D | null {
if (attachment === "handLeft" || attachment === "shield") {
return findAvatarHandBone(root, "left");
return attachment === "shield"
? findAvatarShieldBone(root)
: findAvatarHandBone(root, "left");
}
if (attachment === "handRight") return findAvatarHandBone(root, "right");
return root.getObjectByName(
@@ -82,43 +89,42 @@ function fallbackTarget(
) ?? null;
}
function attachmentTransform(
/** Snapshots all authored attachment offsets once, before animation begins. */
export function resolveEquipmentAttachmentTransforms(
root: Object3D,
entry: AvatarManifestEntry,
attachment: EquipmentAttachmentName,
): { target: Object3D; position: Vector3 } | null {
const point = entry.attachmentPoints?.[attachment];
const target = point
? findSkinBone(root, point.bone) ?? fallbackTarget(root, attachment)
: fallbackTarget(root, attachment);
if (!target) return null;
if (!point) return { target, position: new Vector3() };
root.updateWorldMatrix(true, true);
const position = root.localToWorld(new Vector3(...point.position));
target.worldToLocal(position);
return { target, position };
): EquipmentAttachmentTransforms {
const transforms: Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>> = {};
for (const attachment of EQUIPMENT_ATTACHMENT_NAMES) {
const transform = resolveAvatarAttachmentPoint(
root,
entry.attachmentPoints?.[attachment],
fallbackTarget(root, attachment),
{
useTargetOrigin: attachment === "handLeft"
|| attachment === "handRight"
|| attachment === "shield",
},
);
if (transform) transforms[attachment] = transform;
}
return transforms;
}
function AttachedEquipmentModel({
entry,
itemModel,
root,
transform,
}: {
readonly entry: AvatarManifestEntry;
readonly itemModel: EquipmentModelVisual;
readonly root: Object3D;
readonly transform: AvatarAttachmentTransform;
}): ReactNode {
const gltf = useGLTF(resolveContentUrl(itemModel.url));
const scene = useMemo(
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
[gltf.scene, itemModel.attachment, itemModel.itemId],
);
const transform = useMemo(
() => attachmentTransform(root, entry, itemModel.attachment),
[entry, itemModel.attachment, root],
);
useEffect(() => () => {
const materials = new Set<Material>();
scene.traverse((node) => {
@@ -130,52 +136,109 @@ function AttachedEquipmentModel({
materials.forEach((material) => material.dispose());
}, [scene]);
if (!transform) return null;
const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
: NO_ROTATION;
return createPortal(
<primitive object={scene} position={transform.position} />,
<group
position={transform.position}
rotation={socketRotation}
scale={transform.scale}
>
<primitive object={scene} />
</group>,
transform.target,
);
}
export function EquipmentAttachments({
attackPulseRef,
classId,
function EquipmentModelFallback({
entry,
models,
root,
fallbackScale,
itemModel,
transform,
}: {
readonly attackPulseRef?: MutableRefObject<number>;
readonly classId: ClassId;
readonly entry: AvatarManifestEntry;
readonly fallbackScale: number;
readonly itemModel: EquipmentModelVisual;
readonly transform: AvatarAttachmentTransform;
}): ReactNode {
const hand = equipmentAttachmentHand(itemModel.attachment);
if (!hand || !itemModel.fallbackKind || !itemModel.weaponStance) return null;
const socketRotation = avatarWeaponSocketRotation(
entry,
itemModel.fallbackKind,
itemModel.weaponStance,
);
return createPortal(
<group
position={transform.position}
rotation={socketRotation}
scale={transform.scale}
>
<StarterWeaponVisual
hand={hand}
kind={itemModel.fallbackKind}
name={`fallback-equipment-${itemModel.itemId}-${itemModel.attachment}`}
scale={fallbackScale}
/>
</group>,
transform.target,
);
}
function ResolvedEquipmentAttachment({
entry,
fallbackScale,
itemModel,
transform,
}: {
readonly entry: AvatarManifestEntry;
readonly fallbackScale: number;
readonly itemModel: EquipmentModelVisual;
readonly transform: AvatarAttachmentTransform;
}): ReactNode {
const fallback = (
<EquipmentModelFallback
entry={entry}
fallbackScale={fallbackScale}
itemModel={itemModel}
transform={transform}
/>
);
return (
<AvatarErrorBoundary
fallback={fallback}
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
>
<Suspense fallback={fallback}>
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} />
</Suspense>
</AvatarErrorBoundary>
);
}
export function EquipmentAttachments({
entry,
fallbackScale,
models,
transforms,
}: {
readonly entry: AvatarManifestEntry;
readonly fallbackScale: number;
readonly models: readonly EquipmentModelVisual[];
readonly root: Object3D;
readonly transforms: EquipmentAttachmentTransforms;
}): ReactNode {
return models.map((itemModel) => {
const hand = equipmentAttachmentHand(itemModel.attachment);
const fallback = hand
? (
<StarterWeaponAttachments
attackPulseRef={attackPulseRef}
classId={classId}
onlyHands={[hand]}
root={root}
/>
)
: null;
return (
<AvatarErrorBoundary
const transform = transforms[itemModel.attachment];
return transform ? (
<ResolvedEquipmentAttachment
key={`${itemModel.itemId}:${itemModel.attachment}`}
fallback={fallback}
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
>
<Suspense fallback={fallback}>
<AttachedEquipmentModel
entry={entry}
itemModel={itemModel}
root={root}
/>
</Suspense>
</AvatarErrorBoundary>
);
entry={entry}
fallbackScale={fallbackScale}
itemModel={itemModel}
transform={transform}
/>
) : null;
});
}