Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -559,6 +559,7 @@ function LoadedCharacterModel({
|
||||
fallbackScale={fallbackEquipmentScale}
|
||||
models={resolved.equipmentModels}
|
||||
transforms={equipmentAttachmentTransforms}
|
||||
onReady={preview ? onReady : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -74,7 +74,7 @@ const selected: CharacterAppearance = {
|
||||
describe("resolveAvatarAppearance", () => {
|
||||
it("composes body layers in WoW replacement-texture order", () => {
|
||||
const resolved = resolveAvatarAppearance(manifest, entry, selected, "priest");
|
||||
expect(resolved.bodyLayers.map((layer) => layer.url)).toEqual([
|
||||
expect(resolved.bodyLayers.slice(0, 9).map((layer) => layer.url)).toEqual([
|
||||
"skin-1",
|
||||
"pelvis-1",
|
||||
"torso-1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveEquipmentAppearance,
|
||||
type EquipmentModelVisual,
|
||||
} from "./equipmentAppearance";
|
||||
import { resolveRunewakerEquipment } from "./runewakerEquipment";
|
||||
|
||||
export interface AvatarTextureLayer {
|
||||
url: string;
|
||||
@@ -59,7 +60,9 @@ export function resolveAvatarAppearance(
|
||||
const featureTextures = tripletAt(feature?.byColor ?? [], hairColorIndex);
|
||||
const regions = manifest.textureLayout.regions;
|
||||
const layers: AvatarTextureLayer[] = [];
|
||||
const equipped = resolveEquipmentAppearance(equipment, entry.gender);
|
||||
const equipped = entry.rendererStrategy?.kind === "runewaker-modular" && classId
|
||||
? resolveRunewakerEquipment(classId, equipment)
|
||||
: resolveEquipmentAppearance(equipment, entry.gender, classId);
|
||||
|
||||
if (skin?.base) addLayer(layers, skin.base, regions.base);
|
||||
addLayer(layers, skin?.underwear?.[0] ?? "", regions.pelvisUpper);
|
||||
|
||||
@@ -13,6 +13,36 @@ function item(itemId: number): InventoryItem {
|
||||
}
|
||||
|
||||
describe("resolveEquipmentAppearance", () => {
|
||||
it("gives roster previews and unmapped heirlooms visible clothing without changing inventory", () => {
|
||||
const heirloom = { itemId: 9_000_001, inventorySlot: "chest", armorType: "cloth" } as InventoryItem;
|
||||
for (const items of [[], [heirloom]]) {
|
||||
const resolved = resolveEquipmentAppearance(items, "female", "priest");
|
||||
expect(resolved.bodyLayers.some((layer) => layer.url.includes("torso-upper-female"))).toBe(true);
|
||||
expect(resolved.bodyLayers.some((layer) => layer.url.includes("foot.png"))).toBe(true);
|
||||
expect(resolved.models.map((model) => model.attachment)).toEqual(["shoulderLeft", "shoulderRight"]);
|
||||
expect(resolved.geosets.Trousers).toBe(2);
|
||||
}
|
||||
expect(heirloom.itemId).toBe(9_000_001);
|
||||
});
|
||||
|
||||
it("keeps real equipped appearances ahead of the class armor defaults", () => {
|
||||
const resolved = resolveEquipmentAppearance([
|
||||
{ itemId: 6473, inventorySlot: "chest", armorType: "leather" } as InventoryItem,
|
||||
], "male", "priest");
|
||||
expect(resolved.bodyLayers.some((layer) => layer.url.includes("6473-armor-of-the-fang"))).toBe(true);
|
||||
expect(resolved.bodyLayers.some((layer) => layer.url.includes("6465-robe"))).toBe(false);
|
||||
expect(resolved.geosets.Trousers).toBe(1);
|
||||
});
|
||||
|
||||
it("distinguishes cloth, leather, and heavy starter chest appearances and their caches", () => {
|
||||
const appearances = ["priest", "rogue", "warrior"] as const;
|
||||
const resolved = appearances.map((classId) => resolveEquipmentAppearance([], "male", classId));
|
||||
expect(resolved[0].bodyLayers.some((layer) => layer.url.includes("6465-robe"))).toBe(true);
|
||||
expect(resolved[1].bodyLayers.some((layer) => layer.url.includes("6473-armor"))).toBe(true);
|
||||
expect(resolved[2].bodyLayers.some((layer) => layer.url.includes("6627-mutant"))).toBe(true);
|
||||
expect(new Set(resolved.map((appearance) => appearance.cacheKey)).size).toBe(3);
|
||||
});
|
||||
|
||||
it("maps equipped loot to its exact client-native model attachment", () => {
|
||||
const resolved = resolveEquipmentAppearance([
|
||||
item(6472),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GenderId } from "../app/characterCatalog";
|
||||
import { classById, type ClassId, type GenderId } from "../app/characterCatalog";
|
||||
import type { CharacterWeaponStance } from "../game/combatAnimation";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { TextureRegion } from "./characterManifest";
|
||||
@@ -36,6 +36,8 @@ 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. */
|
||||
readonly sourceFrame?: "runewaker";
|
||||
}
|
||||
|
||||
interface GenderedTextureLayer {
|
||||
@@ -318,6 +320,7 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
|
||||
export function resolveEquipmentAppearance(
|
||||
items: readonly InventoryItem[],
|
||||
gender: GenderId,
|
||||
classId?: ClassId,
|
||||
): ResolvedEquipmentAppearance {
|
||||
const layers: EquipmentTextureLayer[] = [];
|
||||
const models: EquipmentModelVisual[] = [];
|
||||
@@ -325,8 +328,14 @@ export function resolveEquipmentAppearance(
|
||||
const geosets: Record<string, number> = { Cloak: 0 };
|
||||
let cloakUrl = "";
|
||||
|
||||
const equippedVisuals = items.flatMap((item) => {
|
||||
const visual = EQUIPMENT_VISUALS[item.itemId];
|
||||
const starterArmor = classId ? classById(classId).armor : "cloth";
|
||||
const starterSlots = ["chest", "legs", "feet", "hands", "shoulder", "back"];
|
||||
const representedSlots = new Set(items.map((item) => item.inventorySlot));
|
||||
const starterItems = classId ? starterSlots.filter((slot) => !representedSlots.has(slot))
|
||||
.map((slot) => ({ itemId: 0, inventorySlot: slot, armorType: starterArmor })) : [];
|
||||
const equippedVisuals = [...starterItems, ...items].flatMap((item) => {
|
||||
const visual = EQUIPMENT_VISUALS[item.itemId]
|
||||
?? (classId ? representativeArmorVisual(item.inventorySlot, item.armorType ?? starterArmor) : undefined);
|
||||
return visual ? [{ item, visual }] : [];
|
||||
});
|
||||
|
||||
@@ -347,7 +356,7 @@ export function resolveEquipmentAppearance(
|
||||
}
|
||||
|
||||
return {
|
||||
cacheKey: items.map((item) => item.itemId).join(","),
|
||||
cacheKey: [classId ?? "", ...items.map((item) => `${item.itemId}:${item.inventorySlot}:${item.armorType}`)].join(","),
|
||||
bodyLayers: layers,
|
||||
cloakUrl,
|
||||
geosets,
|
||||
@@ -355,6 +364,19 @@ export function resolveEquipmentAppearance(
|
||||
};
|
||||
}
|
||||
|
||||
/** Visible starter/heirloom armor, superseded by exact packaged item visuals. */
|
||||
function representativeArmorVisual(slot: string, armor: string): EquipmentVisualDefinition | undefined {
|
||||
switch (slot) {
|
||||
case "chest": return EQUIPMENT_VISUALS[armor === "cloth" ? 6465 : armor === "leather" ? 6473 : 6627];
|
||||
case "legs": return EQUIPMENT_VISUALS[10410];
|
||||
case "feet": return EQUIPMENT_VISUALS[10411];
|
||||
case "hands": return EQUIPMENT_VISUALS[5970];
|
||||
case "shoulder": return EQUIPMENT_VISUALS[armor === "cloth" ? 6461 : 5404];
|
||||
case "back": return EQUIPMENT_VISUALS[6632];
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function equipmentModelUrls(): readonly string[] {
|
||||
return Object.values(EQUIPMENT_VISUALS)
|
||||
.flatMap((visual) => visual.models?.map((itemModel) => itemModel.url) ?? []);
|
||||
|
||||
@@ -115,16 +115,24 @@ function AttachedEquipmentModel({
|
||||
entry,
|
||||
itemModel,
|
||||
transform,
|
||||
onReady,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
readonly onReady?: () => void;
|
||||
}): ReactNode {
|
||||
const gltf = useGLTF(resolveContentUrl(itemModel.url));
|
||||
const scene = useMemo(
|
||||
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
|
||||
[gltf.scene, itemModel.attachment, itemModel.itemId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!onReady) return;
|
||||
// Include the ready pose after its initial blend, not the rig's bind-pose bounds.
|
||||
const timer = window.setTimeout(onReady, 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [onReady, scene]);
|
||||
useEffect(() => () => {
|
||||
const materials = new Set<Material>();
|
||||
scene.traverse((node) => {
|
||||
@@ -136,7 +144,7 @@ function AttachedEquipmentModel({
|
||||
materials.forEach((material) => material.dispose());
|
||||
}, [scene]);
|
||||
|
||||
const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
|
||||
const socketRotation = itemModel.sourceFrame !== "runewaker" && itemModel.fallbackKind && itemModel.weaponStance
|
||||
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
|
||||
: NO_ROTATION;
|
||||
|
||||
@@ -192,11 +200,13 @@ function ResolvedEquipmentAttachment({
|
||||
fallbackScale,
|
||||
itemModel,
|
||||
transform,
|
||||
onReady,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly itemModel: EquipmentModelVisual;
|
||||
readonly transform: AvatarAttachmentTransform;
|
||||
readonly onReady?: () => void;
|
||||
}): ReactNode {
|
||||
const fallback = (
|
||||
<EquipmentModelFallback
|
||||
@@ -212,7 +222,7 @@ function ResolvedEquipmentAttachment({
|
||||
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
|
||||
>
|
||||
<Suspense fallback={fallback}>
|
||||
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} />
|
||||
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} onReady={onReady} />
|
||||
</Suspense>
|
||||
</AvatarErrorBoundary>
|
||||
);
|
||||
@@ -223,11 +233,13 @@ export function EquipmentAttachments({
|
||||
fallbackScale,
|
||||
models,
|
||||
transforms,
|
||||
onReady,
|
||||
}: {
|
||||
readonly entry: AvatarManifestEntry;
|
||||
readonly fallbackScale: number;
|
||||
readonly models: readonly EquipmentModelVisual[];
|
||||
readonly transforms: EquipmentAttachmentTransforms;
|
||||
readonly onReady?: () => void;
|
||||
}): ReactNode {
|
||||
return models.map((itemModel) => {
|
||||
const transform = transforms[itemModel.attachment];
|
||||
@@ -238,6 +250,7 @@ export function EquipmentAttachments({
|
||||
fallbackScale={fallbackScale}
|
||||
itemModel={itemModel}
|
||||
transform={transform}
|
||||
onReady={onReady}
|
||||
/>
|
||||
) : null;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLASSES } from "../app/characterCatalog";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { resolveRunewakerEquipment } from "./runewakerEquipment";
|
||||
|
||||
describe("original RoM equipment", () => {
|
||||
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, []);
|
||||
expect(resolved.models.length).toBeGreaterThan(0);
|
||||
expect(resolved.bodyLayers).toEqual([]);
|
||||
for (const model of resolved.models) {
|
||||
expect(model.sourceFrame).toBe("runewaker");
|
||||
expect(model.url).toContain("/equipment/runewaker-v1/");
|
||||
expect(existsSync(resolve("public", model.url.slice(1)))).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the equipped weapon type and avoids applying WoW armor to native RoM UVs", () => {
|
||||
const resolved = resolveRunewakerEquipment("rom-priest", [
|
||||
{ itemId: 123, inventorySlot: "main-hand", weaponType: "mace" } as InventoryItem,
|
||||
{ itemId: 6465, inventorySlot: "chest", armorType: "cloth" } as InventoryItem,
|
||||
]);
|
||||
expect(resolved.models).toEqual([expect.objectContaining({
|
||||
itemId: 123, url: expect.stringContaining("starter-mace.glb"), attachment: "handRight",
|
||||
})]);
|
||||
expect(resolved.bodyLayers).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves original ROS provenance for all six packaged weapon kinds", () => {
|
||||
const imported = JSON.parse(readFileSync("public/assets/equipment/runewaker-v1/import-manifest.json", "utf8"));
|
||||
expect(imported.assets).toHaveLength(6);
|
||||
expect(imported.proceduralFallbacks).toEqual([]);
|
||||
for (const asset of imported.assets) {
|
||||
expect(asset.sourceModel).toMatch(/^model\/item\/weapon\/.+\.ros$/);
|
||||
expect(asset.sourceModelSha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { EquipmentModelVisual, ResolvedEquipmentAppearance } from "./equipmentAppearance";
|
||||
import { starterWeaponLoadout, type StarterWeaponKind } from "./starterWeapons";
|
||||
|
||||
export const RUNEWAKER_STARTER_WEAPON_ROOT = "/assets/equipment/runewaker-v1";
|
||||
|
||||
/** Original client props; the character GLB contains the native worn paperdoll. */
|
||||
export function resolveRunewakerEquipment(
|
||||
classId: ClassId,
|
||||
equipment: readonly InventoryItem[],
|
||||
): ResolvedEquipmentAppearance {
|
||||
const loadout = starterWeaponLoadout(classId);
|
||||
const models: EquipmentModelVisual[] = loadout.items.map((starter, index) => {
|
||||
const equipped = equipment.find((item) => starter.kind === "shield"
|
||||
? item.armorType === "shield"
|
||||
: starter.kind === "bow"
|
||||
? item.weaponType === "bow" || item.weaponType === "crossbow"
|
||||
: starter.hand === "left"
|
||||
? item.inventorySlot === "off-hand" && Boolean(item.weaponType)
|
||||
: ["main-hand", "one-hand", "two-hand"].includes(item.inventorySlot));
|
||||
const weaponType = equipped?.weaponType;
|
||||
const kind: StarterWeaponKind = starter.kind === "shield" ? "shield"
|
||||
: weaponType === "staff" ? "staff"
|
||||
: weaponType === "dagger" ? "dagger"
|
||||
: weaponType === "mace" || weaponType === "hammer" ? "mace"
|
||||
: weaponType === "sword" || weaponType === "axe" ? "sword"
|
||||
: starter.kind;
|
||||
return {
|
||||
itemId: equipped?.itemId ?? -(index + 1),
|
||||
name: `RoM starter ${kind}`,
|
||||
url: `${RUNEWAKER_STARTER_WEAPON_ROOT}/starter-${kind}.glb`,
|
||||
attachment: kind === "shield" ? "shield" : starter.hand === "left" ? "handLeft" : "handRight",
|
||||
fallbackKind: kind,
|
||||
weaponStance: kind === "staff" ? "two-handed-left" : kind === "bow" ? "bow" : loadout.stance,
|
||||
sourceFrame: "runewaker",
|
||||
};
|
||||
});
|
||||
return {
|
||||
cacheKey: `${classId}:${models.map((model) => `${model.itemId}:${model.url}`).join(",")}`,
|
||||
bodyLayers: [],
|
||||
cloakUrl: "",
|
||||
geosets: {},
|
||||
models,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user