Files
healer-man/src/avatar/starterWeapons.tsx
T
2026-10-04 22:51:21 -04:00

778 lines
25 KiB
TypeScript

import { createPortal, useFrame } from "@react-three/fiber";
import { useMemo, useRef, type MutableRefObject, type ReactNode } from "react";
import { Vector3, type Group, type Object3D } from "three";
import {
CLASSES,
baseClassFor,
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";
export type StarterWeaponKind =
| "bow"
| "dagger"
| "mace"
| "shield"
| "staff"
| "sword";
export interface StarterWeaponItem {
readonly id: string;
readonly hand: AvatarHand;
readonly kind: StarterWeaponKind;
readonly scale: number;
}
export interface StarterWeaponLoadout {
readonly classId: ClassId;
readonly items: readonly StarterWeaponItem[];
readonly stance: StarterWeaponStance;
}
export type StarterWeaponStance = CharacterWeaponStance;
const item = (
id: string,
kind: StarterWeaponKind,
hand: AvatarHand,
scale = 1,
): StarterWeaponItem => Object.freeze({ id, kind, hand, scale });
/**
* Lightweight starter gear for every playable class. The visuals are kept
* procedural so characters remain equipped even when an item-model pack is
* unavailable or still loading.
*/
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),
]),
}),
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),
]),
}),
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),
]),
}),
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),
]),
}),
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)]),
}),
});
export const STARTER_WEAPON_LOADOUTS: Readonly<Record<ClassId, StarterWeaponLoadout>> = Object.freeze(
Object.fromEntries(CLASSES.map((characterClass) => {
const source = BASE_STARTER_WEAPON_LOADOUTS[baseClassFor(characterClass.id)];
return [characterClass.id, Object.freeze({
classId: characterClass.id,
items: source.items,
stance: source.stance,
})];
})) as unknown as Record<ClassId, StarterWeaponLoadout>,
);
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[] = [],
): readonly StarterWeaponItem[] {
const occupied = new Set(occupiedHands);
return starterWeaponLoadout(classId).items.filter((item) => !occupied.has(item.hand));
}
const FINGER_PARENT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freeze({
left: /(?:Index|Middle|Ring|Pinky)FingerL_p$/i,
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,
/(?:^|[_ .-])p_left_hand$/i,
/(?:^|[_ .-])HandL$/i,
/(?:^|[_ .-])LeftHand$/i,
/(?:^|[_ .-])L_?Hand$/i,
]),
right: Object.freeze([
/(?:^|[_ .-])R Hand$/i,
/(?:^|[_ .-])p_right_hand$/i,
/(?:^|[_ .-])HandR$/i,
/(?:^|[_ .-])RightHand$/i,
/(?:^|[_ .-])R_?Hand$/i,
]),
});
/**
* 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;
votes.set(node.parent, (votes.get(node.parent) ?? 0) + 1);
});
let winner: Object3D | null = null;
let winningVotes = 0;
for (const [candidate, count] of votes) {
if (count > winningVotes) {
winner = candidate;
winningVotes = count;
}
}
if (winner) return winner;
let namedFallback: Object3D | null = null;
root.traverse((node) => {
if (namedFallback) return;
if (NAMED_HAND_PATTERN[hand].some((pattern) => pattern.test(node.name))) namedFallback = node;
});
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;
}
export function findAvatarHandBones(root: Object3D): AvatarHandBones {
return {
left: findAvatarHandBone(root, "left"),
right: findAvatarHandBone(root, "right"),
};
}
/**
* Normalizes equipment to the proportions of the current race/gender rig.
* Hand height carries overall stature while hand span keeps broad races from
* receiving undersized gear. Coordinates are converted back into root-local
* space so world placement and the party actor's scale cannot skew the result.
*/
export function estimateAvatarWeaponScale(
root: Object3D,
hands: AvatarHandBones = findAvatarHandBones(root),
): number {
if (!hands.left || !hands.right) return 1;
root.updateWorldMatrix(true, true);
const left = root.worldToLocal(hands.left.getWorldPosition(new Vector3()));
const right = root.worldToLocal(hands.right.getWorldPosition(new Vector3()));
const handHeight = Math.max(0, (left.y + right.y) * 0.5);
const handSpan = left.distanceTo(right);
if (!Number.isFinite(handHeight) || !Number.isFinite(handSpan) || handHeight <= 0.05) return 1;
return Math.max(0.68, Math.min(1.35, handHeight * 0.75 + handSpan / 3));
}
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 runewakerWeaponSocketRotation(kind, stance);
}
export function runewakerWeaponSocketRotation(
kind: StarterWeaponKind,
stance: StarterWeaponStance,
): StarterWeaponRotation {
if (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));
const smoothStep = (value: number): number => {
const t = clamp01(value);
return t * t * (3 - 2 * t);
};
const mix = (from: number, to: number, amount: number): number => from + (to - from) * amount;
/** Pure animation sampler, kept separate so combat timing can be tested. */
export function sampleStarterWeaponStrike(
kind: StarterWeaponKind,
hand: AvatarHand,
elapsedSeconds: number,
): StarterWeaponRotation {
if (!Number.isFinite(elapsedSeconds) || elapsedSeconds < 0 || elapsedSeconds >= STRIKE_DURATION_SECONDS) {
return ZERO_ROTATION;
}
if (kind === "shield") return ZERO_ROTATION;
const direction = hand === "right" ? 1 : -1;
if (kind === "bow") {
const normalized = elapsedSeconds / STRIKE_DURATION_SECONDS;
const recoil = Math.sin(normalized * Math.PI) * 0.18;
return [0, direction * recoil, direction * recoil * 0.35];
}
const windupEnd = 0.16;
const strikeEnd = 0.31;
const amplitude = kind === "dagger" ? 0.72 : kind === "staff" ? 0.5 : 0.62;
let swing: number;
if (elapsedSeconds < windupEnd) {
swing = mix(0, -amplitude * 0.72, smoothStep(elapsedSeconds / windupEnd));
} else if (elapsedSeconds < strikeEnd) {
swing = mix(
-amplitude * 0.72,
amplitude,
smoothStep((elapsedSeconds - windupEnd) / (strikeEnd - windupEnd)),
);
} else {
swing = mix(
amplitude,
0,
smoothStep((elapsedSeconds - strikeEnd) / (STRIKE_DURATION_SECONDS - strikeEnd)),
);
}
return [0, 0, direction * swing];
}
function SwordVisual() {
return (
<group name="starter-sword-geometry">
<mesh position={[0, 0.02, 0]}>
<cylinderGeometry args={[0.025, 0.028, 0.24, 8]} />
<meshStandardMaterial color="#5a3420" roughness={0.78} />
</mesh>
<mesh position={[0, 0.155, 0]}>
<sphereGeometry args={[0.055, 8, 6]} />
<meshStandardMaterial color="#b68a3f" metalness={0.55} roughness={0.35} />
</mesh>
<mesh position={[0, -0.125, 0]}>
<boxGeometry args={[0.34, 0.045, 0.065]} />
<meshStandardMaterial color="#b68a3f" metalness={0.62} roughness={0.32} />
</mesh>
<mesh position={[0, -0.52, 0]}>
<boxGeometry args={[0.105, 0.73, 0.032]} />
<meshStandardMaterial color="#b9c4cb" metalness={0.88} roughness={0.24} />
</mesh>
<mesh position={[0, -0.925, 0]} rotation={[0, 0, Math.PI]}>
<coneGeometry args={[0.074, 0.14, 4]} />
<meshStandardMaterial color="#d2dce1" metalness={0.9} roughness={0.2} />
</mesh>
</group>
);
}
function DaggerVisual() {
return (
<group name="starter-dagger-geometry">
<mesh position={[0, 0.025, 0]}>
<cylinderGeometry args={[0.022, 0.024, 0.19, 7]} />
<meshStandardMaterial color="#4e2e22" roughness={0.8} />
</mesh>
<mesh position={[0, -0.085, 0]}>
<boxGeometry args={[0.2, 0.035, 0.052]} />
<meshStandardMaterial color="#a77d37" metalness={0.54} roughness={0.36} />
</mesh>
<mesh position={[0, -0.31, 0]}>
<boxGeometry args={[0.07, 0.42, 0.024]} />
<meshStandardMaterial color="#c3ccd2" metalness={0.86} roughness={0.22} />
</mesh>
<mesh position={[0, -0.56, 0]} rotation={[0, 0, Math.PI]}>
<coneGeometry args={[0.05, 0.11, 4]} />
<meshStandardMaterial color="#d9e0e4" metalness={0.88} roughness={0.18} />
</mesh>
</group>
);
}
function MaceVisual() {
return (
<group name="starter-mace-geometry">
<mesh position={[0, -0.27, 0]}>
<cylinderGeometry args={[0.03, 0.035, 0.72, 8]} />
<meshStandardMaterial color="#5c3823" roughness={0.76} />
</mesh>
<mesh position={[0, -0.69, 0]}>
<dodecahedronGeometry args={[0.17, 0]} />
<meshStandardMaterial color="#8f979b" metalness={0.72} roughness={0.34} />
</mesh>
<mesh position={[0, 0.13, 0]}>
<sphereGeometry args={[0.052, 8, 6]} />
<meshStandardMaterial color="#b78a3d" metalness={0.48} roughness={0.4} />
</mesh>
</group>
);
}
function StaffVisual() {
return (
<group name="starter-staff-geometry">
<mesh position={[0, -0.05, 0]}>
<cylinderGeometry args={[0.035, 0.045, 1.75, 8]} />
<meshStandardMaterial color="#5a3a24" roughness={0.9} />
</mesh>
<mesh position={[0, 0.88, 0]}>
<torusGeometry args={[0.15, 0.035, 7, 18]} />
<meshStandardMaterial color="#8f6a34" metalness={0.26} roughness={0.58} />
</mesh>
<mesh position={[0, 0.88, 0]}>
<octahedronGeometry args={[0.095, 0]} />
<meshStandardMaterial
color="#74b9d1"
emissive="#1b6178"
emissiveIntensity={0.55}
metalness={0.16}
roughness={0.24}
/>
</mesh>
</group>
);
}
function BowVisual() {
return (
<group name="starter-bow-geometry">
<mesh position={[0, 0.25, -0.1]} rotation={[0.43, 0, 0]}>
<cylinderGeometry args={[0.025, 0.034, 0.58, 7]} />
<meshStandardMaterial color="#8a5a2d" roughness={0.82} />
</mesh>
<mesh position={[0, -0.25, -0.1]} rotation={[-0.43, 0, 0]}>
<cylinderGeometry args={[0.034, 0.025, 0.58, 7]} />
<meshStandardMaterial color="#8a5a2d" roughness={0.82} />
</mesh>
<mesh position={[0, 0, -0.005]}>
<cylinderGeometry args={[0.006, 0.006, 1.03, 5]} />
<meshStandardMaterial color="#d8d0b3" roughness={0.72} />
</mesh>
<mesh position={[0, 0, -0.16]}>
<cylinderGeometry args={[0.032, 0.032, 0.18, 7]} />
<meshStandardMaterial color="#4f321f" roughness={0.86} />
</mesh>
</group>
);
}
function ShieldVisual() {
return (
<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, 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, 0, -0.045]}>
<torusGeometry args={[0.3, 0.035, 6, 16]} />
<meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} />
</mesh>
</group>
);
}
function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) {
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 {
readonly attackPulse?: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly baseRotation?: StarterWeaponRotation;
readonly hand: AvatarHand;
readonly kind: StarterWeaponKind;
readonly name?: string;
readonly scale?: number;
}
/** Canonical grip-at-origin equipment visual for use inside a hand bone. */
export function StarterWeaponVisual({
attackPulse = 0,
attackPulseRef,
baseRotation = ZERO_ROTATION,
hand,
kind,
name = `starter-${kind}`,
scale = 1,
}: StarterWeaponVisualProps) {
const rootRef = useRef<Group>(null);
const previousPulseRef = useRef(attackPulseRef?.current ?? attackPulse);
const strikeElapsedRef = useRef(Number.POSITIVE_INFINITY);
useFrame((_, delta) => {
const root = rootRef.current;
if (!root) return;
const currentPulse = attackPulseRef?.current ?? attackPulse;
if (currentPulse !== previousPulseRef.current) strikeElapsedRef.current = 0;
previousPulseRef.current = currentPulse;
strikeElapsedRef.current += Math.max(0, Math.min(delta, 0.05));
const sampled = sampleStarterWeaponStrike(kind, hand, strikeElapsedRef.current);
root.rotation.set(
baseRotation[0] + sampled[0],
baseRotation[1] + sampled[1],
baseRotation[2] + sampled[2],
);
});
return (
<group ref={rootRef} name={name} rotation={baseRotation} scale={scale}>
<WeaponGeometry kind={kind} />
</group>
);
}
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(
<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;
readonly scale?: number;
}
/**
* Portals a class loadout into the animated hand bones of a cloned character
* scene. Missing hands are skipped safely; use ApproximateStarterEquipment for
* a loading/error fallback model.
*/
export function StarterWeaponAttachments({
attackPulse = 0,
attackPulseRef,
classId,
entry,
occupiedHands = [],
onlyHands,
root,
scale,
}: StarterWeaponAttachmentsProps): ReactNode {
const hands = useMemo(() => findAvatarHandBones(root), [root]);
const equipmentScale = useMemo(
() => scale ?? estimateAvatarWeaponScale(root, hands),
[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 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}
socketRotation={avatarWeaponSocketRotation(entry, equipped.kind, stance)}
/>
);
});
}
export interface ApproximateStarterEquipmentProps {
readonly attackPulse?: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly classId: ClassId;
readonly handPositions?: Readonly<Record<AvatarHand, readonly [number, number, number]>>;
readonly scale?: number;
}
const DEFAULT_APPROXIMATE_HAND_POSITIONS: Readonly<
Record<AvatarHand, readonly [number, number, number]>
> = Object.freeze({
left: Object.freeze([0.39, 1.02, 0] as const),
right: Object.freeze([-0.39, 1.02, 0] as const),
});
/** Equipment placement for ProceduralAvatar and asset-loading error states. */
export function ApproximateStarterEquipment({
attackPulse = 0,
attackPulseRef,
classId,
handPositions = DEFAULT_APPROXIMATE_HAND_POSITIONS,
scale = 1,
}: ApproximateStarterEquipmentProps) {
const loadout = starterWeaponLoadout(classId);
return (
<group name="approximate-starter-equipment">
{loadout.items.map((equipped) => (
<group
key={equipped.id}
position={handPositions[equipped.hand]}
rotation={approximateStarterWeaponGripRotation(equipped.kind)}
>
<StarterWeaponVisual
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
hand={equipped.hand}
kind={equipped.kind}
name={`approximate-starter-equipment-${equipped.id}`}
scale={scale * equipped.scale}
/>
</group>
))}
</group>
);
}