Files
healer-man/src/avatar/equipmentAppearance.ts
T
2026-08-30 14:16:56 -04:00

376 lines
10 KiB
TypeScript

import type { GenderId } from "../app/characterCatalog";
import type { CharacterWeaponStance } from "../game/combatAnimation";
import type { InventoryItem } from "../game/lootTypes";
import type { TextureRegion } from "./characterManifest";
const ASSET_ROOT = "/assets/equipment/wow335a-v1";
const TEXTURE_ROOT = `${ASSET_ROOT}/textures`;
const ATLAS_REGIONS = Object.freeze({
armUpper: [0, 0, 256, 128] as const,
armLower: [0, 128, 256, 64] as const,
hand: [0, 192, 256, 64] as const,
torsoUpper: [256, 0, 256, 128] as const,
torsoLower: [256, 128, 256, 64] as const,
legUpper: [256, 192, 256, 128] as const,
legLower: [256, 320, 256, 64] as const,
foot: [256, 384, 256, 128] as const,
} satisfies Readonly<Record<string, TextureRegion>>);
export type EquipmentAttachmentName =
| "shield"
| "handRight"
| "handLeft"
| "shoulderRight"
| "shoulderLeft";
export interface EquipmentTextureLayer {
readonly url: string;
readonly region: TextureRegion;
}
export interface EquipmentModelVisual {
readonly itemId: number;
readonly name: string;
readonly url: string;
readonly attachment: EquipmentAttachmentName;
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
readonly weaponStance?: CharacterWeaponStance;
}
interface GenderedTextureLayer {
readonly region: TextureRegion;
readonly url?: string;
readonly male?: string;
readonly female?: string;
}
interface EquipmentVisualDefinition {
readonly layers?: readonly GenderedTextureLayer[];
readonly layerOrder?: number;
readonly geosets?: Readonly<Record<string, number>>;
readonly cloakUrl?: string;
readonly models?: readonly Omit<EquipmentModelVisual, "itemId">[];
}
export interface ResolvedEquipmentAppearance {
readonly cacheKey: string;
readonly bodyLayers: readonly EquipmentTextureLayer[];
readonly cloakUrl: string;
readonly geosets: Readonly<Record<string, number>>;
readonly models: readonly EquipmentModelVisual[];
}
const texture = (path: string): string => `${TEXTURE_ROOT}/${path}`;
const model = (path: string): string => `${ASSET_ROOT}/${path}/${path}.glb`;
const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = Object.freeze({
5243: {
models: [{
name: "Firebelcher",
url: model("firebelcher-5243"),
attachment: "handRight",
fallbackKind: "mace",
weaponStance: "one-handed",
}],
},
5404: {
models: [
{
name: "Serpent's Shoulders (left)",
url: model("serpents-shoulders-5404-left"),
attachment: "shoulderLeft",
},
{
name: "Serpent's Shoulders (right)",
url: model("serpents-shoulders-5404-right"),
attachment: "shoulderRight",
},
],
},
5970: {
layers: [
{
url: texture("5970-serpent-gloves/arm-lower.png"),
region: ATLAS_REGIONS.armLower,
},
{
url: texture("5970-serpent-gloves/hand.png"),
region: ATLAS_REGIONS.hand,
},
],
layerOrder: 20,
geosets: { Gloves: 2 },
},
6447: {
models: [{
name: "Worn Turtle Shell Shield",
url: model("worn-turtle-shell-shield-6447"),
attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}],
},
6448: {
models: [{
name: "Tail Spike",
url: model("tail-spike-6448"),
attachment: "handRight",
fallbackKind: "dagger",
weaponStance: "one-handed",
}],
},
6449: {
cloakUrl: texture("6449-glowing-lizardscale-cloak/cape.png"),
geosets: { Cloak: 6 },
},
6459: {
layers: [
{
url: texture("6459-savage-trodders/leg-lower.png"),
region: ATLAS_REGIONS.legLower,
},
{
url: texture("6459-savage-trodders/foot.png"),
region: ATLAS_REGIONS.foot,
},
],
layerOrder: 11,
geosets: { Boots: 1 },
},
6460: {
layers: [{
url: texture("6460-cobrahns-grasp/leg-upper.png"),
region: ATLAS_REGIONS.legUpper,
}],
layerOrder: 18,
geosets: { Belt: 1 },
},
6461: {
models: [
{
name: "Slime-Encrusted Pads (left)",
url: model("slime-encrusted-pads-6461-left"),
attachment: "shoulderLeft",
},
{
name: "Slime-Encrusted Pads (right)",
url: model("slime-encrusted-pads-6461-right"),
attachment: "shoulderRight",
},
],
},
6465: {
layers: [
{
url: texture("6465-robe-of-the-moccasin/arm-upper.png"),
region: ATLAS_REGIONS.armUpper,
},
{
url: texture("6465-robe-of-the-moccasin/arm-lower.png"),
region: ATLAS_REGIONS.armLower,
},
{
male: texture("6465-robe-of-the-moccasin/torso-upper-male.png"),
female: texture("6465-robe-of-the-moccasin/torso-upper-female.png"),
region: ATLAS_REGIONS.torsoUpper,
},
{
male: texture("6465-robe-of-the-moccasin/torso-lower-male.png"),
female: texture("6465-robe-of-the-moccasin/torso-lower-female.png"),
region: ATLAS_REGIONS.torsoLower,
},
{
url: texture("6465-robe-of-the-moccasin/leg-upper.png"),
region: ATLAS_REGIONS.legUpper,
},
{
url: texture("6465-robe-of-the-moccasin/leg-lower.png"),
region: ATLAS_REGIONS.legLower,
},
],
layerOrder: 13,
geosets: { Sleeves: 3, Chest: 1, Trousers: 2 },
},
6469: {
models: [{
name: "Venomstrike",
url: model("venomstrike-6469"),
attachment: "handLeft",
fallbackKind: "bow",
weaponStance: "bow",
}],
},
6472: {
models: [{
name: "Stinging Viper",
url: model("stinging-viper-6472"),
attachment: "handRight",
fallbackKind: "mace",
weaponStance: "one-handed",
}],
},
6473: {
layers: [
{
url: texture("6473-armor-of-the-fang/torso-upper.png"),
region: ATLAS_REGIONS.torsoUpper,
},
{
url: texture("6473-armor-of-the-fang/torso-lower.png"),
region: ATLAS_REGIONS.torsoLower,
},
],
layerOrder: 13,
geosets: { Sleeves: 1, Chest: 1, Trousers: 1 },
},
6627: {
layers: [
{
url: texture("6627-mutant-scale-breastplate/arm-upper.png"),
region: ATLAS_REGIONS.armUpper,
},
{
url: texture("6627-mutant-scale-breastplate/torso-upper.png"),
region: ATLAS_REGIONS.torsoUpper,
},
{
url: texture("6627-mutant-scale-breastplate/torso-lower.png"),
region: ATLAS_REGIONS.torsoLower,
},
],
layerOrder: 13,
geosets: { Sleeves: 1, Chest: 1, Trousers: 1 },
},
6629: {
cloakUrl: texture("6629-sporid-cape/cape.png"),
geosets: { Cloak: 3 },
},
6630: {
models: [{
name: "Seedcloud Buckler",
url: model("seedcloud-buckler-6630"),
attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}],
},
6631: {
models: [{
name: "Living Root",
url: model("living-root-6631"),
attachment: "handRight",
fallbackKind: "staff",
weaponStance: "two-handed-left",
}],
},
6632: {
cloakUrl: texture("6632-feyscale-cloak/cape.png"),
geosets: { Cloak: 6 },
},
10410: {
layers: [
{
url: texture("10410-leggings-of-the-fang/leg-upper.png"),
region: ATLAS_REGIONS.legUpper,
},
{
url: texture("10410-leggings-of-the-fang/leg-lower.png"),
region: ATLAS_REGIONS.legLower,
},
],
layerOrder: 10,
geosets: { Pants: 1, Kneepads: 1, Trousers: 1 },
},
10411: {
layers: [
{
url: texture("10411-footpads-of-the-fang/leg-lower.png"),
region: ATLAS_REGIONS.legLower,
},
{
url: texture("10411-footpads-of-the-fang/foot.png"),
region: ATLAS_REGIONS.foot,
},
],
layerOrder: 11,
geosets: { Boots: 1 },
},
10412: {
layers: [{
url: texture("10412-belt-of-the-fang/leg-upper.png"),
region: ATLAS_REGIONS.legUpper,
}],
layerOrder: 18,
geosets: { Belt: 1 },
},
13245: {
models: [{
name: "Kresh's Back",
url: model("kreshs-back-13245"),
attachment: "shield",
fallbackKind: "shield",
weaponStance: "one-handed",
}],
},
});
export function resolveEquipmentAppearance(
items: readonly InventoryItem[],
gender: GenderId,
): ResolvedEquipmentAppearance {
const layers: EquipmentTextureLayer[] = [];
const models: EquipmentModelVisual[] = [];
const occupiedAttachments = new Set<EquipmentAttachmentName>();
const geosets: Record<string, number> = { Cloak: 0 };
let cloakUrl = "";
const equippedVisuals = items.flatMap((item) => {
const visual = EQUIPMENT_VISUALS[item.itemId];
return visual ? [{ item, visual }] : [];
});
for (const { item, visual } of equippedVisuals
.slice()
.sort((left, right) => (left.visual.layerOrder ?? 0) - (right.visual.layerOrder ?? 0))) {
for (const layer of visual.layers ?? []) {
const url = layer.url ?? layer[gender];
if (url) layers.push({ url, region: layer.region });
}
Object.assign(geosets, visual.geosets);
if (visual.cloakUrl) cloakUrl = visual.cloakUrl;
for (const itemModel of visual.models ?? []) {
if (occupiedAttachments.has(itemModel.attachment)) continue;
occupiedAttachments.add(itemModel.attachment);
models.push({ ...itemModel, itemId: item.itemId });
}
}
return {
cacheKey: items.map((item) => item.itemId).join(","),
bodyLayers: layers,
cloakUrl,
geosets,
models,
};
}
export function equipmentModelUrls(): readonly string[] {
return Object.values(EQUIPMENT_VISUALS)
.flatMap((visual) => visual.models?.map((itemModel) => itemModel.url) ?? []);
}
export function equipmentAssetUrls(): readonly string[] {
const urls = new Set<string>();
for (const visual of Object.values(EQUIPMENT_VISUALS)) {
for (const itemModel of visual.models ?? []) urls.add(itemModel.url);
for (const layer of visual.layers ?? []) {
if (layer.url) urls.add(layer.url);
if (layer.male) urls.add(layer.male);
if (layer.female) urls.add(layer.female);
}
if (visual.cloakUrl) urls.add(visual.cloakUrl);
}
return [...urls];
}