Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 22:51:21 -04:00
1 parent 6cc1b9217f
commit 6114a2295f
67 files changed
+6241 -347

No files matched your search

+9
View File
@@ -72,6 +72,15 @@ const selected: CharacterAppearance = {
};
describe("resolveAvatarAppearance", () => {
it("keeps authentic RoM weapons and clothing when the optional native body falls back to WoW", () => {
const resolved = resolveAvatarAppearance(manifest, entry, selected, "rom-scout");
expect(resolved.equipmentModels.filter((model) => model.attachment === "handLeft")).toEqual([
expect.objectContaining({ sourceFrame: "runewaker", url: expect.stringContaining("starter-bow.glb") }),
]);
expect(resolved.bodyLayers.length).toBeGreaterThan(9);
expect(resolved.equipmentModels.some((model) => model.attachment === "shoulderLeft")).toBe(true);
});
it("composes body layers in WoW replacement-texture order", () => {
const resolved = resolveAvatarAppearance(manifest, entry, selected, "priest");
expect(resolved.bodyLayers.slice(0, 9).map((layer) => layer.url)).toEqual([
+15 -2
View File
@@ -1,4 +1,4 @@
import type { CharacterAppearance, ClassId } from "../app/characterCatalog";
import { isRomClassId, type CharacterAppearance, type ClassId } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import type {
AvatarManifest,
@@ -60,9 +60,22 @@ export function resolveAvatarAppearance(
const featureTextures = tripletAt(feature?.byColor ?? [], hairColorIndex);
const regions = manifest.textureLayout.regions;
const layers: AvatarTextureLayer[] = [];
const equipped = entry.rendererStrategy?.kind === "runewaker-modular" && classId
const nativeBody = entry.rendererStrategy?.kind === "runewaker-modular";
const bodyEquipment = nativeBody && classId
? resolveRunewakerEquipment(classId, equipment)
: resolveEquipmentAppearance(equipment, entry.gender, classId);
// Class identity survives the optional character pack's basic-human fallback.
const romWeapons = classId && isRomClassId(classId)
? resolveRunewakerEquipment(classId, equipment)
: null;
const equipped = romWeapons && !nativeBody ? {
...bodyEquipment,
cacheKey: `${bodyEquipment.cacheKey}:${romWeapons.cacheKey}`,
models: [
...bodyEquipment.models.filter((model) => model.attachment === "shoulderLeft" || model.attachment === "shoulderRight"),
...romWeapons.models,
],
} : bodyEquipment;
if (skin?.base) addLayer(layers, skin.base, regions.base);
addLayer(layers, skin?.underwear?.[0] ?? "", regions.pelvisUpper);
+1 -1
View File
@@ -36,7 +36,7 @@ export interface EquipmentModelVisual {
readonly attachment: EquipmentAttachmentName;
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
readonly weaponStance?: CharacterWeaponStance;
/** Native RoM props already use the authored RoM socket frame. */
/** Original RoM prop frame, adapted separately from the body's socket frame. */
readonly sourceFrame?: "runewaker";
}
+33 -4
View File
@@ -28,6 +28,7 @@ import {
avatarWeaponSocketRotation,
findAvatarHandBone,
findAvatarShieldBone,
runewakerWeaponSocketRotation,
StarterWeaponVisual,
type AvatarHand,
type StarterWeaponRotation,
@@ -42,6 +43,34 @@ const EQUIPMENT_ATTACHMENT_NAMES: readonly EquipmentAttachmentName[] = Object.fr
]);
const NO_ROTATION: StarterWeaponRotation = Object.freeze([0, 0, 0]);
/** Native props need the inverse socket adaptation when the optional rig is absent. */
export function equipmentWeaponSocketRotation(
entry: AvatarManifestEntry,
itemModel: EquipmentModelVisual,
): StarterWeaponRotation {
if (!itemModel.fallbackKind || !itemModel.weaponStance) return NO_ROTATION;
if (itemModel.sourceFrame !== "runewaker") {
return avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance);
}
if (entry.rendererStrategy?.kind === "runewaker-modular") return NO_ROTATION;
const nativeRotation = runewakerWeaponSocketRotation(
itemModel.fallbackKind,
itemModel.weaponStance,
);
return [-nativeRotation[0], -nativeRotation[1], -nativeRotation[2]];
}
export function equipmentWeaponModelRotation(
entry: AvatarManifestEntry,
itemModel: EquipmentModelVisual,
): StarterWeaponRotation {
// The original bow is Y-long; the basic human bow socket expects the adapted Z axis.
return itemModel.sourceFrame === "runewaker" && itemModel.fallbackKind === "bow"
&& entry.rendererStrategy?.kind !== "runewaker-modular"
? [Math.PI / 2, 0, 0]
: NO_ROTATION;
}
export type EquipmentAttachmentTransforms = Readonly<
Partial<Record<EquipmentAttachmentName, AvatarAttachmentTransform>>
>;
@@ -144,9 +173,7 @@ function AttachedEquipmentModel({
materials.forEach((material) => material.dispose());
}, [scene]);
const socketRotation = itemModel.sourceFrame !== "runewaker" && itemModel.fallbackKind && itemModel.weaponStance
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
: NO_ROTATION;
const socketRotation = equipmentWeaponSocketRotation(entry, itemModel);
return createPortal(
<group
@@ -154,7 +181,9 @@ function AttachedEquipmentModel({
rotation={socketRotation}
scale={transform.scale}
>
<primitive object={scene} />
<group rotation={equipmentWeaponModelRotation(entry, itemModel)}>
<primitive object={scene} />
</group>
</group>,
transform.target,
);
+34
View File
@@ -1,11 +1,45 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { Euler, Quaternion, Vector3 } from "three";
import { CLASSES } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import { resolveRunewakerEquipment } from "./runewakerEquipment";
import { equipmentWeaponModelRotation, equipmentWeaponSocketRotation } from "./equipmentAttachments";
import { avatarWeaponSocketRotation } from "./starterWeapons";
import type { AvatarManifest } from "./characterManifest";
describe("original RoM equipment", () => {
it("aligns the original bow to the basic body's grip without reversing its native facing", () => {
const native = (JSON.parse(readFileSync("public/assets/characters/runewaker-v1/manifest.json", "utf8")) as AvatarManifest).entries["rom-human:male"];
const basic = { ...native, rendererStrategy: undefined };
const bow = resolveRunewakerEquipment("rom-scout", []).models[0];
expect(equipmentWeaponModelRotation(native, bow)).toEqual([0, 0, 0]);
const axis = new Vector3(0, 1, 0)
.applyEuler(new Euler(...equipmentWeaponModelRotation(basic, bow)))
.applyEuler(new Euler(...equipmentWeaponSocketRotation(basic, bow)));
expect(axis.distanceTo(new Vector3(1, 0, 0))).toBeLessThan(1e-6);
const stringSide = new Vector3(0, 0, -1).applyEuler(new Euler(...equipmentWeaponModelRotation(basic, bow)));
expect(stringSide.y).toBeCloseTo(1);
for (const model of resolveRunewakerEquipment("rom-knight", []).models) {
expect(equipmentWeaponModelRotation(native, model)).toEqual([0, 0, 0]);
expect(equipmentWeaponModelRotation(basic, model)).toEqual([0, 0, 0]);
}
});
it("converts native props back to the basic body's socket frame when its pack is absent", () => {
const native = (JSON.parse(readFileSync("public/assets/characters/runewaker-v1/manifest.json", "utf8")) as AvatarManifest).entries["rom-human:male"];
const basic = { ...native, rendererStrategy: undefined };
for (const classId of ["rom-knight", "rom-mage", "rom-scout", "rom-rogue"] as const) {
for (const model of resolveRunewakerEquipment(classId, []).models) {
expect(equipmentWeaponSocketRotation(native, model)).toEqual([0, 0, 0]);
const nativeRotation = new Quaternion().setFromEuler(new Euler(...avatarWeaponSocketRotation(native, model.fallbackKind!, model.weaponStance!)));
const fallbackRotation = new Quaternion().setFromEuler(new Euler(...equipmentWeaponSocketRotation(basic, model)));
expect(nativeRotation.multiply(fallbackRotation).angleTo(new Quaternion())).toBeLessThan(1e-6);
}
}
});
it("equips every RoM class with source-client props in its native socket frame", () => {
for (const characterClass of CLASSES.filter((entry) => entry.id.startsWith("rom-"))) {
const resolved = resolveRunewakerEquipment(characterClass.id, []);
+8
View File
@@ -329,6 +329,14 @@ export function avatarWeaponSocketRotation(
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;