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
+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