Files
Nitro_Render_V3/packages/assets/src/AssetManager.ts
T
medievalshell 9ece87240e feat: branding furni image position + scale (MPU background editor)
Renderer support for the in-client image position editor:
- FurnitureBrandedImageVisualization applies offsetX/Y to the branded image
  layer only (offsetZ stays as z-index/depth), so the image can be moved
  without shifting the furni frame
- new `scale` branding key + FURNITURE_BRANDING_SCALE: zooms the image via a
  real per-sprite scale (RoomObjectSprite.scale, default 1, applied in
  RoomSpriteCanvas) — NOT by writing the read-only width/height
- AssetManager loads external raster images (png/jpg/…) via a CORS <img> +
  Texture.from instead of Assets.load (which didn't load cross-origin images);
  branding image download failures are now surfaced instead of swallowed
2026-05-28 15:29:42 +02:00

382 lines
13 KiB
TypeScript

import { IAssetData, IAssetManager, IGraphicAsset, IGraphicAssetCollection } from '@nitrots/api';
import { NitroBundle, NitroLogger, parseConfigJsonFromResponse } from '@nitrots/utils';
import { AnimatedGIF } from '@pixi/gif';
import { Assets, Spritesheet, SpritesheetData, Texture } from 'pixi.js';
import { GraphicAssetCollection } from './GraphicAssetCollection';
export class AssetManager implements IAssetManager
{
private _textures: Map<string, Texture> = new Map();
private _collections: Map<string, IGraphicAssetCollection> = new Map();
private _missingAssetNames: Set<string> = new Set();
public getTexture(name: string): Texture
{
if(!name) return null;
return this._textures.get(name);
}
public setTexture(name: string, texture: Texture): void
{
if(!name || !texture) return;
texture.label = name;
this._textures.set(name, texture);
}
public getAsset(name: string): IGraphicAsset
{
if(!name) return null;
if(this._missingAssetNames.has(name)) return null;
for(const collection of this._collections.values())
{
if(!collection) continue;
const existing = collection.getAsset(name);
if(!existing) continue;
return existing;
}
this._missingAssetNames.add(name);
return null;
}
public addAssetToCollection(collectionName: string, assetName: string, texture: Texture, override: boolean = true): boolean
{
const collection = this.getCollection(collectionName);
if(!collection) return false;
return collection.addAsset(assetName, texture, override, 0, 0, false, false);
}
public getCollection(name: string): IGraphicAssetCollection
{
if(!name) return null;
return this._collections.get(name) ?? null;
}
public createCollection(data: IAssetData, spritesheet: Spritesheet): IGraphicAssetCollection
{
if(!data) return null;
const collection = new GraphicAssetCollection(data, spritesheet);
for(const [name, texture] of collection.textures.entries()) this.setTexture(name, texture);
this._collections.set(collection.name, collection);
if(this._missingAssetNames.size > 0) this._missingAssetNames.clear();
return collection;
}
public async downloadAssets(urls: string[]): Promise<boolean>
{
if(!urls || !urls.length) return Promise.resolve(true);
await Promise.all(urls.map(url => this.downloadAsset(url)));
return true;
}
public async downloadAsset(url: string): Promise<boolean>
{
try
{
if(!url || !url.length) return false;
if(url.startsWith('local://'))
{
const key = url.substring('local://'.length);
switch(key)
{
case 'room':
await this.loadLocalRoom();
return true;
case 'place_holder':
case 'place_holder_wall':
case 'place_holder_pet':
case 'tile_cursor':
case 'selection_arrow':
case 'avatar_additions':
case 'floor_editor':
case 'group_badge':
await this.loadLocalAsset(key);
return true;
}
return false;
}
if(url.endsWith('.nitro') || url.endsWith('.gif'))
{
let response: Response;
try
{
response = await fetch(url);
}
catch(fetchErr)
{
throw new Error(`Could not fetch "${ url }" — is the URL correct and the server reachable? (${ fetchErr.message })`);
}
if(!response || response.status !== 200) throw new Error(`Failed to load "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
const arrayBuffer = await response.arrayBuffer();
if(url.endsWith('.nitro'))
{
const nitroBundle = await NitroBundle.from(arrayBuffer);
await this.processAsset(nitroBundle.texture, nitroBundle.jsonFile as IAssetData);
}
else
{
try
{
const animatedGif = AnimatedGIF.fromBuffer(arrayBuffer);
const texture = animatedGif.texture;
if(texture) this.setTexture(url, texture);
}
catch(gifErr)
{
const texture = await Assets.load<Texture>(url);
if(texture) this.setTexture(url, texture);
}
}
}
else if(url.endsWith('.json') || url.endsWith('.json5'))
{
let response: Response;
try
{
response = await fetch(url);
}
catch(fetchErr)
{
throw new Error(`Could not fetch "${ url }" — is the URL correct and the server reachable? (${ fetchErr.message })`);
}
if(!response || response.status !== 200) throw new Error(`Failed to load "${ url }" — server returned HTTP ${ response?.status ?? 'no response' }`);
let data: IAssetData;
try
{
data = await parseConfigJsonFromResponse<IAssetData>(response, url);
}
catch(parseErr)
{
throw new Error(`Invalid asset data "${ url }" — JSON/JSON5 parse failed (${ parseErr.message })`);
}
let texture: Texture = null;
const imagePath = data?.spritesheet?.meta?.image;
const fallbackImagePath = ((data?.name && data.name.length > 0)
? `${data.name}.png`
: url.replace(/\.json5?$/i, '.png'));
const resolvedImageUrl = (imagePath
? new URL(imagePath, url).toString()
: new URL(fallbackImagePath, url).toString());
texture = await Assets.load<Texture>(resolvedImageUrl);
await this.processAsset(texture, data);
}
else
{
// External raster images (png/jpg/webp/…). Pixi's Assets.load does
// not reliably load arbitrary cross-origin images in this setup, so
// load them through a CORS-enabled <img> + Texture.from — the same
// approach the badge / dynamic-thumbnail visualizations use.
const texture = await this.loadExternalImageTexture(url);
if(texture) this.setTexture(url, texture);
}
return true;
}
catch (err)
{
throw new Error(`Asset loading failed for "${ url }": ${ err.message || err }`);
}
}
private loadExternalImageTexture(url: string): Promise<Texture>
{
return new Promise<Texture>((resolve, reject) =>
{
const image = new Image();
// crossOrigin must be set BEFORE src so the request is made with CORS
// and the resulting texture isn't tainted (WebGL would refuse it).
image.crossOrigin = 'anonymous';
image.onload = () =>
{
image.onload = null;
image.onerror = null;
if(image.complete && (image.width > 0) && (image.height > 0))
{
const texture = Texture.from(image);
if(texture.source) texture.source.scaleMode = 'linear';
resolve(texture);
}
else
{
reject(new Error(`image had no dimensions`));
}
};
image.onerror = () =>
{
image.onload = null;
image.onerror = null;
reject(new Error(`image element failed to load (CORS / network / 404)`));
};
image.src = url;
});
}
private async loadLocalRoom(): Promise<void>
{
const roomDataModule = await import('./assets/room/room.asset.json');
const roomData = (roomDataModule.default ?? roomDataModule) as IAssetData;
const collection = this.createCollection(roomData, null) as GraphicAssetCollection;
if(!collection) return;
const roomImages = import.meta.glob('./assets/room/*.png', { eager: true });
const roomImagesSub = import.meta.glob('./assets/room/images/*.png', { eager: true });
const merged = { ...roomImages, ...roomImagesSub };
for(const path in merged)
{
const mod = merged[path];
const imageUrl = ((mod as { default?: string }).default ?? mod) as string;
const file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, '');
const texture = await Assets.load<Texture>(imageUrl);
if(!texture) continue;
this.setTexture(rawName, texture);
collection.textures.set(rawName, texture);
if(rawName.startsWith('room_'))
{
const normalizedName = rawName.substring('room_'.length);
this.setTexture(normalizedName, texture);
collection.textures.set(normalizedName, texture);
}
}
collection.define(roomData);
}
private async loadLocalAsset(name: string): Promise<void>
{
let dataModule: any;
switch(name)
{
case 'place_holder':
dataModule = await import('./assets/place_holder/place_holder.asset.json');
break;
case 'place_holder_wall':
dataModule = await import('./assets/place_holder_wall/place_holder_wall.asset.json');
break;
case 'place_holder_pet':
dataModule = await import('./assets/place_holder_pet/place_holder_pet.asset.json');
break;
case 'tile_cursor':
dataModule = await import('./assets/tile_cursor/tile_cursor.asset.json');
break;
case 'selection_arrow':
dataModule = await import('./assets/selection_arrow/selection_arrow.asset.json');
break;
case 'avatar_additions':
dataModule = await import('./assets/avatar_additions/avatar_additions.asset.json');
break;
case 'floor_editor':
dataModule = await import('./assets/floor_editor/floor_editor.asset.json');
break;
case 'group_badge':
dataModule = await import('./assets/group_badge/group_badge.asset.json');
break;
default:
return;
}
const data = (dataModule.default ?? dataModule) as IAssetData;
const collection = this.createCollection(data, null) as GraphicAssetCollection;
if(!collection) return;
const allImages = import.meta.glob('./assets/*/images/*.png', { eager: true });
const prefix = `./assets/${name}/images/`;
for(const path in allImages)
{
if(!path.startsWith(prefix)) continue;
const mod = allImages[path];
const imageUrl = ((mod as { default?: string }).default ?? mod) as string;
const file = path.split('/').pop()!;
const rawName = file.replace(/\.png$/i, '');
const texture = await Assets.load<Texture>(imageUrl);
if(!texture) continue;
this.setTexture(rawName, texture);
collection.textures.set(name + '_' + rawName, texture);
collection.textures.set(rawName, texture);
}
collection.define(data);
}
private async processAsset(texture: Texture, data: IAssetData): Promise<IGraphicAssetCollection>
{
let spritesheet: Spritesheet<SpritesheetData> = null;
if(texture && data?.spritesheet && Object.keys(data.spritesheet).length)
{
spritesheet = new Spritesheet(texture, data.spritesheet);
await spritesheet.parse();
spritesheet.textureSource.label = data.name ?? null;
}
return this.createCollection(data, spritesheet);
}
public get collections(): Map<string, IGraphicAssetCollection>
{
return this._collections;
}
}