778 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|