Release Healer Man 0.1.9

This commit is contained in:
phenom committed 2026-08-30 14:16:56 -04:00
1 parent c245a199b6
commit d060296ade
74 files changed
+5901 -334

No files matched your search

+228 -27
View File
@@ -7,6 +7,10 @@ import {
type BaseClassId,
type ClassId,
} from "../app/characterCatalog";
import type { CharacterWeaponStance } from "../game/combatAnimation";
import type { InventoryItem } from "../game/lootTypes";
import { resolveAvatarAttachmentPoint } from "./attachmentPoints";
import type { AvatarAttachmentPoint, AvatarManifestEntry } from "./characterManifest";
export type AvatarHand = "left" | "right";
@@ -28,8 +32,11 @@ export interface StarterWeaponItem {
export interface StarterWeaponLoadout {
readonly classId: ClassId;
readonly items: readonly StarterWeaponItem[];
readonly stance: StarterWeaponStance;
}
export type StarterWeaponStance = CharacterWeaponStance;
const item = (
id: string,
kind: StarterWeaponKind,
@@ -45,6 +52,7 @@ const item = (
const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLoadout>> = Object.freeze({
warrior: Object.freeze({
classId: "warrior",
stance: "one-handed",
items: Object.freeze([
item("warrior-sword", "sword", "right", 1.04),
item("warrior-shield", "shield", "left", 0.96),
@@ -52,6 +60,7 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
paladin: Object.freeze({
classId: "paladin",
stance: "one-handed",
items: Object.freeze([
item("paladin-mace", "mace", "right", 1.04),
item("paladin-shield", "shield", "left", 0.98),
@@ -59,10 +68,12 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
hunter: Object.freeze({
classId: "hunter",
stance: "bow",
items: Object.freeze([item("hunter-bow", "bow", "left", 1.08)]),
}),
rogue: Object.freeze({
classId: "rogue",
stance: "one-handed",
items: Object.freeze([
item("rogue-main-dagger", "dagger", "right", 0.94),
item("rogue-off-dagger", "dagger", "left", 0.94),
@@ -70,14 +81,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
priest: Object.freeze({
classId: "priest",
stance: "two-handed-left",
items: Object.freeze([item("priest-staff", "staff", "right", 0.96)]),
}),
"death-knight": Object.freeze({
classId: "death-knight",
stance: "two-handed",
items: Object.freeze([item("death-knight-runeblade", "sword", "right", 1.18)]),
}),
shaman: Object.freeze({
classId: "shaman",
stance: "one-handed",
items: Object.freeze([
item("shaman-mace", "mace", "right", 1),
item("shaman-shield", "shield", "left", 0.9),
@@ -85,14 +99,17 @@ const BASE_STARTER_WEAPON_LOADOUTS: Readonly<Record<BaseClassId, StarterWeaponLo
}),
mage: Object.freeze({
classId: "mage",
stance: "two-handed-left",
items: Object.freeze([item("mage-staff", "staff", "right", 0.94)]),
}),
warlock: Object.freeze({
classId: "warlock",
stance: "two-handed-left",
items: Object.freeze([item("warlock-staff", "staff", "right", 1)]),
}),
druid: Object.freeze({
classId: "druid",
stance: "two-handed-left",
items: Object.freeze([item("druid-staff", "staff", "right", 1.02)]),
}),
});
@@ -103,6 +120,7 @@ export const STARTER_WEAPON_LOADOUTS: Readonly<Record<ClassId, StarterWeaponLoad
return [characterClass.id, Object.freeze({
classId: characterClass.id,
items: source.items,
stance: source.stance,
})];
})) as unknown as Record<ClassId, StarterWeaponLoadout>,
);
@@ -111,6 +129,30 @@ export function starterWeaponLoadout(classId: ClassId): StarterWeaponLoadout {
return STARTER_WEAPON_LOADOUTS[classId];
}
export function starterWeaponStance(classId: ClassId): StarterWeaponStance {
return starterWeaponLoadout(classId).stance;
}
type VisibleWeaponItem = Pick<InventoryItem, "inventorySlot" | "itemId" | "weaponType">;
/** Selects a ready pose from the weapon models that are actually being rendered. */
export function characterWeaponStance(
classId: ClassId,
equipment: readonly VisibleWeaponItem[] = [],
visibleWeaponItemIds: readonly number[] = [],
): StarterWeaponStance {
const visibleIds = new Set(visibleWeaponItemIds);
const visibleWeapons = equipment.filter((equipped) => (
equipped.weaponType && visibleIds.has(equipped.itemId)
));
if (visibleWeapons.some((equipped) => equipped.weaponType === "bow")) return "bow";
const weapon = visibleWeapons.find((equipped) => equipped.inventorySlot !== "ranged")
?? visibleWeapons[0];
if (!weapon) return starterWeaponStance(classId);
if (weapon.weaponType === "staff") return "two-handed-left";
return weapon.inventorySlot === "two-hand" ? "two-handed" : "one-handed";
}
export function starterWeaponItemsForAvailableHands(
classId: ClassId,
occupiedHands: readonly AvatarHand[] = [],
@@ -124,6 +166,13 @@ const FINGER_PARENT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freez
right: /(?:Index|Middle|Ring|Pinky)FingerR_p$/i,
});
const EXPLICIT_HAND_PIVOT_PATTERN: Readonly<Record<AvatarHand, RegExp>> = Object.freeze({
left: /^p_left_hand$/i,
right: /^p_right_hand$/i,
});
const EXPLICIT_SHIELD_PIVOT_PATTERN = /^p_hand_shield$/i;
const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Object.freeze({
left: Object.freeze([
/(?:^|[_ .-])L Hand$/i,
@@ -142,11 +191,19 @@ const NAMED_HAND_PATTERN: Readonly<Record<AvatarHand, readonly RegExp[]>> = Obje
});
/**
* Finds a playable-character hand without relying on exporter-specific numeric
* bone ids. WoW's named finger pivot nodes survive every shipped race/gender
* export, and their shared parent is the animated hand bone.
* Finds a playable-character grip without relying on exporter-specific numeric
* bone ids. RuneWaker's explicit link-frame locator wins when present. WoW's
* named finger pivots otherwise identify their shared animated hand bone.
*/
export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D | null {
let explicitPivot: Object3D | null = null;
root.traverse((node) => {
if (!explicitPivot && EXPLICIT_HAND_PIVOT_PATTERN[hand].test(node.name)) {
explicitPivot = node;
}
});
if (explicitPivot) return explicitPivot;
const votes = new Map<Object3D, number>();
root.traverse((node) => {
if (!FINGER_PARENT_PATTERN[hand].test(node.name) || !node.parent) return;
@@ -171,6 +228,16 @@ export function findAvatarHandBone(root: Object3D, hand: AvatarHand): Object3D |
return namedFallback;
}
export function findAvatarShieldBone(root: Object3D): Object3D | null {
let explicitPivot: Object3D | null = null;
root.traverse((node) => {
if (!explicitPivot && EXPLICIT_SHIELD_PIVOT_PATTERN.test(node.name)) {
explicitPivot = node;
}
});
return explicitPivot ?? findAvatarHandBone(root, "left");
}
export interface AvatarHandBones {
readonly left: Object3D | null;
readonly right: Object3D | null;
@@ -206,6 +273,67 @@ export function estimateAvatarWeaponScale(
export type StarterWeaponRotation = readonly [x: number, y: number, z: number];
const ZERO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
const STARTER_WEAPON_MODEL_ROTATIONS: Readonly<
Record<StarterWeaponKind, StarterWeaponRotation>
> = Object.freeze({
bow: Object.freeze([Math.PI / 2, 0, -Math.PI / 2] as const),
dagger: Object.freeze([0, 0, Math.PI / 2] as const),
mace: Object.freeze([0, 0, Math.PI / 2] as const),
shield: ZERO_ROTATION,
staff: Object.freeze([0, 0, -Math.PI / 2] as const),
sword: Object.freeze([0, 0, Math.PI / 2] as const),
});
const APPROXIMATE_WEAPON_GRIP_ROTATIONS: Readonly<
Record<StarterWeaponKind, StarterWeaponRotation>
> = Object.freeze({
bow: Object.freeze([0, 0, Math.PI / 2] as const),
dagger: Object.freeze([0, 0, -Math.PI / 2] as const),
mace: Object.freeze([0, 0, -Math.PI / 2] as const),
shield: ZERO_ROTATION,
staff: Object.freeze([0, 0, Math.PI / 2] as const),
sword: Object.freeze([0, 0, -Math.PI / 2] as const),
});
const RUNEWAKER_ONE_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
0,
-Math.PI / 2,
0,
]);
const RUNEWAKER_TWO_HAND_SOCKET_ROTATION: StarterWeaponRotation = Object.freeze([
0,
0,
Math.PI / 2,
]);
/** Normalizes procedural geometry to the same local axes as native item GLBs. */
export function starterWeaponModelRotation(
kind: StarterWeaponKind,
): StarterWeaponRotation {
return STARTER_WEAPON_MODEL_ROTATIONS[kind];
}
/** Synthetic hand pose used only while the animated character rig is absent. */
export function approximateStarterWeaponGripRotation(
kind: StarterWeaponKind,
): StarterWeaponRotation {
return APPROXIMATE_WEAPON_GRIP_ROTATIONS[kind];
}
/** Adapts the canonical WoW +X item frame to a rig's authored prop socket. */
export function avatarWeaponSocketRotation(
entry: AvatarManifestEntry | undefined,
kind: StarterWeaponKind,
stance: StarterWeaponStance,
): StarterWeaponRotation {
if (entry?.rendererStrategy?.kind !== "runewaker-modular" || kind === "shield") {
return ZERO_ROTATION;
}
return stance === "two-handed" || stance === "two-handed-left"
? RUNEWAKER_TWO_HAND_SOCKET_ROTATION
: RUNEWAKER_ONE_HAND_SOCKET_ROTATION;
}
const STRIKE_DURATION_SECONDS = 0.54;
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
@@ -374,16 +502,16 @@ function BowVisual() {
function ShieldVisual() {
return (
<group name="starter-shield-geometry" position={[-0.08, 0, 0]}>
<mesh rotation={[0, 0, Math.PI / 2]}>
<group name="starter-shield-geometry" position={[0, 0, -0.08]}>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.38, 0.38, 0.07, 12]} />
<meshStandardMaterial color="#6f5635" metalness={0.18} roughness={0.74} />
</mesh>
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
<mesh position={[0, 0, -0.045]} rotation={[Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.12, 0.12, 0.09, 10]} />
<meshStandardMaterial color="#a6afb4" metalness={0.75} roughness={0.31} />
</mesh>
<mesh position={[-0.045, 0, 0]} rotation={[0, 0, Math.PI / 2]}>
<mesh position={[0, 0, -0.045]}>
<torusGeometry args={[0.3, 0.035, 6, 16]} />
<meshStandardMaterial color="#a6afb4" metalness={0.68} roughness={0.35} />
</mesh>
@@ -392,12 +520,22 @@ function ShieldVisual() {
}
function WeaponGeometry({ kind }: { readonly kind: StarterWeaponKind }) {
if (kind === "bow") return <BowVisual />;
if (kind === "dagger") return <DaggerVisual />;
if (kind === "mace") return <MaceVisual />;
if (kind === "shield") return <ShieldVisual />;
if (kind === "staff") return <StaffVisual />;
return <SwordVisual />;
const visual = kind === "bow"
? <BowVisual />
: kind === "dagger"
? <DaggerVisual />
: kind === "mace"
? <MaceVisual />
: kind === "shield"
? <ShieldVisual />
: kind === "staff"
? <StaffVisual />
: <SwordVisual />;
return (
<group rotation={starterWeaponModelRotation(kind)}>
{visual}
</group>
);
}
export interface StarterWeaponVisualProps {
@@ -449,35 +587,88 @@ export function StarterWeaponVisual({
interface AttachedStarterWeaponProps {
readonly attackPulse: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly attachmentScale: Vector3;
readonly item: StarterWeaponItem;
readonly position: Vector3;
readonly scale: number;
readonly socketRotation: StarterWeaponRotation;
readonly target: Object3D;
}
interface ResolvedStarterWeaponProps {
readonly attackPulse: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly fallbackTarget: Object3D | null;
readonly item: StarterWeaponItem;
readonly point?: AvatarAttachmentPoint;
readonly root: Object3D;
readonly scale: number;
readonly socketRotation: StarterWeaponRotation;
}
function AttachedStarterWeapon({
attackPulse,
attackPulseRef,
attachmentScale,
item: equipped,
position,
scale,
socketRotation,
target,
}: AttachedStarterWeaponProps) {
return createPortal(
<StarterWeaponVisual
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
hand={equipped.hand}
kind={equipped.kind}
name={`starter-equipment-${equipped.id}`}
scale={scale * equipped.scale}
/>,
<group position={position} rotation={socketRotation} scale={attachmentScale}>
<StarterWeaponVisual
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
hand={equipped.hand}
kind={equipped.kind}
name={`starter-equipment-${equipped.id}`}
scale={scale * equipped.scale}
/>
</group>,
target,
);
}
function ResolvedStarterWeapon({
attackPulse,
attackPulseRef,
fallbackTarget,
item: equipped,
point,
root,
scale,
socketRotation,
}: ResolvedStarterWeaponProps): ReactNode {
const transform = useMemo(
() => resolveAvatarAttachmentPoint(
root,
point,
fallbackTarget,
{ useTargetOrigin: true },
),
[fallbackTarget, point, root],
);
return transform ? (
<AttachedStarterWeapon
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
attachmentScale={transform.scale}
item={equipped}
position={transform.position}
scale={scale}
socketRotation={socketRotation}
target={transform.target}
/>
) : null;
}
export interface StarterWeaponAttachmentsProps {
readonly attackPulse?: number;
readonly attackPulseRef?: MutableRefObject<number>;
readonly classId: ClassId;
readonly entry?: AvatarManifestEntry;
readonly occupiedHands?: readonly AvatarHand[];
readonly onlyHands?: readonly AvatarHand[];
readonly root: Object3D;
@@ -493,6 +684,7 @@ export function StarterWeaponAttachments({
attackPulse = 0,
attackPulseRef,
classId,
entry,
occupiedHands = [],
onlyHands,
root,
@@ -504,20 +696,29 @@ export function StarterWeaponAttachments({
[hands, root, scale],
);
const allowedHands = onlyHands ? new Set(onlyHands) : null;
const stance = starterWeaponStance(classId);
const items = starterWeaponItemsForAvailableHands(classId, occupiedHands)
.filter((item) => !allowedHands || allowedHands.has(item.hand));
return items.map((equipped) => {
const target = hands[equipped.hand];
return target ? (
<AttachedStarterWeapon
const attachmentName = equipped.kind === "shield"
? "shield"
: equipped.hand === "left" ? "handLeft" : "handRight";
const fallbackTarget = equipped.kind === "shield"
? findAvatarShieldBone(root)
: hands[equipped.hand];
return (
<ResolvedStarterWeapon
key={equipped.id}
attackPulse={attackPulse}
attackPulseRef={attackPulseRef}
fallbackTarget={fallbackTarget}
item={equipped}
point={entry?.attachmentPoints?.[attachmentName]}
root={root}
scale={equipmentScale}
target={target}
socketRotation={avatarWeaponSocketRotation(entry, equipped.kind, stance)}
/>
) : null;
);
});
}
@@ -551,7 +752,7 @@ export function ApproximateStarterEquipment({
<group
key={equipped.id}
position={handPositions[equipped.hand]}
rotation={[0, -Math.PI / 2, 0]}
rotation={approximateStarterWeaponGripRotation(equipped.kind)}
>
<StarterWeaponVisual
attackPulse={attackPulse}