Files
Nitro_Render_V3/packages/avatar/src/EffectAssetDownloadManager.ts
T
medievalshell ae9bc8bfce feat(utils): split-aware gamedata loader with tiered merge
Introduces loadGamedata(url, options?) and mergeGamedata(a, b) in
@nitrots/utils. The loader transparently accepts:

- a single-file URL (legacy) -> parsed as before
- a directory URL ending with '/' -> tier-merged from core/custom/seasonal,
  each tier driven by its own manifest.json5

Merge rules:

- arrays of objects sharing an id key (id, classname, name): merged by id,
  later layers overriding earlier ones
- arrays without an id key: concatenated
- plain objects: recursive merge per key
- anything else: later value wins

All gamedata consumers (FurnitureDataLoader, ProductDataLoader,
EffectAssetDownloadManager, AvatarRenderManager actions+figuredata,
LocalizationManager) are migrated to loadGamedata. Behaviour is unchanged
for single-file URLs, so existing deployments need no config changes;
opt-in to split mode by appending '/' to the URL once the layout is in
place.

README updated with the directory layout, merge table and programmatic
usage example. The companion CLI splitter that produces the core/ tier
from legacy files lives in the Nitro V3 client repo.
2026-05-18 21:19:54 +02:00

230 lines
6.9 KiB
TypeScript

import { IAssetManager, IAvatarEffectListener } from '@nitrots/api';
import { GetConfiguration } from '@nitrots/configuration';
import { AvatarRenderEffectLibraryEvent, GetEventDispatcher, NitroEvent, NitroEventType } from '@nitrots/events';
import { loadGamedata } from '@nitrots/utils';
import { AvatarStructure } from './AvatarStructure';
import { EffectAssetDownloadLibrary } from './EffectAssetDownloadLibrary';
export class EffectAssetDownloadManager
{
private _assets: IAssetManager;
private _structure: AvatarStructure;
private _missingMandatoryLibs: string[] = [];
private _effectMap: Map<string, EffectAssetDownloadLibrary[]> = new Map();
private _effectListeners: Map<string, IAvatarEffectListener[]> = new Map();
private _incompleteEffects: Map<string, EffectAssetDownloadLibrary[]> = new Map();
private _currentDownloads: EffectAssetDownloadLibrary[] = [];
private _libraryNames: string[] = [];
private _libraryLoadedCallback: (event: AvatarRenderEffectLibraryEvent) => void = null;
constructor(assets: IAssetManager, structure: AvatarStructure)
{
this._assets = assets;
this._structure = structure;
}
public async init(): Promise<void>
{
this._missingMandatoryLibs = GetConfiguration().getValue<string[]>('avatar.mandatory.effect.libraries');
const url = GetConfiguration().getValue<string>('avatar.effectmap.url');
if(!url || !url.length) throw new Error('Missing "avatar.effectmap.url" in config — add the effect map URL to your renderer-config.json');
let responseData: any;
try
{
responseData = await loadGamedata(url);
}
catch(err)
{
throw new Error(`Could not load effect map from "${ url }" — check "avatar.effectmap.url" in renderer-config.json (${ err?.message || err })`);
}
this.processEffectMap(responseData.effects);
// Store callback for cleanup
this._libraryLoadedCallback = (event: AvatarRenderEffectLibraryEvent) => this.onLibraryLoaded(event);
GetEventDispatcher().addEventListener(NitroEventType.AVATAR_EFFECT_DOWNLOADED, this._libraryLoadedCallback);
await this.processMissingLibraries();
}
public dispose(): void
{
if(this._libraryLoadedCallback)
{
GetEventDispatcher().removeEventListener(NitroEventType.AVATAR_EFFECT_DOWNLOADED, this._libraryLoadedCallback);
this._libraryLoadedCallback = null;
}
this._effectMap.clear();
this._effectListeners.clear();
this._incompleteEffects.clear();
this._currentDownloads = [];
}
private processEffectMap(data: any): void
{
if(!data) return;
const downloadUrl = GetConfiguration().getValue<string>('avatar.asset.effect.url');
for(const effect of data)
{
if(!effect) continue;
const id = (effect.id as string);
const libraryName = (effect.lib as string);
const revision = (effect.revision || '');
if(this._libraryNames.indexOf(libraryName) >= 0) continue;
this._libraryNames.push(libraryName);
const downloadLibrary = new EffectAssetDownloadLibrary(libraryName, revision, downloadUrl, this._assets);
let existing = this._effectMap.get(id);
if(!existing) existing = [];
existing.push(downloadLibrary);
this._effectMap.set(id, existing);
}
}
private async processMissingLibraries(): Promise<void>
{
const promises: Promise<void>[] = [];
this._missingMandatoryLibs.forEach(value =>
{
const libraries = this._effectMap.get(value);
if(libraries) for(const library of libraries) promises.push(library.downloadAsset());
});
this._missingMandatoryLibs = [];
await Promise.all(promises);
}
private onLibraryLoaded(event: AvatarRenderEffectLibraryEvent): void
{
if(!event || !event.library) return;
const loadedEffects: string[] = [];
this._structure.registerAnimation(event.library.animation);
for(const [id, libraries] of this._incompleteEffects.entries())
{
let isReady = true;
for(const library of libraries)
{
if(!library || library.isLoaded) continue;
isReady = false;
break;
}
if(isReady)
{
loadedEffects.push(id);
const listeners = this._effectListeners.get(id);
for(const listener of listeners)
{
if(!listener || listener.disposed) continue;
listener.resetEffect(parseInt(id));
}
this._effectListeners.delete(id);
GetEventDispatcher().dispatchEvent(new NitroEvent(NitroEventType.AVATAR_EFFECT_LOADED));
}
}
for(const id of loadedEffects) this._incompleteEffects.delete(id);
let index = 0;
while(index < this._currentDownloads.length)
{
const download = this._currentDownloads[index];
if(download)
{
if(download.libraryName === event.library.libraryName) this._currentDownloads.splice(index, 1);
}
index++;
}
}
public isAvatarEffectReady(effect: number): boolean
{
return !this.getAvatarEffectPendingLibraries(effect).length;
}
private getAvatarEffectPendingLibraries(id: number): EffectAssetDownloadLibrary[]
{
const pendingLibraries: EffectAssetDownloadLibrary[] = [];
if(!this._structure) return pendingLibraries;
const libraries = this._effectMap.get(id.toString());
if(libraries)
{
for(const library of libraries)
{
if(!library || library.isLoaded) continue;
if(pendingLibraries.indexOf(library) === -1) pendingLibraries.push(library);
}
}
return pendingLibraries;
}
public downloadAvatarEffect(id: number, listener: IAvatarEffectListener): void
{
const pendingLibraries = this.getAvatarEffectPendingLibraries(id);
if(pendingLibraries && pendingLibraries.length)
{
if(listener && !listener.disposed)
{
let listeners = this._effectListeners.get(id.toString());
if(!listeners) listeners = [];
listeners.push(listener);
this._effectListeners.set(id.toString(), listeners);
}
this._incompleteEffects.set(id.toString(), pendingLibraries);
for(const library of pendingLibraries)
{
if(!library) continue;
library.downloadAsset();
}
}
else
{
if(listener && !listener.disposed) listener.resetEffect(id);
}
}
}