Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

+108 -20
View File
@@ -1,5 +1,8 @@
import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog";
import { resolveContentUrl } from "../content/contentManager";
import {
contentPackIsUsable,
resolveContentUrl,
} from "../content/contentManager";
export type TextureTriplet = readonly [string, string, string];
export type TextureRegion = readonly [number, number, number, number];
@@ -73,6 +76,8 @@ export interface AvatarManifestEntry {
featureOptions: readonly AvatarFeatureOption[];
attachmentPoints?: Readonly<Record<string, AvatarAttachmentPoint>>;
rendererStrategy?: RunewakerModularRendererStrategy;
/** Runtime ownership; generated manifests may omit it. */
contentPackId?: string;
}
export interface AvatarManifest {
@@ -97,12 +102,24 @@ export interface AvatarManifest {
export type CharacterManifest = AvatarManifest;
export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json";
export const AVATAR_MANIFEST_URLS = [
AVATAR_MANIFEST_URL,
"/assets/characters/ascension-v1/manifest.json",
"/assets/characters/runewaker-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;
let manifestPromise: Promise<AvatarManifest> | null = null;
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;
@@ -114,12 +131,37 @@ export function avatarManifestKey(raceId: RaceId, gender: GenderId): string {
return `${raceId}:${gender}`;
}
function mergeAvatarManifests(manifests: readonly AvatarManifest[]): AvatarManifest {
const [baseManifest, ...extensions] = manifests;
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: manifests.map((manifest) => manifest.sourceBuild).join(" + "),
entries: Object.assign({}, baseManifest.entries, ...extensions.map((manifest) => manifest.entries)),
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) },
]),
))),
};
}
@@ -138,10 +180,10 @@ export async function loadAvatarManifestSources(
if (manifest.version !== 1 || !manifest.entries) {
throw new Error(`Avatar manifest is malformed: ${url}`);
}
return manifest;
return { url, manifest };
}));
const manifests = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
if (!manifests.length) {
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
@@ -152,27 +194,73 @@ export async function loadAvatarManifestSources(
console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason);
}
});
return mergeAvatarManifests(manifests);
return mergeAvatarManifests(sources);
}
export async function loadAvatarManifest(): Promise<AvatarManifest> {
if (!manifestPromise) {
manifestPromise = loadAvatarManifestSources(
AVATAR_MANIFEST_URLS,
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) => {
manifestPromise = null;
if (manifestRequest?.promise === promise) manifestRequest = null;
throw error;
});
manifestRequest = { key, promise };
}
return manifestPromise;
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 {
return manifest.entries[avatarManifestKey(raceId, gender)] ?? 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;
}