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> = 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> = 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, ); export function starterWeaponLoadout(classId: ClassId): StarterWeaponLoadout { return STARTER_WEAPON_LOADOUTS[classId]; } export function starterWeaponStance(classId: ClassId): StarterWeaponStance { return starterWeaponLoadout(classId).stance; } type VisibleWeaponItem = Pick; /** 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> = Object.freeze({ left: /(?:Index|Middle|Ring|Pinky)FingerL_p$/i, right: /(?:Index|Middle|Ring|Pinky)FingerR_p$/i, }); const EXPLICIT_HAND_PIVOT_PATTERN: Readonly> = 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> = 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(); 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 > = 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 > = 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 ( ); } function DaggerVisual() { return ( ); } function MaceVisual() { return ( ); } function StaffVisual() { return ( ); } function BowVisual() { return ( ); } function ShieldVisual() { return ( ); } function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) { const visual = kind === "bow" ? : kind === "dagger" ? : kind === "mace" ? : kind === "shield" ? : kind === "staff" ? : ; return ( {visual} ); } export interface StarterWeaponVisualProps { readonly attackPulse?: number; readonly attackPulseRef?: MutableRefObject; 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(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 ( ); } interface AttachedStarterWeaponProps { readonly attackPulse: number; readonly attackPulseRef?: MutableRefObject; 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; 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( , 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 ? ( ) : null; } export interface StarterWeaponAttachmentsProps { readonly attackPulse?: number; readonly attackPulseRef?: MutableRefObject; 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 ( ); }); } export interface ApproximateStarterEquipmentProps { readonly attackPulse?: number; readonly attackPulseRef?: MutableRefObject; readonly classId: ClassId; readonly handPositions?: Readonly>; readonly scale?: number; } const DEFAULT_APPROXIMATE_HAND_POSITIONS: Readonly< Record > = 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 ( {loadout.items.map((equipped) => ( ))} ); }