Release Healer Man 0.1.9
This commit is contained in:
1 parent
c245a199b6
commit
d060296ade
74 files changed
+5901
-334
No files matched your search
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}>");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user