267 lines
8.7 KiB
TypeScript
267 lines
8.7 KiB
TypeScript
import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog";
|
|
import {
|
|
contentPackIsUsable,
|
|
resolveContentUrl,
|
|
} from "../content/contentManager";
|
|
|
|
export type TextureTriplet = readonly [string, string, string];
|
|
export type TextureRegion = readonly [number, number, number, number];
|
|
|
|
export interface AvatarSkinOption {
|
|
dbcColor: number;
|
|
base: string;
|
|
underwear: TextureTriplet;
|
|
}
|
|
|
|
export interface AvatarFaceOption {
|
|
dbcVariation: number;
|
|
bySkin: readonly TextureTriplet[];
|
|
}
|
|
|
|
export interface AvatarHairOption {
|
|
dbcVariation: number;
|
|
geoset: number;
|
|
showScalp: boolean;
|
|
byColor: readonly TextureTriplet[];
|
|
}
|
|
|
|
export interface AvatarFeatureOption {
|
|
dbcVariation: number;
|
|
geosets: readonly number[];
|
|
byColor: readonly TextureTriplet[];
|
|
}
|
|
|
|
export interface AvatarAttachmentPoint {
|
|
bone: number;
|
|
position: readonly [number, number, number];
|
|
}
|
|
|
|
export interface RunewakerModularRendererStrategy {
|
|
kind: "runewaker-modular";
|
|
nativeBodyRig: true;
|
|
sharedSkeleton: true;
|
|
starterPaperdoll: true;
|
|
cachedDimensions: readonly string[];
|
|
sourceParts: readonly string[];
|
|
skinPalette: readonly string[];
|
|
hairPalette: readonly string[];
|
|
}
|
|
|
|
export interface AvatarManifestEntry {
|
|
raceId: RaceId;
|
|
gender: GenderId;
|
|
dbcRaceId: number;
|
|
dbcSexId: number;
|
|
sourceModel: string;
|
|
model: string;
|
|
bytes: number;
|
|
scale: number;
|
|
groundOffset: number;
|
|
rotationY: number;
|
|
materialTokens: { body: string; hair: string };
|
|
animations: {
|
|
idle: string;
|
|
walk: string;
|
|
run: string;
|
|
attack?: string;
|
|
cast?: string;
|
|
wound?: string;
|
|
death?: string;
|
|
};
|
|
counts: AppearanceCounts;
|
|
dbcIndices: Record<keyof AppearanceCounts, readonly number[]>;
|
|
skinOptions: readonly AvatarSkinOption[];
|
|
faceOptions: readonly AvatarFaceOption[];
|
|
hairOptions: readonly AvatarHairOption[];
|
|
featureOptions: readonly AvatarFeatureOption[];
|
|
attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>;
|
|
rendererStrategy?: RunewakerModularRendererStrategy;
|
|
/** Runtime ownership; generated manifests may omit it. */
|
|
contentPackId?: string;
|
|
}
|
|
|
|
export interface AvatarManifest {
|
|
version: number;
|
|
sourceBuild: string;
|
|
publicRoot: string;
|
|
textureLayout: {
|
|
width: number;
|
|
height: number;
|
|
regions: {
|
|
base: TextureRegion;
|
|
faceUpper: TextureRegion;
|
|
faceLower: TextureRegion;
|
|
torsoUpper: TextureRegion;
|
|
pelvisUpper: TextureRegion;
|
|
};
|
|
};
|
|
entries: Record<string, AvatarManifestEntry>;
|
|
}
|
|
|
|
/** Backward-compatible name used by asset verification and older build tools. */
|
|
export type CharacterManifest = AvatarManifest;
|
|
|
|
export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json";
|
|
export const BASIC_CHARACTER_CONTENT_PACK_ID = "core:character-basic";
|
|
export const WOW_CHARACTER_CONTENT_PACK_ID = "characters:wow335a-v1";
|
|
export const AVATAR_MANIFEST_SOURCES = [
|
|
{
|
|
packIds: [BASIC_CHARACTER_CONTENT_PACK_ID, WOW_CHARACTER_CONTENT_PACK_ID],
|
|
url: AVATAR_MANIFEST_URL,
|
|
},
|
|
{
|
|
packIds: ["characters:ascension-v1"],
|
|
url: "/assets/characters/ascension-v1/manifest.json",
|
|
},
|
|
{
|
|
packIds: ["characters:runewaker-v1"],
|
|
url: "/assets/characters/runewaker-v1/manifest.json",
|
|
},
|
|
] as const;
|
|
export const AVATAR_MANIFEST_URLS = AVATAR_MANIFEST_SOURCES.map((source) => source.url);
|
|
let manifestRequest: { readonly key: string; readonly promise: Promise<AvatarManifest> } | null = null;
|
|
|
|
interface AvatarManifestResponse {
|
|
readonly ok: boolean;
|
|
readonly status: number;
|
|
json: () => Promise<unknown>;
|
|
}
|
|
|
|
export function avatarManifestKey(raceId: RaceId, gender: GenderId): string {
|
|
return `${raceId}:${gender}`;
|
|
}
|
|
|
|
export function availableAvatarManifestUrls(
|
|
isUsable: (packId: string) => boolean = contentPackIsUsable,
|
|
): readonly string[] {
|
|
return AVATAR_MANIFEST_SOURCES
|
|
.filter((source) => source.packIds.some((packId) => isUsable(packId)))
|
|
.map((source) => source.url);
|
|
}
|
|
|
|
function contentPackForAvatarEntry(url: string, entry: AvatarManifestEntry): string {
|
|
if (url === AVATAR_MANIFEST_URL) {
|
|
return entry.raceId === "human"
|
|
? BASIC_CHARACTER_CONTENT_PACK_ID
|
|
: WOW_CHARACTER_CONTENT_PACK_ID;
|
|
}
|
|
return AVATAR_MANIFEST_SOURCES.find((source) => source.url === url)?.packIds[0]
|
|
?? WOW_CHARACTER_CONTENT_PACK_ID;
|
|
}
|
|
|
|
function mergeAvatarManifests(
|
|
sources: readonly { readonly url: string; readonly manifest: AvatarManifest }[],
|
|
): AvatarManifest {
|
|
const [{ manifest: baseManifest }, ...extensions] = sources;
|
|
return {
|
|
...baseManifest,
|
|
sourceBuild: sources.map(({ manifest }) => manifest.sourceBuild).join(" + "),
|
|
entries: Object.assign({}, ...sources.map(({ manifest, url }) => Object.fromEntries(
|
|
Object.entries(manifest.entries).map(([key, entry]) => [
|
|
key,
|
|
{ ...entry, contentPackId: contentPackForAvatarEntry(url, entry) },
|
|
]),
|
|
))),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Loads each shipped character pack independently. An unavailable optional
|
|
* pack must never hide the valid rigs from another pack.
|
|
*/
|
|
export async function loadAvatarManifestSources(
|
|
urls: readonly string[],
|
|
request: (url: string) => Promise<AvatarManifestResponse>,
|
|
): Promise<AvatarManifest> {
|
|
const results = await Promise.allSettled(urls.map(async (url) => {
|
|
const response = await request(url);
|
|
if (!response.ok) throw new Error(`Avatar manifest request failed for ${url} (${response.status})`);
|
|
const manifest = await response.json() as AvatarManifest;
|
|
if (manifest.version !== 1 || !manifest.entries) {
|
|
throw new Error(`Avatar manifest is malformed: ${url}`);
|
|
}
|
|
return { url, manifest };
|
|
}));
|
|
const sources = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
|
if (!sources.length) {
|
|
const firstFailure = results.find((result) => result.status === "rejected");
|
|
throw firstFailure?.status === "rejected"
|
|
? firstFailure.reason
|
|
: new Error("No playable-character manifests are available");
|
|
}
|
|
results.forEach((result, index) => {
|
|
if (result.status === "rejected") {
|
|
console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason);
|
|
}
|
|
});
|
|
return mergeAvatarManifests(sources);
|
|
}
|
|
|
|
export async function loadAvatarManifest(): Promise<AvatarManifest> {
|
|
const urls = availableAvatarManifestUrls();
|
|
if (!urls.length) throw new Error("No optional playable-character packs are installed");
|
|
const key = urls.join("|");
|
|
if (manifestRequest?.key !== key) {
|
|
const promise = loadAvatarManifestSources(
|
|
urls,
|
|
(url) => fetch(resolveContentUrl(url), { cache: "force-cache" }),
|
|
)
|
|
.catch((error) => {
|
|
if (manifestRequest?.promise === promise) manifestRequest = null;
|
|
throw error;
|
|
});
|
|
manifestRequest = { key, promise };
|
|
}
|
|
return manifestRequest.promise;
|
|
}
|
|
|
|
function firstOrEmpty<T>(values: readonly T[]): readonly T[] {
|
|
return values.length ? [values[0]] : [];
|
|
}
|
|
|
|
function basicHumanEntry(entry: AvatarManifestEntry): AvatarManifestEntry {
|
|
const skin = entry.skinOptions?.[0];
|
|
const face = entry.faceOptions?.[0];
|
|
const hair = entry.hairOptions?.[0];
|
|
const feature = entry.featureOptions?.[0];
|
|
return {
|
|
...entry,
|
|
contentPackId: BASIC_CHARACTER_CONTENT_PACK_ID,
|
|
counts: { skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 },
|
|
dbcIndices: {
|
|
skinColor: firstOrEmpty(entry.dbcIndices?.skinColor ?? []),
|
|
face: firstOrEmpty(entry.dbcIndices?.face ?? []),
|
|
hairStyle: firstOrEmpty(entry.dbcIndices?.hairStyle ?? []),
|
|
hairColor: firstOrEmpty(entry.dbcIndices?.hairColor ?? []),
|
|
feature: firstOrEmpty(entry.dbcIndices?.feature ?? []),
|
|
},
|
|
skinOptions: skin ? [skin] : [],
|
|
faceOptions: face ? [{ ...face, bySkin: firstOrEmpty(face.bySkin) }] : [],
|
|
hairOptions: hair ? [{ ...hair, byColor: firstOrEmpty(hair.byColor) }] : [],
|
|
featureOptions: feature ? [{ ...feature, byColor: firstOrEmpty(feature.byColor) }] : [],
|
|
};
|
|
}
|
|
|
|
export function getAvatarEntry(
|
|
manifest: AvatarManifest,
|
|
raceId: RaceId,
|
|
gender: GenderId,
|
|
isUsable: (packId: string) => boolean = contentPackIsUsable,
|
|
): AvatarManifestEntry | null {
|
|
const requested = manifest.entries[avatarManifestKey(raceId, gender)] ?? null;
|
|
if (requested) {
|
|
const requestedPackUsable = !requested.contentPackId || isUsable(requested.contentPackId);
|
|
if (requested.raceId === "human") {
|
|
if (requestedPackUsable && isUsable(WOW_CHARACTER_CONTENT_PACK_ID)) return requested;
|
|
} else if (requestedPackUsable) {
|
|
return requested;
|
|
}
|
|
}
|
|
const basicHuman = manifest.entries[avatarManifestKey("human", gender)]
|
|
?? manifest.entries[avatarManifestKey("human", "male")]
|
|
?? null;
|
|
return basicHuman && (!basicHuman.contentPackId || isUsable(basicHuman.contentPackId))
|
|
? basicHumanEntry(basicHuman)
|
|
: null;
|
|
}
|