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

+25 -11
View File
@@ -61,10 +61,13 @@ import {
import {
EquipmentAttachments,
equipmentAttachmentHand,
resolveEquipmentAttachmentTransforms,
} from "./equipmentAttachments";
import {
ApproximateStarterEquipment,
StarterWeaponAttachments,
characterWeaponStance,
estimateAvatarWeaponScale,
type AvatarHand,
} from "./starterWeapons";
@@ -192,6 +195,14 @@ function LoadedCharacterModel({
}) {
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);
@@ -199,7 +210,6 @@ function LoadedCharacterModel({
const overrideLooping = useRef(false);
const currentMotion = useRef<CharacterLocomotionMotion | null>(null);
const observedAttackPulse = useRef(attackPulseRef?.current ?? 0);
const equipmentAttackPulseRef = useRef(attackPulseRef?.current ?? 0);
const observedAnimationRevision = useRef(animationEventRef?.current?.revision ?? 0);
const attackElapsed = useRef(ATTACK_PRESENTATION_DURATION_SECONDS);
const attackJoint = useMemo(
@@ -233,6 +243,15 @@ function LoadedCharacterModel({
}))] 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;
@@ -339,7 +358,7 @@ function LoadedCharacterModel({
};
const selectClip = (motion: CharacterBaseMotion): AnimationClip | undefined => (
selectCharacterBaseClip(gltf.animations, motion)
selectCharacterBaseClip(gltf.animations, motion, weaponStance)
?? namedClip(entry.animations[motion])
?? namedClip(entry.animations.idle)
?? gltf.animations[0]
@@ -347,7 +366,6 @@ function LoadedCharacterModel({
const switchMotion = (motion: CharacterLocomotionMotion, moving: boolean) => {
if (overrideAction.current) return;
if (currentMotion.current === motion) return;
const verticalSelection = motion === "rising" || motion === "falling" || motion === "landing"
? selectCharacterVerticalClip(gltf.animations, motion, moving)
: null;
@@ -356,6 +374,7 @@ function LoadedCharacterModel({
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;
@@ -462,15 +481,11 @@ function LoadedCharacterModel({
if (nextAnimationEvent && nextAnimationEvent.revision !== observedAnimationRevision.current) {
observedAnimationRevision.current = nextAnimationEvent.revision;
playAnimationEvent(nextAnimationEvent);
if (nextAnimationEvent.kind === "attack" || nextAnimationEvent.kind === "ability-release") {
equipmentAttackPulseRef.current = nextAnimationEvent.revision;
}
}
const nextAttackPulse = attackPulseRef?.current ?? 0;
if (nextAttackPulse !== observedAttackPulse.current) {
observedAttackPulse.current = nextAttackPulse;
if (nextAttackPulse > 0) {
equipmentAttackPulseRef.current = nextAttackPulse;
playAnimationEvent({ revision: nextAttackPulse, kind: "attack" });
}
}
@@ -513,17 +528,16 @@ function LoadedCharacterModel({
/>
</group>
<StarterWeaponAttachments
attackPulseRef={equipmentAttackPulseRef}
classId={identity.classId}
entry={entry}
occupiedHands={occupiedEquipmentHands}
root={scene}
/>
<EquipmentAttachments
attackPulseRef={equipmentAttackPulseRef}
classId={identity.classId}
entry={entry}
fallbackScale={fallbackEquipmentScale}
models={resolved.equipmentModels}
root={scene}
transforms={equipmentAttachmentTransforms}
/>
</>
);
+111
View File
@@ -0,0 +1,111 @@
import {
Bone,
BufferGeometry,
Group,
MeshBasicMaterial,
Skeleton,
SkinnedMesh,
Vector3,
} from "three";
import { describe, expect, it } from "vitest";
import {
findAvatarSkinBone,
resolveAvatarAttachmentPoint,
} from "./attachmentPoints";
const expectVectorClose = (actual: Vector3, expected: Vector3): void => {
expect(actual.x).toBeCloseTo(expected.x, 6);
expect(actual.y).toBeCloseTo(expected.y, 6);
expect(actual.z).toBeCloseTo(expected.z, 6);
};
function attachmentRig() {
const world = new Group();
world.position.set(4, -2, 7);
world.rotation.set(0.2, -0.35, 0.1);
world.scale.setScalar(1.7);
const root = new Group();
root.position.set(-1, 3, 2);
root.rotation.set(-0.1, 0.25, -0.15);
root.scale.setScalar(0.8);
world.add(root);
const internalRig = new Group();
internalRig.scale.setScalar(0.1);
root.add(internalRig);
const unusedBone = new Bone();
const attachmentBone = new Bone();
attachmentBone.position.set(2, 4, -3);
internalRig.add(unusedBone, attachmentBone);
const mesh = new SkinnedMesh(new BufferGeometry(), new MeshBasicMaterial());
mesh.bind(new Skeleton([unusedBone, attachmentBone]));
root.add(mesh);
return { attachmentBone, root, world };
}
describe("avatar attachment-point transforms", () => {
it("converts manifest positions into the selected skin bone", () => {
const { attachmentBone, root, world } = attachmentRig();
const point = { bone: 1, position: [0.45, 0.9, -0.2] as const };
const transform = resolveAvatarAttachmentPoint(root, point, null);
expect(transform?.target).toBe(attachmentBone);
expect(findAvatarSkinBone(root, 1)).toBe(attachmentBone);
world.updateWorldMatrix(true, true);
const expectedWorldPosition = root.localToWorld(new Vector3(...point.position));
const actualWorldPosition = attachmentBone.localToWorld(transform!.position.clone());
expectVectorClose(actualWorldPosition, expectedWorldPosition);
});
it("cancels internal locator scale without cancelling the avatar scale", () => {
const { attachmentBone, root, world } = attachmentRig();
const transform = resolveAvatarAttachmentPoint(root, undefined, attachmentBone)!;
const portalChild = new Group();
portalChild.scale.copy(transform.scale);
attachmentBone.add(portalChild);
world.updateWorldMatrix(true, true);
expectVectorClose(
portalChild.getWorldScale(new Vector3()),
root.getWorldScale(new Vector3()),
);
});
it("keeps authored hand joints at local zero after the skeleton animates", () => {
const { attachmentBone, root } = attachmentRig();
attachmentBone.position.set(-6, 8, 4);
attachmentBone.rotation.set(0.7, -0.4, 0.2);
const transform = resolveAvatarAttachmentPoint(
root,
{ bone: 1, position: [0.45, 0.9, -0.2] },
null,
{ useTargetOrigin: true },
);
expect(transform?.target).toBe(attachmentBone);
expect(transform?.position.toArray()).toEqual([0, 0, 0]);
});
it("falls back safely when a manifest joint is unavailable", () => {
const { root } = attachmentRig();
const fallback = new Group();
root.add(fallback);
const withMissingJoint = resolveAvatarAttachmentPoint(
root,
{ bone: 99, position: [0.1, 0.2, 0.3] },
fallback,
);
expect(withMissingJoint?.target).toBe(fallback);
const withoutMetadata = resolveAvatarAttachmentPoint(root, undefined, fallback);
expect(withoutMetadata?.target).toBe(fallback);
expect(withoutMetadata?.position.toArray()).toEqual([0, 0, 0]);
expect(resolveAvatarAttachmentPoint(root, undefined, null)).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { SkinnedMesh, Vector3, type Object3D } from "three";
import type { AvatarAttachmentPoint } from "./characterManifest";
export interface AvatarAttachmentTransform {
readonly target: Object3D;
readonly position: Vector3;
readonly scale: Vector3;
}
export interface AvatarAttachmentOptions {
/** The manifest point is the authored origin of its referenced attachment joint. */
readonly useTargetOrigin?: boolean;
}
const safeScaleRatio = (rootScale: number, targetScale: number): number => (
Number.isFinite(rootScale)
&& Number.isFinite(targetScale)
&& Math.abs(targetScale) > 1e-6
? rootScale / targetScale
: 1
);
function attachmentScale(root: Object3D, target: Object3D): Vector3 {
const rootScale = root.getWorldScale(new Vector3());
const targetScale = target.getWorldScale(new Vector3());
return new Vector3(
safeScaleRatio(rootScale.x, targetScale.x),
safeScaleRatio(rootScale.y, targetScale.y),
safeScaleRatio(rootScale.z, targetScale.z),
);
}
export function findAvatarSkinBone(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;
}
/** Converts a manifest point from avatar-local space into its target bone. */
export function resolveAvatarAttachmentPoint(
root: Object3D,
point: AvatarAttachmentPoint | undefined,
fallbackTarget: Object3D | null,
options: AvatarAttachmentOptions = {},
): AvatarAttachmentTransform | null {
const skinBone = point ? findAvatarSkinBone(root, point.bone) : null;
const target = skinBone ?? fallbackTarget;
if (!target) return null;
root.updateWorldMatrix(true, true);
const scale = attachmentScale(root, target);
if (!point || (skinBone && options.useTargetOrigin)) {
return { target, position: new Vector3(), scale };
}
const position = root.localToWorld(new Vector3(...point.position));
target.worldToLocal(position);
return { target, position, scale };
}
@@ -21,6 +21,15 @@ describe("avatar rendering integration invariants", () => {
expect(characterModelSource).toContain("<EquipmentAttachments");
});
it("uses manifest sockets while the loaded skeleton owns weapon motion", () => {
const loadedStarterAttachment = characterModelSource.match(
/<StarterWeaponAttachments[\s\S]*?\/>/,
)?.[0] ?? "";
expect(loadedStarterAttachment).toContain("entry={entry}");
expect(loadedStarterAttachment).not.toContain("attackPulseRef=");
expect(characterModelSource).not.toContain("equipmentAttackPulseRef");
});
it("isolates each optional gear model behind its own loading and error boundary", () => {
expect(equipmentAttachmentSource).toContain("<AvatarErrorBoundary");
expect(equipmentAttachmentSource).toContain("<Suspense fallback={fallback}>");
+1 -1
View File
@@ -35,7 +35,7 @@ const REQUIRED_NATIVE_IDS = [
0, 1, 4, 5, 8, 9, 10,
16, 17, 18, 19,
25, 26, 27, 28,
46,
46, 109,
51, 52, 53, 54,
57, 58, 95, 124, 125,
] as const;
+21 -1
View File
@@ -22,7 +22,7 @@ describe("resolveEquipmentAppearance", () => {
expect(resolved.models).toEqual(expect.arrayContaining([
expect.objectContaining({ itemId: 6472, attachment: "handRight" }),
expect.objectContaining({ itemId: 6447, attachment: "handLeft" }),
expect.objectContaining({ itemId: 6447, attachment: "shield" }),
expect.objectContaining({ itemId: 5404, attachment: "shoulderLeft" }),
expect.objectContaining({ itemId: 5404, attachment: "shoulderRight" }),
]));
@@ -39,6 +39,26 @@ describe("resolveEquipmentAppearance", () => {
]);
});
it("routes every shield model through the dedicated shield attachment", () => {
for (const itemId of [6447, 6630, 13245]) {
expect(resolveEquipmentAppearance([item(itemId)], "male").models).toEqual([
expect.objectContaining({ itemId, attachment: "shield" }),
]);
}
});
it("declares a stance-matched procedural fallback for every held model", () => {
for (const itemId of [5243, 6447, 6448, 6469, 6472, 6630, 6631, 13245]) {
const heldModels = resolveEquipmentAppearance([item(itemId)], "male").models
.filter((model) => model.attachment !== "shoulderLeft" && model.attachment !== "shoulderRight");
expect(heldModels).not.toHaveLength(0);
for (const model of heldModels) {
expect(model.fallbackKind).toBeTruthy();
expect(model.weaponStance).toBeTruthy();
}
}
});
it("uses gendered robe textures, equipment geosets, and the actual cape skin", () => {
const resolved = resolveEquipmentAppearance([
item(6465),
+22 -3
View File
@@ -1,4 +1,5 @@
import type { GenderId } from "../app/characterCatalog";
import type { CharacterWeaponStance } from "../game/combatAnimation";
import type { InventoryItem } from "../game/lootTypes";
import type { TextureRegion } from "./characterManifest";
@@ -33,6 +34,8 @@ export interface EquipmentModelVisual {
readonly name: string;
readonly url: string;
readonly attachment: EquipmentAttachmentName;
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
readonly weaponStance?: CharacterWeaponStance;
}
interface GenderedTextureLayer {
@@ -67,6 +70,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Firebelcher",
url: model("firebelcher-5243"),
attachment: "handRight",
fallbackKind: "mace",
weaponStance: "one-handed",
}],
},
5404: {
@@ -101,7 +106,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
models: [{
name: "Worn Turtle Shell Shield",
url: model("worn-turtle-shell-shield-6447"),
attachment: "handLeft",
attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}],
},
6448: {
@@ -109,6 +116,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Tail Spike",
url: model("tail-spike-6448"),
attachment: "handRight",
fallbackKind: "dagger",
weaponStance: "one-handed",
}],
},
6449: {
@@ -188,6 +197,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Venomstrike",
url: model("venomstrike-6469"),
attachment: "handLeft",
fallbackKind: "bow",
weaponStance: "bow",
}],
},
6472: {
@@ -195,6 +206,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Stinging Viper",
url: model("stinging-viper-6472"),
attachment: "handRight",
fallbackKind: "mace",
weaponStance: "one-handed",
}],
},
6473: {
@@ -237,7 +250,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
models: [{
name: "Seedcloud Buckler",
url: model("seedcloud-buckler-6630"),
attachment: "handLeft",
attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}],
},
6631: {
@@ -245,6 +260,8 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
name: "Living Root",
url: model("living-root-6631"),
attachment: "handRight",
fallbackKind: "staff",
weaponStance: "two-handed-left",
}],
},
6632: {
@@ -291,7 +308,9 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
models: [{
name: "Kresh's Back",
url: model("kreshs-back-13245"),
attachment: "handLeft",
attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}],
},
});
+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;
});
}
@@ -3,6 +3,19 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import type { AvatarManifest } from "./characterManifest";
interface RunewakerGlbDocument {
readonly animations?: readonly { readonly name?: string }[];
readonly nodes?: readonly { readonly name?: string }[];
}
function readGlbDocument(glb: Buffer): RunewakerGlbDocument {
expect(glb.readUInt32LE(0)).toBe(0x46546c67);
const jsonLength = glb.readUInt32LE(12);
return JSON.parse(
glb.subarray(20, 20 + jsonLength).toString("utf8").trimEnd(),
) as RunewakerGlbDocument;
}
const manifestFile = path.resolve("public/assets/characters/runewaker-v1/manifest.json");
describe("runewaker-v1 playable avatar pack", () => {
@@ -32,6 +45,17 @@ describe("runewaker-v1 playable avatar pack", () => {
});
const model = path.resolve("public", entry.model.replace(/^\//, ""));
expect((await stat(model)).size).toBe(entry.bytes);
const document = readGlbDocument(await readFile(model));
const animationNames = new Set(
document.animations?.map((animation) => animation.name ?? "") ?? [],
);
for (const animationName of ["Idle - 1h_idle", "Idle - 2h_idle", "Idle - bow_idle"]) {
expect(animationNames.has(animationName), `${entry.model} ${animationName}`).toBe(true);
}
const nodeNames = new Set(document.nodes?.map((node) => node.name ?? "") ?? []);
for (const nodeName of ["p_left_hand", "p_right_hand", "p_hand_shield"]) {
expect(nodeNames.has(nodeName), `${entry.model} ${nodeName}`).toBe(true);
}
}
});
});
+127 -3
View File
@@ -1,18 +1,26 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Group, Object3D } from "three";
import { Euler, Group, Object3D, Vector3 } from "three";
import { describe, expect, it } from "vitest";
import { CLASSES, type ClassId } from "../app/characterCatalog";
import {
STARTER_WEAPON_LOADOUTS,
approximateStarterWeaponGripRotation,
avatarWeaponSocketRotation,
characterWeaponStance,
estimateAvatarWeaponScale,
findAvatarHandBone,
findAvatarHandBones,
findAvatarShieldBone,
sampleStarterWeaponStrike,
starterWeaponItemsForAvailableHands,
starterWeaponLoadout,
starterWeaponModelRotation,
starterWeaponStance,
} from "./starterWeapons";
import type { AvatarManifestEntry } from "./characterManifest";
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
import { equipmentAttachmentHand } from "./equipmentAttachments";
interface RawGlbNode {
@@ -93,6 +101,89 @@ describe("starter weapon loadouts", () => {
expect(equipmentAttachmentHand("shield")).toBe("left");
expect(equipmentAttachmentHand("shoulderLeft")).toBeNull();
});
it("selects the native ready stance that matches each loadout", () => {
expect(starterWeaponStance("warrior")).toBe("one-handed");
expect(starterWeaponStance("rogue")).toBe("one-handed");
expect(starterWeaponStance("hunter")).toBe("bow");
expect(starterWeaponStance("priest")).toBe("two-handed-left");
expect(starterWeaponStance("death-knight")).toBe("two-handed");
expect(starterWeaponStance("rom-priest")).toBe("two-handed-left");
});
it("lets visible equipped weapons override the class starter stance", () => {
expect(characterWeaponStance("priest", [
{ inventorySlot: "one-hand", itemId: 6472, weaponType: "dagger" },
], [6472])).toBe("one-handed");
expect(characterWeaponStance("warrior", [
{ inventorySlot: "two-hand", itemId: 6631, weaponType: "staff" },
], [6631])).toBe("two-handed-left");
expect(characterWeaponStance("warrior", [
{ inventorySlot: "ranged", itemId: 6469, weaponType: "bow" },
], [6469])).toBe("bow");
expect(characterWeaponStance("priest", [
{ inventorySlot: "one-hand", itemId: 9999, weaponType: "dagger" },
], [])).toBe("two-handed-left");
});
it("normalizes procedural equipment to the native WoW item axes", () => {
const rotated = (
kind: Parameters<typeof starterWeaponModelRotation>[0],
direction: readonly [number, number, number],
) => new Vector3(...direction).applyEuler(new Euler(...starterWeaponModelRotation(kind)));
expect(rotated("sword", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
.toBeLessThan(1e-6);
expect(rotated("dagger", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
.toBeLessThan(1e-6);
expect(rotated("mace", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0)))
.toBeLessThan(1e-6);
expect(rotated("staff", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
.toBeLessThan(1e-6);
expect(rotated("bow", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0)))
.toBeLessThan(1e-6);
expect(rotated("bow", [0, 0, -1]).distanceTo(new Vector3(0, 1, 0)))
.toBeLessThan(1e-6);
expect(starterWeaponModelRotation("shield")).toEqual([0, 0, 0]);
const fallbackDirection = (
kind: Parameters<typeof starterWeaponModelRotation>[0],
direction: readonly [number, number, number],
) => rotated(kind, direction).applyEuler(
new Euler(...approximateStarterWeaponGripRotation(kind)),
);
expect(fallbackDirection("sword", [0, -1, 0]).distanceTo(new Vector3(0, -1, 0)))
.toBeLessThan(1e-6);
expect(fallbackDirection("staff", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
.toBeLessThan(1e-6);
expect(fallbackDirection("bow", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0)))
.toBeLessThan(1e-6);
});
it("adapts canonical weapons to RuneWaker prop-socket axes", () => {
const entry = {
rendererStrategy: { kind: "runewaker-modular" },
} as AvatarManifestEntry;
const socketDirection = (
kind: Parameters<typeof avatarWeaponSocketRotation>[1],
stance: Parameters<typeof avatarWeaponSocketRotation>[2],
) => new Vector3(1, 0, 0).applyEuler(
new Euler(...avatarWeaponSocketRotation(entry, kind, stance)),
);
expect(socketDirection("sword", "one-handed").distanceTo(new Vector3(0, 0, 1)))
.toBeLessThan(1e-6);
expect(socketDirection("bow", "bow").distanceTo(new Vector3(0, 0, 1)))
.toBeLessThan(1e-6);
expect(socketDirection("staff", "two-handed-left").distanceTo(new Vector3(0, 1, 0)))
.toBeLessThan(1e-6);
expect(socketDirection("sword", "two-handed").distanceTo(new Vector3(0, 1, 0)))
.toBeLessThan(1e-6);
expect(socketDirection("shield", "one-handed").distanceTo(new Vector3(1, 0, 0)))
.toBeLessThan(1e-6);
expect(avatarWeaponSocketRotation(undefined, "sword", "one-handed"))
.toEqual([0, 0, 0]);
});
});
describe("avatar hand-bone discovery", () => {
@@ -123,6 +214,37 @@ describe("avatar hand-bone discovery", () => {
expect(findAvatarHandBone(root, "right")).toBeNull();
});
it("prioritizes explicit RuneWaker equipment locators over generic hand bones", () => {
const root = new Group();
const genericLeft = new Group();
genericLeft.name = "Bip01 L Hand";
const genericRight = new Group();
genericRight.name = "Bip01 R Hand";
const leftPivot = new Group();
leftPivot.name = "p_left_hand";
const rightPivot = new Group();
rightPivot.name = "p_right_hand";
const shieldPivot = new Group();
shieldPivot.name = "p_hand_shield";
root.add(genericLeft, genericRight, leftPivot, rightPivot, shieldPivot);
expect(findAvatarHandBone(root, "left")).toBe(leftPivot);
expect(findAvatarHandBone(root, "right")).toBe(rightPivot);
expect(findAvatarShieldBone(root)).toBe(shieldPivot);
});
it("cancels inherited locator scale for portal children", () => {
const root = new Group();
const scaledRig = new Group();
scaledRig.scale.setScalar(0.1);
const pivot = new Group();
root.add(scaledRig);
scaledRig.add(pivot);
const transform = resolveAvatarAttachmentPoint(root, undefined, pivot);
expect(transform?.scale.toArray()).toEqual([10, 10, 10]);
});
it("estimates equipment scale in avatar-local space and ignores parent transforms", () => {
const actor = new Group();
actor.position.set(20, 8, -12);
@@ -143,7 +265,7 @@ describe("avatar hand-bone discovery", () => {
});
it("resolves both hands in every shipped race/gender character rig", () => {
const manifestPaths = ["wow335a-v1", "ascension-v1"].map((version) => path.join(
const manifestPaths = ["wow335a-v1", "ascension-v1", "runewaker-v1"].map((version) => path.join(
projectDirectory,
"public",
"assets",
@@ -154,7 +276,7 @@ describe("avatar hand-bone discovery", () => {
const entries = manifestPaths.flatMap((manifestPath) => (
Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries)
));
expect(entries).toHaveLength(45);
expect(entries).toHaveLength(50);
const resolvedLeftNames = new Set<string>();
const resolvedRightNames = new Set<string>();
const unresolved: string[] = [];
@@ -172,6 +294,8 @@ describe("avatar hand-bone discovery", () => {
// accidentally hardcodes one id cannot satisfy this diversity check.
expect(resolvedLeftNames.size).toBeGreaterThan(8);
expect(resolvedRightNames.size).toBeGreaterThan(8);
expect(resolvedLeftNames).toContain("p_left_hand");
expect(resolvedRightNames).toContain("p_right_hand");
}, 20_000);
});
+228 -27
View File
@@ -7,6 +7,10 @@ import {
type BaseClassId,
type ClassId,
} from "../app/characterCatalog";
import type { CharacterWeaponStance } from "../game/combatAnimation";
import type { InventoryItem } from "../game/lootTypes";
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
import type { AvatarAttachmentPoint, AvatarManifestEntry } from "./characterManifest";
export type AvatarHand = "left" | "right";
@@ -28,8 +32,11 @@ export interface StarterWeaponItem {
export interface StarterWeaponLoadout {
readonly classId: ClassId;
readonly items: readonly StarterWeaponItem[];
readonly stance: StarterWeaponStance;
}
export type StarterWeaponStance = CharacterWeaponStance;
const item = (
id: string,
kind: StarterWeaponKind,
@@ -45,6 +52,7 @@ const item = (
const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLoadout>> = Object.freeze({
warrior: Object.freeze({
classId: "warrior",
stance: "one-handed",
items: Object.freeze([
item("warrior-sword", "sword", "right", 1.04),
item("warrior-shield", "shield", "left", 0.96),
@@ -52,6 +60,7 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
paladin: Object.freeze({
classId: "paladin",
stance: "one-handed",
items: Object.freeze([
item("paladin-mace", "mace", "right", 1.04),
item("paladin-shield", "shield", "left", 0.98),
@@ -59,10 +68,12 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
hunter: Object.freeze({
classId: "hunter",
stance: "bow",
items: Object.freeze([item("hunter-bow", "bow", "left", 1.08)]),
}),
rogue: Object.freeze({
classId: "rogue",
stance: "one-handed",
items: Object.freeze([
item("rogue-main-dagger", "dagger", "right", 0.94),
item("rogue-off-dagger", "dagger", "left", 0.94),
@@ -70,14 +81,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
priest: Object.freeze({
classId: "priest",
stance: "two-handed-left",
items: Object.freeze([item("priest-staff", "staff", "right", 0.96)]),
}),
"death-knight": Object.freeze({
classId: "death-knight",
stance: "two-handed",
items: Object.freeze([item("death-knight-runeblade", "sword", "right", 1.18)]),
}),
shaman: Object.freeze({
classId: "shaman",
stance: "one-handed",
items: Object.freeze([
item("shaman-mace", "mace", "right", 1),
item("shaman-shield", "shield", "left", 0.9),
@@ -85,14 +99,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
mage: Object.freeze({
classId: "mage",
stance: "two-handed-left",
items: Object.freeze([item("mage-staff", "staff", "right", 0.94)]),
}),
warlock: Object.freeze({
classId: "warlock",
stance: "two-handed-left",
items: Object.freeze([item("warlock-staff", "staff", "right", 1)]),
}),
druid: Object.freeze({
classId: "druid",
stance: "two-handed-left",
items: Object.freeze([item("druid-staff", "staff", "right", 1.02)]),
}),
});
@@ -103,6 +120,7 @@ export const STARTER_WEAPON_LOADOUTS: Readonly<Record<ClassId, StarterWeaponLoad
return [characterClass.id, Object.freeze({
classId: characterClass.id,
items: source.items,
stance: source.stance,
})];
})) as unknown as Record<ClassId, StarterWeaponLoadout>,
);
@@ -111,6 +129,30 @@ export function starterWeaponLoadout(classId: ClassId): StarterWeaponLoadout {
return STARTER_WEAPON_LOADOUTS[classId];
}
export function starterWeaponStance(classId: ClassId): StarterWeaponStance {
return starterWeaponLoadout(classId).stance;
}
type VisibleWeaponItem = Pick<InventoryItem, "inventorySlot" | "itemId" | "weaponType">;
/** Selects a ready pose from the weapon models that are actually being rendered. */
export function characterWeaponStance(
classId: ClassId,
equipment: readonly VisibleWeaponItem[] = [],
visibleWeaponItemIds: readonly number[] = [],
): StarterWeaponStance {
const visibleIds = new Set(visibleWeaponItemIds);
const visibleWeapons = equipment.filter((equipped) => (
equipped.weaponType && visibleIds.has(equipped.itemId)
));
if (visibleWeapons.some((equipped) => equipped.weaponType === "bow")) return "bow";
const weapon = visibleWeapons.find((equipped) => equipped.inventorySlot !== "ranged")
?? visibleWeapons[0];
if (!weapon) return starterWeaponStance(classId);
if (weapon.weaponType === "staff") return "two-handed-left";
return weapon.inventorySlot === "two-hand" ? "two-handed" : "one-handed";
}
export function starterWeaponItemsForAvailableHands(
classId: ClassId,
occupiedHands: readonly AvatarHand[] = [],
@@ -124,6 +166,13 @@ const FINGER_PARENT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freez
right: /(?:Index|Middle|Ring|Pinky)FingerR_p$/i,
});
const EXPLICIT_HAND_PIVOT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freeze({
left: /^p_left_hand$/i,
right: /^p_right_hand$/i,
});
const EXPLICIT_SHIELD_PIVOT_PATTERN = /^p_hand_shield$/i;
const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Object.freeze({
left: Object.freeze([
/(?:^|[_ .-])L Hand$/i,
@@ -142,11 +191,19 @@ const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Obje
});
/**
* Finds a playable-character hand without relying on exporter-specific numeric
* bone ids. WoW's named finger pivot nodes survive every shipped race/gender
* export, and their shared parent is the animated hand bone.
* Finds a playable-character grip without relying on exporter-specific numeric
* bone ids. RuneWaker's explicit link-frame locator wins when present. WoW's
* named finger pivots otherwise identify their shared animated hand bone.
*/
export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D | null {
let explicitPivot: Object3D | null = null;
root.traverse((node) => {
if (!explicitPivot && EXPLICIT_HAND_PIVOT_PATTERN[hand].test(node.name)) {
explicitPivot = node;
}
});
if (explicitPivot) return explicitPivot;
const votes = new Map<Object3D, number>();
root.traverse((node) => {
if (!FINGER_PARENT_PATTERN[hand].test(node.name) || !node.parent) return;
@@ -171,6 +228,16 @@ export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D |
return namedFallback;
}
export function findAvatarShieldBone(root: Object3D): Object3D | null {
let explicitPivot: Object3D | null = null;
root.traverse((node) => {
if (!explicitPivot && EXPLICIT_SHIELD_PIVOT_PATTERN.test(node.name)) {
explicitPivot = node;
}
});
return explicitPivot ?? findAvatarHandBone(root, "left");
}
export interface AvatarHandBones {
readonly left: Object3D | null;
readonly right: Object3D | null;
@@ -206,6 +273,67 @@ export function estimateAvatarWeaponScale(
export type StarterWeaponRotation = readonly [x: number, y: number, z: number];
const ZERO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
const STARTER_WEAPON_MODEL_ROTATIONS: Readonly<
Record<StarterWeaponKind, StarterWeaponRotation>
> = Object.freeze({
bow: Object.freeze([Math.PI / 2, 0, -Math.PI / 2] as const),
dagger: Object.freeze([0, 0, Math.PI / 2] as const),
mace: Object.freeze([0, 0, Math.PI / 2] as const),
shield: ZERO_ROTATION,
staff: Object.freeze([0, 0, -Math.PI / 2] as const),
sword: Object.freeze([0, 0, Math.PI / 2] as const),
});
const APPROXIMATE_WEAPON_GRIP_ROTATIONS: Readonly<
Record<StarterWeaponKind, StarterWeaponRotation>
> = Object.freeze({
bow: Object.freeze([0, 0, Math.PI / 2] as const),
dagger: Object.freeze([0, 0, -Math.PI / 2] as const),
mace: Object.freeze([0, 0, -Math.PI / 2] as const),
shield: ZERO_ROTATION,
staff: Object.freeze([0, 0, Math.PI / 2] as const),
sword: Object.freeze([0, 0, -Math.PI / 2] as const),
});
const RUNEWAKER_ONE_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
0,
-Math.PI / 2,
0,
]);
const RUNEWAKER_TWO_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
0,
0,
Math.PI / 2,
]);
/** Normalizes procedural geometry to the same local axes as native item GLBs. */
export function starterWeaponModelRotation(
kind: StarterWeaponKind,
): StarterWeaponRotation {
return STARTER_WEAPON_MODEL_ROTATIONS[kind];
}
/** Synthetic hand pose used only while the animated character rig is absent. */
export function approximateStarterWeaponGripRotation(
kind: StarterWeaponKind,
): StarterWeaponRotation {
return APPROXIMATE_WEAPON_GRIP_ROTATIONS[kind];
}
/** Adapts the canonical WoW +X item frame to a rig's authored prop socket. */
export function avatarWeaponSocketRotation(
entry: AvatarManifestEntry | undefined,
kind: StarterWeaponKind,
stance: StarterWeaponStance,
): StarterWeaponRotation {
if (entry?.rendererStrategy?.kind !== "runewaker-modular" || kind === "shield") {
return ZERO_ROTATION;
}
return stance === "two-handed" || stance === "two-handed-left"
? RUNEWAKER_TWO_HAND_SOCKET_ROTATION
: RUNEWAKER_ONE_HAND_SOCKET_ROTATION;
}
const STRIKE_DURATION_SECONDS = 0.54;
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
@@ -374,16 +502,16 @@ function BowVisual() {
function ShieldVisual() {
return (
<group name="starter-shield-geometry" position={[-0.08, 0, 0]}>
<mesh rotation={[0, 0, Math.PI / 2]}>
<group name="starter-shield-geometry" position={[0, 0, -0.08]}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.38, 0.38, 0.07, 12]} />
<meshStandardMaterial color="#6f5635" metalness={0.18} roughness={0.74} />
</mesh>
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
<mesh position={[0, 0, -0.045]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.12, 0.12, 0.09, 10]} />
<meshStandardMaterial color="#a6afb4" metalness={0.75} roughness={0.31} />
</mesh>
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
<mesh position={[0, 0, -0.045]}>
<torusGeometry args={[0.3, 0.035, 6, 16]} />
<meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} />
</mesh>
@@ -392,12 +520,22 @@ function ShieldVisual() {
}
function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) {
if (kind === "bow") return <BowVisual />;
if (kind === "dagger") return <DaggerVisual />;
if (kind === "mace") return <MaceVisual />;
if (kind === "shield") return <ShieldVisual />;
if (kind === "staff") return <StaffVisual />;
return <SwordVisual />;
const visual = kind === "bow"
? <BowVisual />
: kind === "dagger"
? <DaggerVisual />
: kind === "mace"
? <MaceVisual />
: kind === "shield"
? <ShieldVisual />
: kind === "staff"
? <StaffVisual />
: <SwordVisual />;
return (
<group rotation={starterWeaponModelRotation(kind)}>
{visual}
</group>
);
}
export interface StarterWeaponVisualProps {
@@ -449,35 +587,88 @@ export function StarterWeaponVisual({
interface AttachedStarterWeaponProps {
readonly attackPulse: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly attachmentScale: Vector3;
readonly item: StarterWeaponItem;
readonly position: Vector3;
readonly scale: number;
readonly socketRotation: StarterWeaponRotation;
readonly target: Object3D;
}
interface ResolvedStarterWeaponProps {
readonly attackPulse: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly fallbackTarget: Object3D | null;
readonly item: StarterWeaponItem;
readonly point?: AvatarAttachmentPoint;
readonly root: Object3D;
readonly scale: number;
readonly socketRotation: StarterWeaponRotation;
}
function AttachedStarterWeapon({
attackPulse,
attackPulseRef,
attachmentScale,
item: equipped,
position,
scale,
socketRotation,
target,
}: AttachedStarterWeaponProps) {
return createPortal(
<StarterWeaponVisual
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
hand={equipped.hand}
kind={equipped.kind}
name={`starter-equipment-${equipped.id}`}
scale={scale * equipped.scale}
/>,
<group position={position} rotation={socketRotation} scale={attachmentScale}>
<StarterWeaponVisual
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
hand={equipped.hand}
kind={equipped.kind}
name={`starter-equipment-${equipped.id}`}
scale={scale * equipped.scale}
/>
</group>,
target,
);
}
function ResolvedStarterWeapon({
attackPulse,
attackPulseRef,
fallbackTarget,
item: equipped,
point,
root,
scale,
socketRotation,
}: ResolvedStarterWeaponProps): ReactNode {
const transform = useMemo(
() => resolveAvatarAttachmentPoint(
root,
point,
fallbackTarget,
{ useTargetOrigin: true },
),
[fallbackTarget, point, root],
);
return transform ? (
<AttachedStarterWeapon
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
attachmentScale={transform.scale}
item={equipped}
position={transform.position}
scale={scale}
socketRotation={socketRotation}
target={transform.target}
/>
) : null;
}
export interface StarterWeaponAttachmentsProps {
readonly attackPulse?: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly classId: ClassId;
readonly entry?: AvatarManifestEntry;
readonly occupiedHands?: readonly AvatarHand[];
readonly onlyHands?: readonly AvatarHand[];
readonly root: Object3D;
@@ -493,6 +684,7 @@ export function StarterWeaponAttachments({
attackPulse = 0,
attackPulseRef,
classId,
entry,
occupiedHands = [],
onlyHands,
root,
@@ -504,20 +696,29 @@ export function StarterWeaponAttachments({
[hands, root, scale],
);
const allowedHands = onlyHands ? new Set(onlyHands) : null;
const stance = starterWeaponStance(classId);
const items = starterWeaponItemsForAvailableHands(classId, occupiedHands)
.filter((item) => !allowedHands || allowedHands.has(item.hand));
return items.map((equipped) => {
const target = hands[equipped.hand];
return target ? (
<AttachedStarterWeapon
const attachmentName = equipped.kind === "shield"
? "shield"
: equipped.hand === "left" ? "handLeft" : "handRight";
const fallbackTarget = equipped.kind === "shield"
? findAvatarShieldBone(root)
: hands[equipped.hand];
return (
<ResolvedStarterWeapon
key={equipped.id}
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
fallbackTarget={fallbackTarget}
item={equipped}
point={entry?.attachmentPoints?.[attachmentName]}
root={root}
scale={equipmentScale}
target={target}
socketRotation={avatarWeaponSocketRotation(entry, equipped.kind, stance)}
/>
) : null;
);
});
}
@@ -551,7 +752,7 @@ export function ApproximateStarterEquipment({
<group
key={equipped.id}
position={handPositions[equipped.hand]}
rotation={[0, -Math.PI / 2, 0]}
rotation={approximateStarterWeaponGripRotation(equipped.kind)}
>
<StarterWeaponVisual
attackPulse={attackPulse}