Release Healer Man 0.1.14
This commit is contained in:
1 parent
67d7a8ebe7
commit
7b41b102fa
147 files changed
+169892
-1342
No files matched your search
@@ -36,6 +36,7 @@ import type {
|
||||
} from "../app/characterCatalog";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import {
|
||||
selectCharacterBaseClip,
|
||||
selectCharacterEventClip,
|
||||
@@ -144,6 +145,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
|
||||
manifest: AvatarManifest | null;
|
||||
entry: AvatarManifestEntry | null;
|
||||
} {
|
||||
const contentRevision = useContentManagerSnapshot().installedRevision;
|
||||
const [state, setState] = useState<{ manifest: AvatarManifest; entry: AvatarManifestEntry } | null>(null);
|
||||
const key = identity ? avatarManifestKey(identity.raceId, identity.gender) : "";
|
||||
const raceId = identity?.raceId;
|
||||
@@ -162,7 +164,7 @@ function useManifestEntry(identity: AvatarIdentity | null | undefined): {
|
||||
if (!cancelled) console.warn("Playable-character manifest could not be loaded", error);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [gender, key, raceId]);
|
||||
}, [contentRevision, gender, key, raceId]);
|
||||
return { manifest: state?.manifest ?? null, entry: state?.entry ?? null };
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useGLTF } from "@react-three/drei";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import type { AvatarIdentity } from "./CharacterModel";
|
||||
import {
|
||||
availableAvatarManifestUrls,
|
||||
getAvatarEntry,
|
||||
loadAvatarManifest,
|
||||
} from "./characterManifest";
|
||||
@@ -16,6 +17,7 @@ const requestedModels = new Set<string>();
|
||||
export async function preloadAvatarModels(
|
||||
identities: readonly (AvatarIdentity | null | undefined)[],
|
||||
): Promise<void> {
|
||||
if (!availableAvatarManifestUrls().length) return;
|
||||
const manifest = await loadAvatarManifest();
|
||||
for (const identity of identities) {
|
||||
if (!identity) continue;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
availableAvatarManifestUrls,
|
||||
getAvatarEntry,
|
||||
loadAvatarManifestSources,
|
||||
type AvatarManifest,
|
||||
type AvatarManifestEntry,
|
||||
@@ -33,6 +35,32 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("playable-character manifest loading", () => {
|
||||
it("does not expose optional character manifests until their packs are usable", () => {
|
||||
expect(availableAvatarManifestUrls(() => false)).toEqual([]);
|
||||
expect(availableAvatarManifestUrls((packId) => packId === "core:character-basic")).toEqual([
|
||||
"/assets/characters/wow335a-v1/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the matching basic human when an extra race pack is unavailable", async () => {
|
||||
const loaded = await loadAvatarManifestSources(
|
||||
["/assets/characters/wow335a-v1/manifest.json"],
|
||||
async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => manifest("3.3.5", {
|
||||
"human:male": { raceId: "human", gender: "male", model: "/base-human.glb" },
|
||||
"orc:male": { raceId: "orc", gender: "male", model: "/orc.glb" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const fallback = getAvatarEntry(loaded, "orc", "male", (packId) => packId === "core:character-basic");
|
||||
expect(fallback?.model).toBe("/base-human.glb");
|
||||
expect(fallback?.counts).toEqual({ skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 });
|
||||
expect(getAvatarEntry(loaded, "orc", "male", () => true)?.model).toBe("/orc.glb");
|
||||
});
|
||||
|
||||
it("keeps valid base characters available when an optional pack fails", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
const base = manifest("3.3.5", {
|
||||
|
||||
+108
-20
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user